Files
shadow-gunbuilder-ai-proto/app/builds/page.tsx
T

366 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
type BuildClass = "Rifle" | "Pistol" | "NFA";
type Caliber = "5.56" | "7.62" | "9mm" | ".300 BLK" | "12ga";
type BuildCard = {
id: string;
title: string;
slug: string;
creator: string;
caliber: Caliber;
buildClass: BuildClass;
price: number;
votes: number;
tags: string[];
};
const DUMMY_BUILDS: BuildCard[] = [
{
id: "1",
title: "Duty-Grade 12.5\" AR-15",
slug: "duty-12-5-ar15",
creator: "quietpro_01",
caliber: "5.56",
buildClass: "Rifle",
price: 2450,
votes: 128,
tags: ["Duty", "NV-Ready", "LPVO"],
},
{
id: "2",
title: "Home Defense PCC",
slug: "home-defense-pcc",
creator: "nerdgunner",
caliber: "9mm",
buildClass: "Pistol",
price: 1650,
votes: 74,
tags: ["Home Defense", "Red Dot", "Suppressor-Ready"],
},
{
id: "3",
title: "Short King .300 BLK SBR",
slug: "short-king-300blk-sbr",
creator: "subsonic_six",
caliber: ".300 BLK",
buildClass: "NFA",
price: 3125,
votes: 201,
tags: ["SBR", "Suppressed", "Night Work"],
},
{
id: "4",
title: "Budget 16\" Training Rifle",
slug: "budget-16-training-rifle",
creator: "range_rat",
caliber: "5.56",
buildClass: "Rifle",
price: 975,
votes: 53,
tags: ["Budget", "Trainer", "Recce-ish"],
},
];
const CALIBER_FILTERS: (Caliber | "all")[] = [
"all",
"5.56",
"7.62",
"9mm",
".300 BLK",
"12ga",
];
const CLASS_FILTERS: (BuildClass | "all")[] = ["all", "Rifle", "Pistol", "NFA"];
const PRICE_FILTERS = [
{ id: "all", label: "All", min: 0, max: Infinity },
{ id: "sub1k", label: "< $1k", min: 0, max: 1000 },
{ id: "1to2k", label: "$1k$2k", min: 1000, max: 2000 },
{ id: "2to3k", label: "$2k$3k", min: 2000, max: 3000 },
{ id: "3kplus", label: "$3k+", min: 3000, max: Infinity },
];
export default function BuildsPage() {
const [caliberFilter, setCaliberFilter] = useState<Caliber | "all">("all");
const [classFilter, setClassFilter] = useState<BuildClass | "all">("all");
const [priceFilterId, setPriceFilterId] = useState<string>("all");
const [votes, setVotes] = useState<Record<string, number>>(
Object.fromEntries(DUMMY_BUILDS.map((b) => [b.id, b.votes])),
);
const activePriceFilter = PRICE_FILTERS.find(
(p) => p.id === priceFilterId,
) ?? PRICE_FILTERS[0];
const filteredBuilds = useMemo(() => {
return DUMMY_BUILDS.filter((build) => {
const matchesCaliber =
caliberFilter === "all" || build.caliber === caliberFilter;
const matchesClass =
classFilter === "all" || build.buildClass === classFilter;
const matchesPrice =
build.price >= activePriceFilter.min &&
build.price < activePriceFilter.max;
return matchesCaliber && matchesClass && matchesPrice;
});
}, [caliberFilter, classFilter, activePriceFilter]);
const handleVote = (id: string, delta: 1 | -1) => {
setVotes((prev) => ({
...prev,
[id]: (prev[id] ?? 0) + delta,
}));
};
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
{/* Header */}
<header className="mb-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
Battl Builder · Build Book
</p>
<h1
className="mt-2 text-3xl md:text-4xl font-extrabold tracking-tight
bg-gradient-to-r from-amber-400 via-amber-200 to-amber-400
text-transparent bg-clip-text drop-shadow-[0_2px_6px_rgba(255,193,7,0.25)]"
>
Community Builds
</h1>
<p className="mt-2 text-sm md:text-base text-zinc-400 max-w-xl">
Browse community rifle builds, vote them up or down, and steal
good ideas without shame. This is placeholder content for now
real accounts, comments, and saved builds will wire in later.
</p>
</div>
<div className="flex gap-2 md:gap-3">
<Link
href="/builder"
className="inline-flex items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/60 px-4 py-2 text-xs md:text-sm font-medium text-zinc-200 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
>
Open Builder
</Link>
<button
type="button"
className="inline-flex items-center justify-center rounded-md border border-amber-400/70 bg-amber-400/10 px-4 py-2 text-xs md:text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
>
+ Submit Build (Coming Soon)
</button>
</div>
</header>
{/* Filters */}
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-xs font-semibold tracking-[0.16em] uppercase text-zinc-400">
Filter Builds
</h2>
<p className="text-xs text-zinc-500 mt-1">
Filter by caliber, platform class, and ballpark price range.
All logic is client-side placeholder until the real feed is wired
into the backend.
</p>
</div>
<div className="text-xs text-zinc-500">
Showing{" "}
<span className="text-zinc-200 font-medium">
{filteredBuilds.length}
</span>{" "}
of{" "}
<span className="text-zinc-200 font-medium">
{DUMMY_BUILDS.length}
</span>{" "}
demo builds
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
{/* Caliber filter */}
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
Caliber
</div>
<div className="flex flex-wrap gap-2">
{CALIBER_FILTERS.map((caliber) => (
<button
key={caliber}
type="button"
onClick={() =>
setCaliberFilter(caliber === "all" ? "all" : caliber)
}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
caliberFilter === caliber
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
}`}
>
{caliber === "all" ? "All" : caliber}
</button>
))}
</div>
</div>
{/* Class filter */}
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
Class
</div>
<div className="flex flex-wrap gap-2">
{CLASS_FILTERS.map((cls) => (
<button
key={cls}
type="button"
onClick={() =>
setClassFilter(cls === "all" ? "all" : cls)
}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
classFilter === cls
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
}`}
>
{cls === "all" ? "All" : cls}
</button>
))}
</div>
</div>
{/* Price filter */}
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
Price Range
</div>
<div className="flex flex-wrap gap-2">
{PRICE_FILTERS.map((range) => (
<button
key={range.id}
type="button"
onClick={() => setPriceFilterId(range.id)}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
priceFilterId === range.id
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
}`}
>
{range.label}
</button>
))}
</div>
</div>
</div>
</section>
{/* Build list */}
<section>
<div className="space-y-4">
{filteredBuilds.map((build) => (
<article
key={build.id}
className="flex gap-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4"
>
{/* Votes column */}
<div className="flex flex-col items-center justify-center gap-1">
<button
type="button"
onClick={() => handleVote(build.id, 1)}
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
>
</button>
<div className="text-xs font-semibold text-amber-200">
{votes[build.id] ?? build.votes}
</div>
<button
type="button"
onClick={() => handleVote(build.id, -1)}
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
>
</button>
</div>
{/* Placeholder thumbnail */}
<div className="hidden sm:block">
<div className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/80 h-24 w-40">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="https://placehold.co/320x200/png?text=Build+Photo"
alt={`${build.title} placeholder`}
className="h-full w-full object-cover"
/>
</div>
</div>
{/* Main content */}
<div className="flex-1">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
{build.buildClass} · {build.caliber}
</div>
<h3 className="text-lg md:text-xl font-semibold text-zinc-50">
<Link
href={`/builds/${build.slug}`}
className="hover:text-amber-200 transition-colors"
>
{build.title}
</Link>
</h3>
<p className="text-xs text-zinc-500 mt-1">
Posted by{" "}
<span className="text-zinc-300">
b/{build.creator}
</span>{" "}
· Placeholder build listing details, parts list, and
comments will live on the build detail page later.
</p>
</div>
<div className="text-right mt-1 md:mt-0">
<div className="text-xs uppercase tracking-[0.16em] text-zinc-500">
Est. Build Cost
</div>
<div className="text-lg font-semibold text-amber-300">
${build.price.toLocaleString()}
</div>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{build.tags.map((tag) => (
<span
key={tag}
className="rounded-full border border-zinc-700 bg-zinc-900/60 px-2 py-0.5 text-[0.7rem] text-zinc-400"
>
{tag}
</span>
))}
<span className="ml-auto text-[0.7rem] text-zinc-500">
Comments, save, and share coming soon.
</span>
</div>
</div>
</article>
))}
{filteredBuilds.length === 0 && (
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-6 text-center text-sm text-zinc-500">
No builds match those filters yet. Once the real feed is wired
up, this will update live as the community posts rifles, pistols,
and NFA builds.
</div>
)}
</div>
</section>
</div>
</main>
);
}