new pages for user buils (vault), edit saved builds, and the community build page.

This commit is contained in:
2025-12-20 10:01:07 -05:00
parent f81974cc0b
commit 61935982b3
10 changed files with 1405 additions and 316 deletions
+303 -95
View File
@@ -1,6 +1,19 @@
// app/(builder)/builder/page.tsx // app/(builder)/builder/page.tsx
"use client"; "use client";
/**
* Battl Builder - Main builder page
*
* Key flows:
* 1) Build selections are stored locally (localStorage) so users can come back.
* 2) "Save As…" creates a NEW saved build in the user's Vault (server-side).
* 3) Vault -> Builder loads builds via ?load=<uuid> (also supports legacy ?build=<uuid>).
*
* Notes for devs:
* - We intentionally keep ONE save-to-vault handler: handleSaveAs()
* - Toast is used for clear user feedback (save success/fail)
*/
import { useMemo, useState, useEffect, useCallback, useRef } from "react"; import { useMemo, useState, useEffect, useCallback, useRef } from "react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation"; import { useSearchParams, useRouter } from "next/navigation";
@@ -31,6 +44,11 @@ type GunbuilderProductFromApi = {
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const; const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
const isUuid = (v: string) =>
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
v
);
const API_BASE_URL = const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
@@ -114,10 +132,24 @@ export default function GunbuilderPage() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
// -----------------------------
// Save As… modal state (Vault variants)
// -----------------------------
const [saveAsOpen, setSaveAsOpen] = useState(false);
const [saveAsTitle, setSaveAsTitle] = useState("");
const [saveAsDesc, setSaveAsDesc] = useState("");
const [saveAsSaving, setSaveAsSaving] = useState(false); // prevents double-submit
// -----------------------------
// Parts data state
// -----------------------------
const [parts, setParts] = useState<Part[]>([]); const [parts, setParts] = useState<Part[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// -----------------------------
// Platform state (AR-15/AR-10/AR-9)
// -----------------------------
const isValidPlatform = ( const isValidPlatform = (
value: string | null value: string | null
): value is (typeof PLATFORMS)[number] => ): value is (typeof PLATFORMS)[number] =>
@@ -129,6 +161,9 @@ export default function GunbuilderPage() {
return isValidPlatform(initial) ? initial : "AR-15"; return isValidPlatform(initial) ? initial : "AR-15";
}); });
// -----------------------------
// Build state (categoryId -> productId), persisted locally
// -----------------------------
const [build, setBuild] = useState<BuildState>(() => { const [build, setBuild] = useState<BuildState>(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
@@ -143,6 +178,20 @@ export default function GunbuilderPage() {
return {}; return {};
}); });
// -----------------------------
// Toast notifications (save success / errors)
// -----------------------------
type Toast = { type: "success" | "error" | "info"; message: string };
const [toast, setToast] = useState<Toast | null>(null);
const showToast = useCallback((t: Toast) => {
setToast(t);
window.setTimeout(() => setToast(null), 3500);
}, []);
// -----------------------------
// Misc UI state
// -----------------------------
const [shareStatus, setShareStatus] = useState<string | null>(null); const [shareStatus, setShareStatus] = useState<string | null>(null);
const [shareUrl, setShareUrl] = useState<string>(""); const [shareUrl, setShareUrl] = useState<string>("");
@@ -153,7 +202,9 @@ export default function GunbuilderPage() {
// ✅ Guard to prevent infinite loops when processing ?select / ?remove // ✅ Guard to prevent infinite loops when processing ?select / ?remove
const processedActionKeyRef = useRef<string>(""); const processedActionKeyRef = useRef<string>("");
// ---------- Derived ---------- // -----------------------------
// Derived collections
// -----------------------------
const partsByCategory: Record<CategoryId, Part[]> = useMemo(() => { const partsByCategory: Record<CategoryId, Part[]> = useMemo(() => {
const grouped = {} as Record<CategoryId, Part[]>; const grouped = {} as Record<CategoryId, Part[]>;
for (const category of CATEGORIES) { for (const category of CATEGORIES) {
@@ -177,8 +228,26 @@ export default function GunbuilderPage() {
}); });
}, [build, parts]); }, [build, parts]);
const selectedByCategory: Record<CategoryId, unknown> = useMemo(
() =>
Object.fromEntries(
Object.entries(build).map(([categoryId, partId]) => [
categoryId as CategoryId,
partId ? true : false,
])
) as Record<CategoryId, unknown>,
[build]
);
const totalPrice = useMemo(
() => selectedParts.reduce((sum, p) => sum + p.price, 0),
[selectedParts]
);
// -----------------------------
// Role -> Category resolver // Role -> Category resolver
// NOTE: defensive while backend roles/mappings evolve // NOTE: defensive while backend roles/mappings evolve
// -----------------------------
const resolveCategoryId = (normalizedRole: string): CategoryId | null => { const resolveCategoryId = (normalizedRole: string): CategoryId | null => {
if (normalizedRole === "upper-receiver") return "upper-receiver"; if (normalizedRole === "upper-receiver") return "upper-receiver";
if (normalizedRole.includes("complete-upper")) return "complete-upper"; if (normalizedRole.includes("complete-upper")) return "complete-upper";
@@ -271,22 +340,9 @@ export default function GunbuilderPage() {
return (PART_ROLE_TO_CATEGORY as any)[normalizedRole] ?? null; return (PART_ROLE_TO_CATEGORY as any)[normalizedRole] ?? null;
}; };
const selectedByCategory: Record<CategoryId, unknown> = useMemo( // -----------------------------
() => // Selection handler (with hint warnings)
Object.fromEntries( // -----------------------------
Object.entries(build).map(([categoryId, partId]) => [
categoryId as CategoryId,
partId ? true : false,
])
) as Record<CategoryId, unknown>,
[build]
);
const totalPrice = useMemo(
() => selectedParts.reduce((sum, p) => sum + p.price, 0),
[selectedParts]
);
const handleSelectPart = useCallback( const handleSelectPart = useCallback(
(categoryId: CategoryId, partId: string, _opts?: { confirm?: boolean }) => { (categoryId: CategoryId, partId: string, _opts?: { confirm?: boolean }) => {
const warn = (msg: string) => { const warn = (msg: string) => {
@@ -305,7 +361,9 @@ export default function GunbuilderPage() {
[build] [build]
); );
// ---------- Fetch products ---------- // -----------------------------
// Fetch products (scoped + universal merge)
// -----------------------------
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
@@ -317,7 +375,6 @@ export default function GunbuilderPage() {
const scopedUrl = `${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent( const scopedUrl = `${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent(
platform platform
)}`; )}`;
const universalUrl = `${API_BASE_URL}/api/v1/products`; const universalUrl = `${API_BASE_URL}/api/v1/products`;
const [scopedRes, universalRes] = await Promise.all([ const [scopedRes, universalRes] = await Promise.all([
@@ -350,6 +407,7 @@ export default function GunbuilderPage() {
const categoryId = resolveCategoryId(normalizedRole); const categoryId = resolveCategoryId(normalizedRole);
if (!categoryId) return null; if (!categoryId) return null;
// Only accept universal fetch results for "universal" categories
if ( if (
data === universalData && data === universalData &&
!UNIVERSAL_CATEGORIES.has(categoryId) !UNIVERSAL_CATEGORIES.has(categoryId)
@@ -377,6 +435,7 @@ export default function GunbuilderPage() {
const scopedParts = normalize(scopedData); const scopedParts = normalize(scopedData);
const universalParts = normalize(universalData); const universalParts = normalize(universalData);
// Merge (avoid duplicates across calls)
const seen = new Set<string>(); const seen = new Set<string>();
const merged: Part[] = []; const merged: Part[] = [];
for (const p of [...scopedParts, ...universalParts]) { for (const p of [...scopedParts, ...universalParts]) {
@@ -399,7 +458,9 @@ export default function GunbuilderPage() {
return () => controller.abort(); return () => controller.abort();
}, [platform]); }, [platform]);
// ✅ Persist build state whenever it changes // -----------------------------
// Persist build to localStorage
// -----------------------------
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
try { try {
@@ -409,7 +470,9 @@ export default function GunbuilderPage() {
} }
}, [build]); }, [build]);
// When the platform changes, clear ONLY platform-scoped selections. // -----------------------------
// When platform changes, clear ONLY platform-scoped selections
// -----------------------------
useEffect(() => { useEffect(() => {
const prev = lastPlatformRef.current; const prev = lastPlatformRef.current;
lastPlatformRef.current = platform; lastPlatformRef.current = platform;
@@ -433,20 +496,25 @@ export default function GunbuilderPage() {
}); });
}, [platform]); }, [platform]);
// ✅ Load a saved build from Vault via ?load=<uuid> // -----------------------------
// Load a saved build from Vault via ?load=<uuid> OR legacy ?build=<uuid>
// -----------------------------
useEffect(() => { useEffect(() => {
const loadUuid = searchParams.get("load"); const loadUuid = searchParams.get("load");
if (!loadUuid) return; const buildParam = searchParams.get("build");
const uuidToLoad =
(loadUuid && isUuid(loadUuid) ? loadUuid : null) ||
(buildParam && isUuid(buildParam) ? buildParam : null);
if (!uuidToLoad) return;
const run = async () => { const run = async () => {
try { try {
setShareStatus("Loading build…"); setShareStatus("Loading build…");
// NOTE: your backend GET route is /api/v1/products/builds/{uuid}
const res = await fetch( const res = await fetch(
`${API_BASE_URL}/api/v1/products/builds/${encodeURIComponent( `${API_BASE_URL}/api/v1/builds/me/${encodeURIComponent(uuidToLoad)}`,
loadUuid
)}`,
{ credentials: "include" } { credentials: "include" }
); );
@@ -466,10 +534,15 @@ export default function GunbuilderPage() {
setBuild(nextBuild); setBuild(nextBuild);
setShareStatus(`Loaded: ${dto.title}`); setShareStatus(`Loaded: ${dto.title}`);
// Clean URL so reload doesn't re-fetch // Clean URL so refresh doesn't re-load repeatedly
const qp = new URLSearchParams(searchParams.toString()); const qp = new URLSearchParams(searchParams.toString());
qp.delete("load"); qp.delete("load");
router.replace(`/builder?${qp.toString()}`, { scroll: false }); if (buildParam && isUuid(buildParam)) qp.delete("build");
const next = qp.toString();
router.replace(next ? `/builder?${next}` : "/builder", {
scroll: false,
});
} catch (e: any) { } catch (e: any) {
setShareStatus(e?.message ?? "Failed to load build"); setShareStatus(e?.message ?? "Failed to load build");
} finally { } finally {
@@ -481,9 +554,86 @@ export default function GunbuilderPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, router]); }, [searchParams, router]);
// -----------------------------
// SAVE AS… (single source of truth for saving to Vault)
// - Creates a NEW saved build server-side
// - Shows toast so users know it worked
// -----------------------------
const handleSaveAs = async () => {
if (selectedParts.length === 0) {
showToast({ type: "error", message: "Add a few parts before saving." });
return;
}
const title = (saveAsTitle || `${platform} Build`).trim();
if (!title) {
showToast({ type: "error", message: "Title is required." });
return;
}
const items = Object.entries(build)
.filter(([, partId]) => !!partId)
.map(([categoryId, partId]) => ({
productId: Number(partId),
slot: categoryId,
position: 0,
quantity: 1,
}));
try {
setSaveAsSaving(true);
showToast({ type: "info", message: "Saving build…" });
const res = await fetch(`${API_BASE_URL}/api/v1/builds/me`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
title,
description: saveAsDesc?.trim() || null,
isPublic: false,
items,
}),
});
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(txt || `Save failed (${res.status})`);
}
const saved = await res.json(); // BuildDto (expects { uuid, ... })
// UX: copy UUID for quick testing + user confidence
try {
if (saved?.uuid) await navigator.clipboard.writeText(saved.uuid);
} catch {
// ignore clipboard failures
}
showToast({
type: "success",
message: "Saved to Vault ✅ (UUID copied)",
});
// Close modal + reset inputs for next variant
setSaveAsOpen(false);
setSaveAsTitle("");
setSaveAsDesc("");
} catch (e: any) {
showToast({
type: "error",
message: e?.message ?? "Save failed",
});
} finally {
setSaveAsSaving(false);
}
};
// -----------------------------
// Handle URL query parameters: // Handle URL query parameters:
// - ?select=categoryId:partId // - ?select=categoryId:partId
// - ?remove=categoryId // - ?remove=categoryId
// -----------------------------
useEffect(() => { useEffect(() => {
const selectParam = searchParams.get("select"); const selectParam = searchParams.get("select");
const removeParam = searchParams.get("remove"); const removeParam = searchParams.get("remove");
@@ -522,12 +672,11 @@ export default function GunbuilderPage() {
const nextPlatform = isValidPlatform(qpPlatform) ? qpPlatform : platform; const nextPlatform = isValidPlatform(qpPlatform) ? qpPlatform : platform;
// Keep URL clean/stable
const nextUrl = `/builder?platform=${encodeURIComponent(nextPlatform)}`; const nextUrl = `/builder?platform=${encodeURIComponent(nextPlatform)}`;
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const current = `${window.location.pathname}${window.location.search}`; const current = `${window.location.pathname}${window.location.search}`;
if (current !== nextUrl) { if (current !== nextUrl) router.replace(nextUrl, { scroll: false });
router.replace(nextUrl, { scroll: false });
}
} else { } else {
router.replace(nextUrl, { scroll: false }); router.replace(nextUrl, { scroll: false });
} }
@@ -541,7 +690,7 @@ export default function GunbuilderPage() {
} }
}, [searchParams, platform]); }, [searchParams, platform]);
// Build share URL whenever build changes // Build share URL whenever build changes (client-side only)
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
@@ -554,9 +703,7 @@ export default function GunbuilderPage() {
const payload = JSON.stringify(build); const payload = JSON.stringify(build);
const encoded = window.btoa(payload); const encoded = window.btoa(payload);
const origin = window.location?.origin ?? ""; const origin = window.location?.origin ?? "";
const url = `${origin}/builder/build?build=${encodeURIComponent( const url = `${origin}/builder/build?build=${encodeURIComponent(encoded)}`;
encoded
)}`;
setShareUrl(url); setShareUrl(url);
} catch { } catch {
setShareUrl(""); setShareUrl("");
@@ -571,53 +718,7 @@ export default function GunbuilderPage() {
[] []
); );
// Handler to Save user build to account // Copy summary text to clipboard
const handleSaveToAccount = async () => {
if (selectedParts.length === 0) {
setShareStatus("Add a few parts before saving.");
return;
}
const title = `${platform} Build`;
const items = Object.entries(build)
.filter(([, partId]) => !!partId)
.map(([categoryId, partId]) => ({
productId: Number(partId),
slot: categoryId,
position: 0,
quantity: 1,
}));
try {
setShareStatus("Saving build…");
const res = await fetch(`${API_BASE_URL}/api/v1/products/me/builds`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
title,
description: null,
isPublic: false,
items,
}),
});
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(`Save failed (${res.status}) ${txt}`);
}
const saved = await res.json(); // BuildDto
setShareStatus(`Saved! Build UUID: ${saved.uuid}`);
} catch (e: any) {
setShareStatus(e?.message ?? "Save failed");
} finally {
window.setTimeout(() => setShareStatus(null), 4500);
}
};
const handleShare = async () => { const handleShare = async () => {
if (selectedParts.length === 0) { if (selectedParts.length === 0) {
setShareStatus("Add a few parts before sharing your build."); setShareStatus("Add a few parts before sharing your build.");
@@ -625,7 +726,7 @@ export default function GunbuilderPage() {
} }
const lines: string[] = []; const lines: string[] = [];
lines.push("B Build"); lines.push("Battl Build");
lines.push(""); lines.push("");
lines.push(`Total: $${totalPrice.toFixed(2)}`); lines.push(`Total: $${totalPrice.toFixed(2)}`);
lines.push(""); lines.push("");
@@ -685,8 +786,8 @@ export default function GunbuilderPage() {
if (navigator.share) { if (navigator.share) {
try { try {
await navigator.share({ await navigator.share({
title: "Shadow Standard — The Armory Build", title: "BATTL — The Builder",
text: "Check out my Shadow Standard Armory build.", text: "Check out my Battl Build.",
url: shareUrl, url: shareUrl,
}); });
setShareStatus("Share dialog opened."); setShareStatus("Share dialog opened.");
@@ -702,6 +803,99 @@ export default function GunbuilderPage() {
return ( return (
<main className="min-h-screen bg-white text-zinc-900 dark:bg-black dark:text-zinc-50"> <main className="min-h-screen bg-white text-zinc-900 dark:bg-black dark:text-zinc-50">
{/* Toast (top-right) */}
{toast && (
<div className="fixed top-4 right-4 z-50">
<div
className={`rounded-lg border px-4 py-3 text-sm shadow-lg backdrop-blur
${
toast.type === "success"
? "border-emerald-500/30 bg-emerald-500/15 text-emerald-100"
: toast.type === "error"
? "border-red-500/30 bg-red-500/15 text-red-100"
: "border-white/10 bg-white/10 text-white"
}`}
>
{toast.message}
</div>
</div>
)}
{/* Save As… Modal */}
{saveAsOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-lg rounded-xl border border-white/10 bg-zinc-950 p-4">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-sm font-semibold">Save As</div>
<div className="text-xs text-zinc-400">
Create a new saved build variant in your Vault.
</div>
</div>
<button
type="button"
onClick={() => setSaveAsOpen(false)}
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs hover:bg-white/10"
>
Close
</button>
</div>
<div className="mt-4 space-y-3">
<div>
<label className="block text-xs font-medium text-zinc-300">
Title
</label>
<input
value={saveAsTitle}
onChange={(e) => setSaveAsTitle(e.target.value)}
placeholder={`e.g. "${platform} Mk2 (Suppressed)"`}
className="mt-1 w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none focus:ring-1 focus:ring-amber-400/60"
/>
</div>
<div>
<label className="block text-xs font-medium text-zinc-300">
Description (optional)
</label>
<textarea
value={saveAsDesc}
onChange={(e) => setSaveAsDesc(e.target.value)}
rows={3}
placeholder="Notes for future-you…"
className="mt-1 w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none focus:ring-1 focus:ring-amber-400/60"
/>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<button
type="button"
onClick={() => setSaveAsOpen(false)}
className="text-sm text-zinc-300 hover:underline"
disabled={saveAsSaving}
>
Cancel
</button>
<button
type="button"
onClick={handleSaveAs}
disabled={saveAsSaving}
className={`rounded-md px-4 py-2 text-sm font-semibold text-black ${
saveAsSaving
? "bg-amber-400/60 cursor-not-allowed"
: "bg-amber-400 hover:bg-amber-300"
}`}
>
{saveAsSaving ? "Saving…" : "Save"}
</button>
</div>
</div>
</div>
</div>
)}
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10"> <div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
{/* Header */} {/* Header */}
<header className="mb-6"> <header className="mb-6">
@@ -728,6 +922,7 @@ export default function GunbuilderPage() {
const next = e.target.value as (typeof PLATFORMS)[number]; const next = e.target.value as (typeof PLATFORMS)[number];
setPlatform(next); setPlatform(next);
// Keep URL in sync, and clear transient action params
const qp = new URLSearchParams(searchParams.toString()); const qp = new URLSearchParams(searchParams.toString());
qp.set("platform", next); qp.set("platform", next);
qp.delete("select"); qp.delete("select");
@@ -760,6 +955,7 @@ export default function GunbuilderPage() {
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-amber-300"> <h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-amber-300">
My Build Breakdown My Build Breakdown
</h2> </h2>
<div> <div>
<div className="text-xs text-zinc-400">Current build total</div> <div className="text-xs text-zinc-400">Current build total</div>
<div className="text-2xl font-semibold text-amber-300"> <div className="text-2xl font-semibold text-amber-300">
@@ -787,25 +983,25 @@ export default function GunbuilderPage() {
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
// Reset local build state
setBuild({}); setBuild({});
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
} }
showToast({ type: "info", message: "Build cleared." });
}} }}
disabled={selectedParts.length === 0} disabled={selectedParts.length === 0}
className={`w-full sm:w-auto rounded-md border border-zinc-700 bg-zinc-900/50 px-3 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors ${ className={`w-full sm:w-auto rounded-md border border-zinc-700 bg-zinc-900/50 px-3 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors ${
selectedParts.length === 0 selectedParts.length === 0 ? "opacity-40 cursor-not-allowed" : ""
? "opacity-40 cursor-not-allowed"
: ""
}`} }`}
> >
Clear Build Clear Build
</button> </button>
{/* Save Build */} {/* Save As… triggers modal */}
<button <button
type="button" type="button"
onClick={handleSaveToAccount} onClick={() => setSaveAsOpen(true)}
disabled={selectedParts.length === 0} disabled={selectedParts.length === 0}
className={`w-full sm:w-auto rounded-md px-3 py-2 text-sm font-semibold transition-colors border ${ className={`w-full sm:w-auto rounded-md px-3 py-2 text-sm font-semibold transition-colors border ${
selectedParts.length === 0 selectedParts.length === 0
@@ -813,8 +1009,16 @@ export default function GunbuilderPage() {
: "bg-amber-400 text-black border-amber-300 hover:bg-amber-300" : "bg-amber-400 text-black border-amber-300 hover:bg-amber-300"
}`} }`}
> >
Save Build Save As
</button> </button>
{/* Optional: quick access to Vault */}
<Link
href="/vault"
className="w-full sm:w-auto rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm font-medium text-zinc-200 hover:bg-white/10 text-center"
>
Go to Vault
</Link>
</div> </div>
</div> </div>
@@ -829,6 +1033,7 @@ export default function GunbuilderPage() {
Share this link to let others view your build or bookmark it Share this link to let others view your build or bookmark it
to come back later. to come back later.
</p> </p>
<div className="mt-2 flex flex-col gap-2 md:flex-row md:items-center"> <div className="mt-2 flex flex-col gap-2 md:flex-row md:items-center">
<div className="flex-1"> <div className="flex-1">
<div className="text-[0.65rem] uppercase tracking-[0.16em] text-zinc-500 mb-1"> <div className="text-[0.65rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
@@ -841,6 +1046,7 @@ export default function GunbuilderPage() {
className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-1.5 text-[0.7rem] text-zinc-200 font-mono outline-none focus:ring-1 focus:ring-amber-400" className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-1.5 text-[0.7rem] text-zinc-200 font-mono outline-none focus:ring-1 focus:ring-amber-400"
/> />
</div> </div>
<div className="flex gap-2 md:flex-none md:mt-5"> <div className="flex gap-2 md:flex-none md:mt-5">
<button <button
type="button" type="button"
@@ -849,6 +1055,7 @@ export default function GunbuilderPage() {
> >
Share Share
</button> </button>
<button <button
type="button" type="button"
onClick={handleCopyLink} onClick={handleCopyLink}
@@ -968,7 +1175,8 @@ export default function GunbuilderPage() {
<tbody> <tbody>
{groupCategories.map((category) => { {groupCategories.map((category) => {
const categoryParts = partsByCategory[category.id] ?? []; const categoryParts =
partsByCategory[category.id] ?? [];
const selectedPartId = build[category.id]; const selectedPartId = build[category.id];
const selectedPart = selectedPartId const selectedPart = selectedPartId
@@ -995,7 +1203,7 @@ export default function GunbuilderPage() {
{selectedPart.name} {selectedPart.name}
</div> </div>
{/* ✅ Category overlap chips */} {/* Overlap chips help explain conflicts / dependencies */}
<div className="mt-1 flex flex-wrap gap-1.5"> <div className="mt-1 flex flex-wrap gap-1.5">
{getOverlapChipsForCategory( {getOverlapChipsForCategory(
category.id as CategoryId, category.id as CategoryId,
@@ -1038,7 +1246,7 @@ export default function GunbuilderPage() {
</td> </td>
{/* Caliber placeholder */} {/* Caliber placeholder (future: derive from build profile) */}
<td className="px-3 py-2 align-top text-zinc-400"> <td className="px-3 py-2 align-top text-zinc-400">
</td> </td>
@@ -591,7 +591,7 @@ export default function ProductDetailsPage() {
{isBestOffer(o) && ( {isBestOffer(o) && (
<span className="ml-2 inline-flex items-center rounded bg-amber-400 px-2 py-0.5 text-[10px] font-semibold text-black"> <span className="ml-2 inline-flex items-center rounded bg-amber-400 px-2 py-0.5 text-[10px] font-semibold text-black">
BEST BEST DEAL
</span> </span>
)} )}
</td> </td>
+293 -178
View File
@@ -1,103 +1,182 @@
"use client"; "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"; import Link from "next/link";
type BuildClass = "Rifle" | "Pistol" | "NFA"; 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 = { type BuildCard = {
id: string; id: string; // we use uuid here (public identifier)
title: string; title: string;
slug: string; slug: string; // can be uuid for now; keep property so UI does not change
creator: string; creator: string; // placeholder until user profiles are real
caliber: Caliber; caliber: Caliber;
buildClass: BuildClass; buildClass: BuildClass;
price: number; price: number; // optional server-side later; for now allow 0 fallback
votes: number; votes: number;
tags: string[]; tags: string[];
coverImageUrl?: string | null;
}; };
const DUMMY_BUILDS: BuildCard[] = [ type BuildFeedCardDto = {
{ uuid: string;
id: "1", title?: string | null;
title: "Duty-Grade 12.5\" AR-15", slug?: string | null; // optional (we can generate from title later)
slug: "duty-12-5-ar15", creator?: string | null; // optional
creator: "quietpro_01", caliber?: string | null;
caliber: "5.56", buildClass?: BuildClass | null;
buildClass: "Rifle", price?: number | null;
price: 2450, votes?: number | null;
votes: 128, tags?: string[] | null;
tags: ["Duty", "NV-Ready", "LPVO"], coverImageUrl?: string | null;
}, };
{
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")[] = [ const API_BASE_URL =
"all", process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
"5.56",
"7.62",
"9mm",
".300 BLK",
"12ga",
];
// --- UI Filter Sets (keep; well generate options from data too) ---
const CLASS_FILTERS: (BuildClass | "all")[] = ["all", "Rifle", "Pistol", "NFA"]; const CLASS_FILTERS: (BuildClass | "all")[] = ["all", "Rifle", "Pistol", "NFA"];
const PRICE_FILTERS = [ const PRICE_FILTERS = [
{ id: "all", label: "All", min: 0, max: Infinity }, { id: "all", label: "All", min: 0, max: Infinity },
{ id: "sub1k", label: "< $1k", min: 0, max: 1000 }, { id: "sub1k", label: "< $1k", min: 0, max: 1000_00 },
{ id: "1to2k", label: "$1k$2k", min: 1000, max: 2000 }, { id: "1to2k", label: "$1k$2k", min: 1000_00, max: 2000_00 },
{ id: "2to3k", label: "$2k$3k", min: 2000, max: 3000 }, { id: "2to3k", label: "$2k$3k", min: 2000_00, max: 3000_00 },
{ id: "3kplus", label: "$3k+", min: 3000, max: Infinity }, { 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() { 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 [caliberFilter, setCaliberFilter] = useState<Caliber | "all">("all");
const [classFilter, setClassFilter] = useState<BuildClass | "all">("all"); const [classFilter, setClassFilter] = useState<BuildClass | "all">("all");
const [priceFilterId, setPriceFilterId] = useState<string>("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( // Local vote state for optimistic UI
(p) => p.id === priceFilterId, const [votes, setVotes] = useState<Record<string, number>>({});
) ?? PRICE_FILTERS[0];
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(() => { const filteredBuilds = useMemo(() => {
return DUMMY_BUILDS.filter((build) => { return builds.filter((build) => {
const matchesCaliber = const matchesCaliber =
caliberFilter === "all" || build.caliber === caliberFilter; caliberFilter === "all" || build.caliber === caliberFilter;
@@ -110,14 +189,16 @@ export default function BuildsPage() {
return matchesCaliber && matchesClass && matchesPrice; 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) => ({ setVotes((prev) => ({
...prev, ...prev,
[id]: (prev[id] ?? 0) + delta, [id]: (prev[id] ?? 0) + delta,
})); }));
}; }, []);
return ( return (
<main className="min-h-screen bg-black text-zinc-50"> <main className="min-h-screen bg-black text-zinc-50">
@@ -129,18 +210,19 @@ export default function BuildsPage() {
Battl Builder · Build Book Battl Builder · Build Book
</p> </p>
<h1 <h1
className="mt-2 text-3xl md:text-4xl font-extrabold tracking-tight 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 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)]" text-transparent bg-clip-text drop-shadow-[0_2px_6px_rgba(255,193,7,0.25)]"
> >
Community Builds Community Builds
</h1> </h1>
<p className="mt-2 text-sm md:text-base text-zinc-400 max-w-xl"> <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 Browse community builds, vote them up or down, and steal good
good ideas without shame. This is placeholder content for now ideas without shame. This feed is backed by public builds
real accounts, comments, and saved builds will wire in later. (isPublic=true).
</p> </p>
</div> </div>
<div className="flex gap-2 md:gap-3"> <div className="flex gap-2 md:gap-3">
<Link <Link
href="/builder" href="/builder"
@@ -148,15 +230,35 @@ export default function BuildsPage() {
> >
Open Builder Open Builder
</Link> </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" 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) + Submit Build
</button> </Link>
</div> </div>
</header> </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 */} {/* Filters */}
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4"> <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 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 Filter Builds
</h2> </h2>
<p className="text-xs text-zinc-500 mt-1"> <p className="text-xs text-zinc-500 mt-1">
Filter by caliber, platform class, and ballpark price range. Filter by caliber, class, and price range. (Client-side for
All logic is client-side placeholder until the real feed is wired MVP.)
into the backend.
</p> </p>
</div> </div>
<div className="text-xs text-zinc-500"> <div className="text-xs text-zinc-500">
Showing{" "} Showing{" "}
<span className="text-zinc-200 font-medium"> <span className="text-zinc-200 font-medium">
{filteredBuilds.length} {filteredBuilds.length}
</span>{" "} </span>{" "}
of{" "} of{" "}
<span className="text-zinc-200 font-medium"> <span className="text-zinc-200 font-medium">{builds.length}</span>{" "}
{DUMMY_BUILDS.length} builds
</span>{" "}
demo builds
</div> </div>
</div> </div>
@@ -219,9 +319,7 @@ export default function BuildsPage() {
<button <button
key={cls} key={cls}
type="button" type="button"
onClick={() => onClick={() => setClassFilter(cls === "all" ? "all" : cls)}
setClassFilter(cls === "all" ? "all" : cls)
}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${ className={`rounded-full border px-3 py-1 text-xs transition-colors ${
classFilter === cls classFilter === cls
? "border-amber-400/70 bg-amber-400/10 text-amber-200" ? "border-amber-400/70 bg-amber-400/10 text-amber-200"
@@ -262,100 +360,117 @@ export default function BuildsPage() {
{/* Build list */} {/* Build list */}
<section> <section>
<div className="space-y-4"> <div className="space-y-4">
{filteredBuilds.map((build) => ( {filteredBuilds.map((build) => {
<article const dollars = Math.floor((build.price ?? 0) / 100);
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 */} return (
<div className="hidden sm:block"> <article
<div className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/80 h-24 w-40"> key={build.id}
{/* eslint-disable-next-line @next/next/no-img-element */} className="flex gap-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4"
<img >
src="https://placehold.co/320x200/png?text=Build+Photo" {/* Votes column */}
alt={`${build.title} placeholder`} <div className="flex flex-col items-center justify-center gap-1">
className="h-full w-full object-cover" <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>
</div>
{/* Main content */} {/* Thumbnail */}
<div className="flex-1"> <div className="hidden sm:block">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between"> <div className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/80 h-24 w-40">
<div> {/* eslint-disable-next-line @next/next/no-img-element */}
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1"> <img
{build.buildClass} · {build.caliber} 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> </div>
<h3 className="text-lg md:text-xl font-semibold text-zinc-50">
<Link <div className="text-right mt-1 md:mt-0">
href={`/builds/${build.slug}`} <div className="text-xs uppercase tracking-[0.16em] text-zinc-500">
className="hover:text-amber-200 transition-colors" 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} {tag}
</Link> </span>
</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"> <span className="ml-auto text-[0.7rem] text-zinc-500">
{build.tags.map((tag) => ( Comments + save coming soon.
<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>
))} </div>
<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"> <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 No builds match those filters yet.
up, this will update live as the community posts rifles, pistols,
and NFA builds.
</div> </div>
)} )}
</div> </div>
@@ -363,4 +478,4 @@ export default function BuildsPage() {
</div> </div>
</main> </main>
); );
} }
+2 -2
View File
@@ -25,12 +25,12 @@ const themeScript = `
export default function RootLayout({ children }: { children: ReactNode }) { export default function RootLayout({ children }: { children: ReactNode }) {
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning className="dark">
<head> <head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} /> <script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head> </head>
<body className="bg-white text-zinc-950 dark:bg-black dark:text-zinc-50"> <body className="bg-dark text-zinc-950 dark:bg-black dark:text-zinc-50">
{" "} {" "}
<AuthProvider> <AuthProvider>
<Banner /> <Banner />
+1 -1
View File
@@ -98,7 +98,7 @@ export default function HomePage() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center"> <div className="flex items-center">
<Image <Image
src="/battl/battl-logo-mark-2.svg" src="/battl/battl-logo-mark-f.svg"
alt="Battl Builders Logo" alt="Battl Builders Logo"
width={260} // adjust to taste width={260} // adjust to taste
height={48} // adjust to taste height={48} // adjust to taste
+640
View File
@@ -0,0 +1,640 @@
// app/vault/[uuid]/edit/page.tsx
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
type BuildItemDto = {
id?: string;
uuid?: string;
slot?: string; // categoryId in your MVP
position?: number | null;
quantity?: number | null;
productId?: string | null;
productName?: string | null;
productBrand?: string | null;
productImageUrl?: string | null;
};
type BuildDto = {
id: string;
uuid: string;
title: string;
description?: string | null;
isPublic?: boolean | null;
createdAt?: string | null;
updatedAt?: string | null;
items?: BuildItemDto[] | null;
// Option B: metadata fields on Build (or related BuildProfile)
platform?: string | null;
caliber?: string | null;
// purpose?: string | null;
buildClass?: string | null;
tags?: string[] | null;
coverImageUrl?: string | null;
};
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
// -----------------------------
// Controlled vocab (platform + caliber)
// -----------------------------
const PLATFORM_OPTIONS = ["AR-15", "AR-10", "AR-9"] as const;
type PlatformOption = (typeof PLATFORM_OPTIONS)[number] | "";
/**
* Keep this intentionally small and controlled for now:
* - Only calibers relevant to AR-15 / AR-10 / AR-9
* - Easy future expansion: add strings to the arrays below
* - If you later want “canonical” values vs “labels”, we can switch to { value, label } objects.
*/
const CALIBERS_BY_PLATFORM: Record<
Exclude<PlatformOption, "">,
readonly string[]
> = {
"AR-15": [
"5.56 NATO",
".223 Rem",
".300 Blackout",
"6.5 Grendel",
"6mm ARC",
"7.62x39",
".224 Valkyrie",
"350 Legend",
"6.8 SPC",
"458 SOCOM",
],
"AR-10": [
"7.62 NATO",
".308 Win",
"6.5 Creedmoor",
"6mm Creedmoor",
".260 Rem",
".243 Win",
"8.6 Blackout",
],
"AR-9": ["9mm", "10mm", ".40 S&W", ".45 ACP"],
} as const;
type CaliberOption = string | "";
// -----------------------------
// Other controlled fields (optional scaffolding)
// -----------------------------
const PURPOSE_OPTIONS = [
"Home Defense",
"Duty / Patrol",
"Training",
"Competition",
"Hunting",
"Range / Fun",
] as const;
type PurposeOption = (typeof PURPOSE_OPTIONS)[number] | "";
function fmt(d?: string | null) {
if (!d) return "—";
const dt = new Date(d);
if (Number.isNaN(dt.getTime())) return d;
return dt.toLocaleString();
}
function isUuid(v: string) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
v
);
}
export default function EditBuildPage() {
const router = useRouter();
const params = useParams();
const { user, loading, getAuthHeaders } = useAuth();
const uuid = String(params?.uuid ?? "");
const authed = !!user;
const headers = useMemo(() => getAuthHeaders(), [getAuthHeaders]);
const [busy, setBusy] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [build, setBuild] = useState<BuildDto | null>(null);
// Editable submission/profile fields
const [profile, setProfile] = useState<{
title: string;
description: string;
isPublic: boolean;
platform: PlatformOption;
caliber: CaliberOption;
purpose: PurposeOption;
tagsText: string; // comma-separated for MVP
coverImageUrl: string;
} | null>(null);
// Redirect to login if not authed
useEffect(() => {
if (!loading && !authed) router.replace("/login");
}, [loading, authed, router]);
// Load build
useEffect(() => {
if (loading || !authed) return;
if (!uuid || !isUuid(uuid)) {
setError("Invalid build id.");
return;
}
let cancelled = false;
(async () => {
setBusy(true);
setError(null);
try {
// NOTE: ownership / public checks happen server-side
const res = await fetch(
`${API_BASE_URL}/api/v1/builds/me/${encodeURIComponent(uuid)}`,
{
credentials: "include",
}
);
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(txt || `Load failed (${res.status})`);
}
const dto = (await res.json()) as BuildDto;
if (cancelled) return;
setBuild(dto);
const normalizedPlatform: PlatformOption = PLATFORM_OPTIONS.includes(
(dto.platform ?? "") as any
)
? (dto.platform as PlatformOption)
: "";
const allowedCalibers = normalizedPlatform
? CALIBERS_BY_PLATFORM[normalizedPlatform]
: [];
const normalizedCaliber =
dto.caliber && allowedCalibers.includes(dto.caliber)
? dto.caliber
: "";
setProfile({
title: dto.title ?? "",
description: dto.description ?? "",
isPublic: !!dto.isPublic,
platform: normalizedPlatform,
caliber: normalizedCaliber,
purpose: (PURPOSE_OPTIONS.includes((dto.buildClass ?? "") as any)
? (dto.buildClass as any)
: "") as PurposeOption,
tagsText: Array.isArray(dto.tags) ? dto.tags.join(", ") : "",
coverImageUrl: dto.coverImageUrl ?? "",
});
} catch (e: any) {
if (!cancelled) setError(e?.message || "Failed to load build");
} finally {
if (!cancelled) setBusy(false);
}
})();
return () => {
cancelled = true;
};
}, [loading, authed, uuid, headers]);
// Derived caliber options based on selected platform
const caliberOptions = useMemo(() => {
if (!profile?.platform) return [];
return (
CALIBERS_BY_PLATFORM[profile.platform as Exclude<PlatformOption, "">] ??
[]
);
}, [profile?.platform]);
// If platform changes, ensure caliber is valid for that platform
useEffect(() => {
if (!profile) return;
if (!profile.platform) return;
const allowed =
CALIBERS_BY_PLATFORM[profile.platform as Exclude<PlatformOption, "">] ??
[];
if (!profile.caliber) return;
if (!allowed.includes(profile.caliber)) {
setProfile((p) => (p ? { ...p, caliber: "" } : p));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [profile?.platform]); // intentionally only platform
const save = async () => {
if (!profile) return;
if (!uuid || !isUuid(uuid)) return;
const title = profile.title.trim();
if (!title) {
setError("Title is required.");
return;
}
setSaving(true);
setError(null);
try {
const payload = {
title,
description: profile.description?.trim() || null,
isPublic: !!profile.isPublic,
// Backend expects buildClass (not purpose)
buildClass: profile.purpose || null,
caliber: profile.caliber || null,
coverImageUrl: profile.coverImageUrl?.trim() || null,
// Backend expects tags: string[]
tags: profile.tagsText
.split(",")
.map((t) => t.trim())
.filter(Boolean),
// IMPORTANT: if your backend overwrites items when provided,
// leave items undefined unless you intend to edit items here.
// If your backend REQUIRES items, uncomment and map from build.items:
// items: (build?.items ?? []).map((it) => ({
// productId: it.productId ? Number(it.productId) : null,
// slot: it.slot ?? "",
// position: it.position ?? 0,
// quantity: it.quantity ?? 1,
// })).filter((it) => it.productId && it.slot),
};
const res = await fetch(
`${API_BASE_URL}/api/v1/builds/me/${encodeURIComponent(uuid)}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
...headers,
},
credentials: "include",
body: JSON.stringify(payload),
}
);
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(txt || `Save failed (${res.status})`);
}
const updated = (await res.json().catch(() => null)) as BuildDto | null;
if (updated?.uuid) setBuild(updated);
router.replace("/vault");
} catch (e: any) {
setError(e?.message || "Save failed");
} finally {
setSaving(false);
}
};
if (loading || busy) {
return (
<div className="mx-auto max-w-6xl px-4 py-6">
<div className="text-sm opacity-70">Loading</div>
</div>
);
}
if (!authed) return null;
return (
<div className="mx-auto max-w-6xl px-4 py-6">
<div className="mb-6 flex items-start justify-between gap-4">
<div className="min-w-0">
<h1 className="text-2xl font-semibold truncate">Edit Build</h1>
<div className="mt-1 text-xs opacity-70 font-mono truncate">
{uuid}
</div>
<div className="mt-1 text-xs opacity-60">
Updated: {fmt(build?.updatedAt)}
</div>
</div>
<div className="flex items-center gap-3">
<Link
prefetch={false}
href={`/builder?load=${encodeURIComponent(uuid)}`}
className="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm hover:bg-white/10"
>
Open in Builder
</Link>
<Link href="/vault" className="text-sm opacity-80 hover:underline">
Vault
</Link>
</div>
</div>
{error && (
<div className="mb-4 rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-sm">
{error}
</div>
)}
{!profile ? (
<div className="rounded-xl border border-white/10 bg-white/5 p-4 text-sm opacity-70">
No build loaded.
</div>
) : (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
{/* Left: form */}
<div className="lg:col-span-2 rounded-xl border border-white/10 bg-white/5 p-4">
<div className="mb-4">
<div className="text-sm font-medium">Submission Details</div>
<div className="text-xs opacity-70">
Controlled fields keep the community feed clean + filterable.
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{/* Title */}
<div className="md:col-span-2">
<label className="block text-xs font-medium text-white/70">
Title
</label>
<input
value={profile.title}
onChange={(e) =>
setProfile((p) => (p ? { ...p, title: e.target.value } : p))
}
className="mt-1 w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none focus:ring-1 focus:ring-amber-400/60"
placeholder='e.g. "Budget 16” General Purpose AR-15"'
/>
</div>
{/* Platform */}
<div>
<label className="block text-xs font-medium text-white/70">
Platform
</label>
<select
value={profile.platform}
onChange={(e) =>
setProfile((p) =>
p
? {
...p,
platform: e.target.value as PlatformOption,
}
: p
)
}
className="mt-1 w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none focus:ring-1 focus:ring-amber-400/60"
>
<option value="">Select</option>
{PLATFORM_OPTIONS.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
<div className="mt-1 text-xs text-white/40">
Controlled list (prevents messy feed data).
</div>
</div>
{/* Caliber */}
<div>
<label className="block text-xs font-medium text-white/70">
Caliber
</label>
<select
value={profile.caliber}
onChange={(e) =>
setProfile((p) =>
p ? { ...p, caliber: e.target.value as CaliberOption } : p
)
}
disabled={!profile.platform}
className={`mt-1 w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none focus:ring-1 focus:ring-amber-400/60 ${
!profile.platform ? "opacity-50 cursor-not-allowed" : ""
}`}
>
<option value="">
{profile.platform ? "Select…" : "Select platform first…"}
</option>
{caliberOptions.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
<div className="mt-1 text-xs text-white/40">
Options are tied to platform. Update{" "}
<span className="font-mono">CALIBERS_BY_PLATFORM</span> to add
more.
</div>
</div>
{/* Purpose */}
<div>
<label className="block text-xs font-medium text-white/70">
Purpose
</label>
<select
value={profile.purpose}
onChange={(e) =>
setProfile((p) =>
p ? { ...p, purpose: e.target.value as PurposeOption } : p
)
}
className="mt-1 w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none focus:ring-1 focus:ring-amber-400/60"
>
<option value="">Select</option>
{PURPOSE_OPTIONS.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</div>
{/* Tags */}
<div>
<label className="block text-xs font-medium text-white/70">
Tags
</label>
<input
value={profile.tagsText}
onChange={(e) =>
setProfile((p) =>
p ? { ...p, tagsText: e.target.value } : p
)
}
className="mt-1 w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none focus:ring-1 focus:ring-amber-400/60"
placeholder="budget, general purpose, suppressed"
/>
<div className="mt-1 text-xs text-white/40">
Comma-separated for MVP.
</div>
</div>
{/* Cover image URL */}
<div className="md:col-span-2">
<label className="block text-xs font-medium text-white/70">
Cover Image URL
</label>
<input
value={profile.coverImageUrl}
onChange={(e) =>
setProfile((p) =>
p ? { ...p, coverImageUrl: e.target.value } : p
)
}
className="mt-1 w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none focus:ring-1 focus:ring-amber-400/60"
placeholder="https://…"
/>
<div className="mt-1 text-xs text-white/40">
Later well replace this with uploads.
</div>
</div>
{/* Description */}
<div className="md:col-span-2">
<label className="block text-xs font-medium text-white/70">
Description
</label>
<textarea
value={profile.description}
onChange={(e) =>
setProfile((p) =>
p ? { ...p, description: e.target.value } : p
)
}
rows={5}
className="mt-1 w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none focus:ring-1 focus:ring-amber-400/60"
placeholder="Whats the goal of this build? Any notes, constraints, lessons learned…"
/>
</div>
{/* Public toggle */}
<div className="md:col-span-2 flex items-center justify-between rounded-md border border-white/10 bg-black/20 px-3 py-2">
<div>
<div className="text-sm font-medium">Make Public</div>
<div className="text-xs opacity-70">
When enabled, this build can appear in the community feed
later.
</div>
</div>
<input
type="checkbox"
checked={!!profile.isPublic}
onChange={(e) =>
setProfile((p) =>
p ? { ...p, isPublic: e.target.checked } : p
)
}
className="h-5 w-5 accent-amber-400"
/>
</div>
</div>
{/* Actions */}
<div className="mt-6 flex items-center justify-end gap-3">
<Link
href="/vault"
className="text-sm opacity-80 hover:underline"
>
Cancel
</Link>
<button
type="button"
onClick={save}
disabled={saving}
className={`rounded-md px-4 py-2 text-sm font-semibold ${
saving
? "bg-white/10 text-white/60 cursor-not-allowed"
: "bg-amber-400 text-black hover:bg-amber-300"
}`}
>
{saving ? "Saving…" : "Save Changes"}
</button>
</div>
</div>
{/* Right: preview */}
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<div className="mb-3">
<div className="text-sm font-medium">Preview</div>
<div className="text-xs opacity-70">
This is roughly what the community card will show.
</div>
</div>
<div className="rounded-lg border border-white/10 bg-black/30 p-3">
<div className="text-sm font-semibold truncate">
{profile.title || "Untitled Build"}
</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs">
<span className="rounded-full border border-white/10 bg-white/5 px-2 py-1">
{profile.platform || "—"}
</span>
<span className="rounded-full border border-white/10 bg-white/5 px-2 py-1">
{profile.caliber || "—"}
</span>
<span className="rounded-full border border-white/10 bg-white/5 px-2 py-1">
{profile.purpose || "—"}
</span>
</div>
{profile.tagsText?.trim() ? (
<div className="mt-2 text-xs opacity-80">
Tags:{" "}
{profile.tagsText
.split(",")
.map((t) => t.trim())
.filter(Boolean)
.slice(0, 6)
.join(", ")}
</div>
) : null}
{profile.description?.trim() ? (
<div className="mt-3 text-xs opacity-80 line-clamp-5">
{profile.description}
</div>
) : (
<div className="mt-3 text-xs opacity-50">
Add a description to help people understand the why.
</div>
)}
<div className="mt-3 text-[11px] opacity-60">
Build UUID: <span className="font-mono">{uuid}</span>
</div>
</div>
</div>
</div>
)}
</div>
);
}
+118 -38
View File
@@ -8,8 +8,8 @@ import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
type BuildDto = { type BuildDto = {
id: string; id: string; // internal numeric id (not used by UI)
uuid: string; // UUID string uuid: string; // public identifier
title: string; title: string;
description?: string | null; description?: string | null;
isPublic?: boolean | null; isPublic?: boolean | null;
@@ -20,6 +20,12 @@ type BuildDto = {
const API_BASE_URL = const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
// UUID validator (defensive)
const isUuid = (v: string) =>
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
v
);
function fmt(d?: string | null) { function fmt(d?: string | null) {
if (!d) return "—"; if (!d) return "—";
const dt = new Date(d); const dt = new Date(d);
@@ -37,6 +43,7 @@ export default function VaultPage() {
const authed = !!user; const authed = !!user;
// Redirect if not logged in
useEffect(() => { useEffect(() => {
if (!loading && !authed) { if (!loading && !authed) {
router.replace("/login"); router.replace("/login");
@@ -45,6 +52,7 @@ export default function VaultPage() {
const headers = useMemo(() => getAuthHeaders(), [getAuthHeaders]); const headers = useMemo(() => getAuthHeaders(), [getAuthHeaders]);
// Fetch user builds
useEffect(() => { useEffect(() => {
if (loading || !authed) return; if (loading || !authed) return;
@@ -55,9 +63,10 @@ export default function VaultPage() {
setError(null); setError(null);
try { try {
const res = await fetch(`${API_BASE_URL}/api/v1/products/me/builds`, { const res = await fetch(`${API_BASE_URL}/api/v1/builds/me`, {
method: "GET", method: "GET",
headers, headers,
credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
@@ -87,16 +96,18 @@ export default function VaultPage() {
); );
} }
// If not authed, we already redirected; render nothing. // Already redirected
if (!authed) return null; if (!authed) return null;
return ( return (
<div className="mx-auto max-w-6xl px-4 py-6"> <div className="mx-auto max-w-6xl px-4 py-6">
{/* Header */}
<div className="mb-6 flex items-start justify-between gap-4"> <div className="mb-6 flex items-start justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-semibold">Vault</h1> <h1 className="text-2xl font-semibold">Vault</h1>
<p className="text-sm opacity-70"> <p className="text-sm opacity-70">
Your saved builds. Load one into the builder or publish it later. Your saved builds. Open one to keep tweaking parts, or add details
to submit it to the community.
</p> </p>
</div> </div>
@@ -107,21 +118,20 @@ export default function VaultPage() {
> >
New Build New Build
</Link> </Link>
<Link <Link href="/account" className="text-sm opacity-80 hover:underline">
href="/account"
className="text-sm opacity-80 hover:underline"
>
Account Account
</Link> </Link>
</div> </div>
</div> </div>
{error ? ( {/* Error */}
{error && (
<div className="mb-4 rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-sm"> <div className="mb-4 rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-sm">
{error} {error}
</div> </div>
) : null} )}
{/* Builds list */}
<div className="rounded-xl border border-white/10 bg-white/5"> <div className="rounded-xl border border-white/10 bg-white/5">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3"> <div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
<div className="text-sm font-medium">My Builds</div> <div className="text-sm font-medium">My Builds</div>
@@ -132,39 +142,109 @@ export default function VaultPage() {
{builds.length === 0 ? ( {builds.length === 0 ? (
<div className="p-4 text-sm opacity-70"> <div className="p-4 text-sm opacity-70">
No builds yet. Create one in the builder and hit Save to Vault. No builds yet. Create one in the builder and hit Save Build.
</div> </div>
) : ( ) : (
<ul className="divide-y divide-white/10"> <ul className="divide-y divide-white/10">
{builds.map((b) => ( {builds.map((b) => {
<li key={b.uuid} className="p-4 flex items-start justify-between gap-4"> const valid = !!b?.uuid && isUuid(String(b.uuid));
<div className="min-w-0"> const published = Boolean(b.isPublic);
<div className="font-medium truncate">{b.title}</div>
<div className="mt-1 text-xs opacity-70"> const openHref = valid
Updated: {fmt(b.updatedAt)} UUID:{" "} ? `/builder?load=${encodeURIComponent(b.uuid)}`
<span className="font-mono">{b.uuid}</span> : "#";
</div>
{b.description ? ( const detailsHref = valid
<div className="mt-2 text-sm opacity-80 line-clamp-2"> ? `/vault/${encodeURIComponent(b.uuid)}/edit`
{b.description} : "#";
return (
<li
key={b.uuid}
className="p-4 flex items-start justify-between gap-4"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="font-medium truncate">{b.title}</div>
{/* Status chip */}
<span
className={`shrink-0 rounded-full border px-2 py-0.5 text-[11px] ${
published
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
: "border-white/10 bg-white/5 text-white/70"
}`}
title={
published
? "This build is public (community-visible)."
: "Draft (only visible in your Vault)."
}
>
{published ? "Published" : "Draft"}
</span>
{!valid && (
<span
className="shrink-0 rounded-full border border-red-500/30 bg-red-500/10 px-2 py-0.5 text-[11px] text-red-200"
title="This build record has an invalid UUID. The backend returned a bad identifier."
>
Invalid UUID
</span>
)}
</div> </div>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2"> <div className="mt-1 text-xs opacity-70">
{/* Placeholder: wire this to "load into builder" next */} Updated: {fmt(b.updatedAt)} UUID:{" "}
<Link <span className="font-mono">{b.uuid}</span>
href={`/builder?load=${b.uuid}`} </div>
// future href to support platform {b.description && (
// href={`/builder?platform=${encodeURIComponent(platform)}&load=${b.uuid}`} <div className="mt-2 text-sm opacity-80 line-clamp-2">
className="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs hover:bg-white/10" {b.description}
> </div>
Open )}
</Link> </div>
</div>
</li> <div className="flex shrink-0 items-center gap-2">
))} <Link
href={openHref}
aria-disabled={!valid}
tabIndex={!valid ? -1 : 0}
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
valid
? "border-white/10 bg-white/5 hover:bg-white/10"
: "border-white/10 bg-white/5 opacity-40 pointer-events-none"
}`}
title={
valid
? "Load this build into the builder"
: "Invalid build id"
}
>
Open
</Link>
<Link
href={detailsHref}
aria-disabled={!valid}
tabIndex={!valid ? -1 : 0}
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
valid
? "border-white/10 bg-white/5 hover:bg-white/10"
: "border-white/10 bg-white/5 opacity-40 pointer-events-none"
}`}
title={
valid
? "Add details + submit/publish later"
: "Invalid build id"
}
>
Edit
</Link>
</div>
</li>
);
})}
</ul> </ul>
)} )}
</div> </div>
+1 -1
View File
@@ -44,7 +44,7 @@ export function TopNav() {
className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-400" className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-400"
> >
<Image <Image
src="/battl/battl-logo-mark-2.svg" src="/battl/battl-logo-mark-f.svg"
alt="Battl Builders Logo" alt="Battl Builders Logo"
width={260} width={260}
height={48} height={48}
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

+46
View File
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1133.86 425.2">
<defs>
<style>
.cls-1 {
fill: #fff;
}
.cls-1, .cls-2 {
fill-rule: evenodd;
}
.cls-2 {
fill: #febe2a;
}
</style>
</defs>
<g id="Page_1" data-name="Page 1">
<g id="Icon">
<g>
<path class="cls-2" d="M314.03,138.62c4.07,8.49,3.1,16.88-2.79,24.22l-39.94,49.76,39.94,49.76c5.89,7.34,6.86,15.74,2.79,24.22-4.07,8.49-11.22,12.99-20.64,12.99h-91.92v42.9h91.92c25.92,0,48.11-13.97,59.32-37.34s8.21-49.42-8.02-69.63l-18.38-22.9,18.38-22.9c16.23-20.21,19.23-46.26,8.02-69.63s-33.4-37.34-59.32-37.34h-91.92v42.9h91.92c9.41,0,16.57,4.5,20.64,12.99h0Z"/>
<polygon class="cls-2" points="271.3 212.6 236.9 169.76 181.89 169.76 216.28 212.6 181.89 255.44 236.9 255.44 271.3 212.6"/>
<polygon class="cls-2" points="146.46 125.63 201.48 125.63 167.04 82.73 57 82.73 126.87 169.76 181.89 169.76 146.46 125.63"/>
<path class="cls-2" d="M150.79,199.56c-14.45-3.22-31.32-5.34-47.86-5.34h-33.96v13.15l-59.88,5.22,59.88,5.22v13.15h33.96c16.54,0,33.41-2.12,47.86-5.34,12.64-2.81,23.43-6.46,30.53-10.27l.08-2.77-.08-2.77c-7.11-3.8-17.9-7.46-30.53-10.27h0Z"/>
<polygon class="cls-2" points="181.89 255.44 126.87 255.44 57 342.47 167.04 342.47 201.48 299.56 146.46 299.56 181.89 255.44"/>
</g>
</g>
<g id="BattlBuilders_text" data-name="BattlBuilders text">
<g>
<path class="cls-1" d="M478.75,241.24c-4.01-13.79-16.75-23.88-31.84-23.88h-50.03v125.1h52.33c18.36-.08,33.02-15,33.02-33.16v-5.76c0-11.16-5.51-21.02-13.96-27.04,7.86-6.63,11.8-15.78,11.8-26,0-3.22-.46-6.34-1.32-9.28h0ZM425.57,241.24h15.07c5.93,0,10.73,4.81,10.73,10.73v1.91c0,5.93-4.81,10.73-10.73,10.73h-15.07v-23.37h0ZM425.57,288.49h17.23c5.93,0,10.73,4.81,10.73,10.73v8.64c0,5.95-4.8,10.71-10.73,10.73h-17.23v-30.1h0Z"/>
<polygon class="cls-1" points="588.81 217.37 588.81 342.47 617.5 342.47 617.5 217.37 588.81 217.37"/>
<polygon class="cls-1" points="629.21 217.37 629.21 342.47 703.46 342.47 703.46 318.59 657.89 318.59 657.89 217.37 629.21 217.37"/>
<path class="cls-1" d="M712.99,217.37v125.1h52.05c18.35,0,33.29-14.68,33.29-33.16v-58.79c0-18.21-14.74-33.15-33.15-33.15h-52.19ZM741.68,318.59v-77.35h17.23c5.9,0,10.69,4.76,10.73,10.65v56.04c-.04,5.96-4.87,10.65-10.81,10.65h-17.15Z"/>
<polygon class="cls-1" points="810.04 217.37 810.04 342.47 886.77 342.47 886.77 318.59 838.73 318.59 838.73 291.85 876.86 291.85 876.86 267.98 838.73 267.98 838.73 241.24 886.77 241.24 886.77 217.37 810.04 217.37"/>
<path class="cls-1" d="M993.35,250.52c.29,10.37,1.8,16.6,18.51,28.4l29.13,20.82c4.72,3.37,7.36,5.55,7.36,9.26,0,4.95-5.22,9.6-10.63,9.6h-4.95c-5.9,0-10.69-4.76-10.73-10.65v-4.84h-28.69v6.21c0,3.22.46,6.33,1.32,9.28,4,13.74,16.68,23.88,31.84,23.88h17.48c15.16,0,27.83-10.13,31.84-23.88.86-2.95,1.32-6.06,1.32-9.28-.29-10.37-1.8-16.6-18.51-28.4l-29.13-20.82c-4.72-3.37-7.36-5.55-7.36-9.26,0-4.96,5.22-9.6,10.63-9.6h4.95c5.9,0,10.69,4.76,10.73,10.65v4.84h28.69v-6.21c0-3.22-.46-6.33-1.32-9.28-4-13.74-16.68-23.88-31.84-23.88h-17.48c-15.16,0-27.83,10.13-31.84,23.88-.86,2.95-1.32,6.06-1.32,9.28h0Z"/>
<path class="cls-1" d="M981.64,250.52c0-18.21-14.74-33.15-33.16-33.15h-52.19v125.1h28.69v-43.33h17.16c5.97,0,10.81,4.74,10.81,10.73v22.34c0,4.75.91,8.65,2.42,10.27h28.69c-1.52-1.61-2.43-5.52-2.43-10.27v-18.74c0-10.31-4.76-19.97-12.92-26.27,8.17-6.3,12.92-15.96,12.92-26.27v-10.41h0ZM942.22,241.24c5.93,0,10.73,4.81,10.73,10.73v12.55c0,5.99-4.84,10.73-10.81,10.73h-17.16v-34.02h17.24Z"/>
<path class="cls-1" d="M548.42,217.37v93.21h-2.51v3.84h2.51v4.17h-27.97v-4.17h2.51v-3.84h-2.51v-93.21h-28.69v91.95c0,18.16,14.66,33.08,33.02,33.16h19.31c15.1-.06,27.71-10.18,31.7-23.88.86-2.95,1.32-6.06,1.32-9.28v-91.95h-28.69Z"/>
<path class="cls-1" d="M478.75,106.61c-4.01-13.79-16.75-23.88-31.84-23.88h-50.03v125.1h52.33c18.36-.07,33.02-15,33.02-33.15v-5.76c0-11.16-5.51-21.02-13.96-27.04,7.86-6.63,11.8-15.78,11.8-26,0-3.22-.46-6.34-1.32-9.28h0ZM425.57,106.61h15.07c5.93,0,10.73,4.81,10.73,10.73v1.91c0,5.93-4.81,10.73-10.73,10.73h-15.07v-23.37h0ZM425.57,153.85h17.23c5.93,0,10.73,4.81,10.73,10.73v8.64c0,5.95-4.8,10.71-10.73,10.73h-17.23v-30.1h0Z"/>
<path class="cls-1" d="M535.69,97.25c5.99,13.8,8.73,28.29,8.73,41.27v25.48h-19.97v-25.48c0-12.98,2.74-27.48,8.73-41.27h2.51ZM577.1,115.89c0-18.16-14.66-33.08-33.02-33.15h-19.31c-18.36.07-33.02,15-33.02,33.15v91.95h28.69v-10.03l2.56-11.48v-13.17h22.85v13.17l2.56,11.48v10.03h28.69v-91.95h0Z"/>
<polygon class="cls-1" points="766.9 82.73 766.9 207.83 841.16 207.83 841.16 183.96 795.59 183.96 795.59 82.73 766.9 82.73"/>
<polygon class="cls-1" points="757.37 82.73 669.97 82.73 669.97 106.6 699.33 106.6 699.33 207.83 728.01 207.83 728.01 106.6 757.37 106.6 757.37 82.73"/>
<polygon class="cls-1" points="660.44 106.6 660.44 82.73 571.56 82.73 585.34 106.6 602.39 106.6 602.39 207.83 631.08 207.83 631.08 106.6 660.44 106.6"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.0 KiB