new pages for user buils (vault), edit saved builds, and the community build page.
This commit is contained in:
+293
-178
@@ -1,103 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
/**
|
||||
* app/builds/page.tsx
|
||||
* Community Builds feed (public)
|
||||
*
|
||||
* Dev Notes:
|
||||
* - We are moving to Option B:
|
||||
* Build (core) + BuildProfile (meta) + Votes/Comments/Media as separate tables.
|
||||
* - This page expects a lightweight "feed card" DTO from the backend, NOT full build items.
|
||||
* - Keep UI/filters here client-side for MVP; move to server-side query params later if needed.
|
||||
*
|
||||
* Backend (target contract):
|
||||
* GET /api/v1/builds?limit=50 -> BuildFeedCardDto[]
|
||||
* (Optional later) POST /api/v1/builds/{uuid}/vote { delta: 1 | -1 }
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
type BuildClass = "Rifle" | "Pistol" | "NFA";
|
||||
type Caliber = "5.56" | "7.62" | "9mm" | ".300 BLK" | "12ga";
|
||||
|
||||
/**
|
||||
* Keep this loose. Backend will control the allowed calibers via BuildProfile.
|
||||
* We can tighten once the controlled vocab is finalized.
|
||||
*/
|
||||
type Caliber = string;
|
||||
|
||||
/**
|
||||
* This matches what the UI needs (card format).
|
||||
* It maps cleanly to: builds + build_profiles + aggregated votes (+ optional price).
|
||||
*/
|
||||
type BuildCard = {
|
||||
id: string;
|
||||
id: string; // we use uuid here (public identifier)
|
||||
title: string;
|
||||
slug: string;
|
||||
creator: string;
|
||||
slug: string; // can be uuid for now; keep property so UI does not change
|
||||
creator: string; // placeholder until user profiles are real
|
||||
caliber: Caliber;
|
||||
buildClass: BuildClass;
|
||||
price: number;
|
||||
price: number; // optional server-side later; for now allow 0 fallback
|
||||
votes: number;
|
||||
tags: string[];
|
||||
coverImageUrl?: string | null;
|
||||
};
|
||||
|
||||
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"],
|
||||
},
|
||||
];
|
||||
type BuildFeedCardDto = {
|
||||
uuid: string;
|
||||
title?: string | null;
|
||||
slug?: string | null; // optional (we can generate from title later)
|
||||
creator?: string | null; // optional
|
||||
caliber?: string | null;
|
||||
buildClass?: BuildClass | null;
|
||||
price?: number | null;
|
||||
votes?: number | null;
|
||||
tags?: string[] | null;
|
||||
coverImageUrl?: string | null;
|
||||
};
|
||||
|
||||
const CALIBER_FILTERS: (Caliber | "all")[] = [
|
||||
"all",
|
||||
"5.56",
|
||||
"7.62",
|
||||
"9mm",
|
||||
".300 BLK",
|
||||
"12ga",
|
||||
];
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||
|
||||
// --- UI Filter Sets (keep; we’ll generate options from data too) ---
|
||||
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 },
|
||||
{ id: "sub1k", label: "< $1k", min: 0, max: 1000_00 },
|
||||
{ id: "1to2k", label: "$1k–$2k", min: 1000_00, max: 2000_00 },
|
||||
{ id: "2to3k", label: "$2k–$3k", min: 2000_00, max: 3000_00 },
|
||||
{ id: "3kplus", label: "$3k+", min: 3000_00, max: Infinity },
|
||||
];
|
||||
|
||||
function safeArray(v: unknown): string[] {
|
||||
return Array.isArray(v) ? v.filter(Boolean).map(String) : [];
|
||||
}
|
||||
|
||||
function fallbackSlug(dto: BuildFeedCardDto) {
|
||||
// MVP: stable route id is uuid. Keep a "slug" field for UI continuity.
|
||||
return dto.slug?.trim() || dto.uuid;
|
||||
}
|
||||
|
||||
function normalizeCard(dto: BuildFeedCardDto): BuildCard {
|
||||
const title = (dto.title ?? "Untitled Build").trim() || "Untitled Build";
|
||||
|
||||
return {
|
||||
id: dto.uuid,
|
||||
title,
|
||||
slug: fallbackSlug(dto),
|
||||
|
||||
// Until we wire real users: keep a placeholder creator string.
|
||||
creator: (dto.creator ?? "anonymous").trim() || "anonymous",
|
||||
|
||||
caliber: (dto.caliber ?? "—").trim() || "—",
|
||||
|
||||
// Default to Rifle for display if missing; backend should set this via profile.
|
||||
buildClass: (dto.buildClass ?? "Rifle") as BuildClass,
|
||||
|
||||
// Price is optional (we can compute later from BuildItems + offers).
|
||||
price: typeof dto.price === "number" ? dto.price : 0,
|
||||
|
||||
votes: typeof dto.votes === "number" ? dto.votes : 0,
|
||||
|
||||
tags: safeArray(dto.tags),
|
||||
|
||||
coverImageUrl: dto.coverImageUrl ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export default function BuildsPage() {
|
||||
// --- Remote data state ---
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [builds, setBuilds] = useState<BuildCard[]>([]);
|
||||
|
||||
// --- UI state (filters/votes) ---
|
||||
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];
|
||||
// Local vote state for optimistic UI
|
||||
const [votes, setVotes] = useState<Record<string, number>>({});
|
||||
|
||||
const activePriceFilter =
|
||||
PRICE_FILTERS.find((p) => p.id === priceFilterId) ?? PRICE_FILTERS[0];
|
||||
|
||||
// --- Fetch public builds feed ---
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/v1/builds?limit=50`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "");
|
||||
throw new Error(txt || `Failed to load builds (${res.status})`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as BuildFeedCardDto[];
|
||||
const cards = (Array.isArray(data) ? data : []).map(normalizeCard);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setBuilds(cards);
|
||||
|
||||
// Initialize vote UI from payload totals
|
||||
setVotes(Object.fromEntries(cards.map((b) => [b.id, b.votes])));
|
||||
} catch (e: any) {
|
||||
if (!cancelled) setError(e?.message || "Failed to load builds feed");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Build the caliber filters dynamically from data, but keep "all" first.
|
||||
const CALIBER_FILTERS = useMemo<(Caliber | "all")[]>(() => {
|
||||
const uniq = new Set<string>();
|
||||
for (const b of builds) {
|
||||
const c = (b.caliber ?? "").trim();
|
||||
if (c && c !== "—") uniq.add(c);
|
||||
}
|
||||
return ["all", ...Array.from(uniq).sort()];
|
||||
}, [builds]);
|
||||
|
||||
const filteredBuilds = useMemo(() => {
|
||||
return DUMMY_BUILDS.filter((build) => {
|
||||
return builds.filter((build) => {
|
||||
const matchesCaliber =
|
||||
caliberFilter === "all" || build.caliber === caliberFilter;
|
||||
|
||||
@@ -110,14 +189,16 @@ export default function BuildsPage() {
|
||||
|
||||
return matchesCaliber && matchesClass && matchesPrice;
|
||||
});
|
||||
}, [caliberFilter, classFilter, activePriceFilter]);
|
||||
}, [builds, caliberFilter, classFilter, activePriceFilter]);
|
||||
|
||||
const handleVote = (id: string, delta: 1 | -1) => {
|
||||
const handleVote = useCallback((id: string, delta: 1 | -1) => {
|
||||
// MVP: optimistic local vote only.
|
||||
// Later: POST /api/v1/builds/{uuid}/vote { delta } and reconcile.
|
||||
setVotes((prev) => ({
|
||||
...prev,
|
||||
[id]: (prev[id] ?? 0) + delta,
|
||||
}));
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-black text-zinc-50">
|
||||
@@ -129,18 +210,19 @@ export default function BuildsPage() {
|
||||
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
|
||||
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.
|
||||
Browse community builds, vote them up or down, and steal good
|
||||
ideas without shame. This feed is backed by public builds
|
||||
(isPublic=true).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 md:gap-3">
|
||||
<Link
|
||||
href="/builder"
|
||||
@@ -148,15 +230,35 @@ export default function BuildsPage() {
|
||||
>
|
||||
Open Builder
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
{/* Later: route to /vault + choose build to submit, or direct /builder submit flow */}
|
||||
<Link
|
||||
href="/vault"
|
||||
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>
|
||||
+ Submit Build
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Loading / Error */}
|
||||
{loading && (
|
||||
<div className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 text-sm text-zinc-400">
|
||||
Loading community builds…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<div className="mb-6 rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-200">
|
||||
{error}
|
||||
<div className="mt-2 text-xs text-red-200/80">
|
||||
Dev tip: confirm backend route{" "}
|
||||
<span className="font-mono">GET /api/v1/builds</span> is
|
||||
implemented + CORS is configured.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
@@ -165,21 +267,19 @@ export default function BuildsPage() {
|
||||
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.
|
||||
Filter by caliber, class, and price range. (Client-side for
|
||||
MVP.)
|
||||
</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
|
||||
<span className="text-zinc-200 font-medium">{builds.length}</span>{" "}
|
||||
builds
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -219,9 +319,7 @@ export default function BuildsPage() {
|
||||
<button
|
||||
key={cls}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setClassFilter(cls === "all" ? "all" : cls)
|
||||
}
|
||||
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"
|
||||
@@ -262,100 +360,117 @@ export default function BuildsPage() {
|
||||
{/* 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>
|
||||
{filteredBuilds.map((build) => {
|
||||
const dollars = Math.floor((build.price ?? 0) / 100);
|
||||
|
||||
{/* 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"
|
||||
/>
|
||||
return (
|
||||
<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"
|
||||
aria-label="Upvote"
|
||||
title="Upvote"
|
||||
>
|
||||
▲
|
||||
</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"
|
||||
aria-label="Downvote"
|
||||
title="Downvote"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</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}
|
||||
{/* 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={
|
||||
build.coverImageUrl ||
|
||||
"https://placehold.co/320x200/png?text=Build+Photo"
|
||||
}
|
||||
alt={`${build.title} cover`}
|
||||
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>{" "}
|
||||
· Build detail page will show the parts list +
|
||||
comments.
|
||||
</p>
|
||||
</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"
|
||||
|
||||
<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 > 0 ? (
|
||||
<span>
|
||||
${Math.floor(build.price / 100).toLocaleString()}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</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"
|
||||
>
|
||||
{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>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
|
||||
<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 className="ml-auto text-[0.7rem] text-zinc-500">
|
||||
Comments + save coming soon.
|
||||
</span>
|
||||
))}
|
||||
<span className="ml-auto text-[0.7rem] text-zinc-500">
|
||||
Comments, save, and share coming soon.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredBuilds.length === 0 && (
|
||||
{!loading && !error && 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.
|
||||
No builds match those filters yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -363,4 +478,4 @@ export default function BuildsPage() {
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user