new pages for user buils (vault), edit saved builds, and the community build page.
This commit is contained in:
+303
-95
@@ -1,6 +1,19 @@
|
||||
// app/(builder)/builder/page.tsx
|
||||
"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 Link from "next/link";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
@@ -31,6 +44,11 @@ type GunbuilderProductFromApi = {
|
||||
|
||||
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 =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||
|
||||
@@ -114,10 +132,24 @@ export default function GunbuilderPage() {
|
||||
const searchParams = useSearchParams();
|
||||
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 [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// -----------------------------
|
||||
// Platform state (AR-15/AR-10/AR-9)
|
||||
// -----------------------------
|
||||
const isValidPlatform = (
|
||||
value: string | null
|
||||
): value is (typeof PLATFORMS)[number] =>
|
||||
@@ -129,6 +161,9 @@ export default function GunbuilderPage() {
|
||||
return isValidPlatform(initial) ? initial : "AR-15";
|
||||
});
|
||||
|
||||
// -----------------------------
|
||||
// Build state (categoryId -> productId), persisted locally
|
||||
// -----------------------------
|
||||
const [build, setBuild] = useState<BuildState>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
@@ -143,6 +178,20 @@ export default function GunbuilderPage() {
|
||||
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 [shareUrl, setShareUrl] = useState<string>("");
|
||||
|
||||
@@ -153,7 +202,9 @@ export default function GunbuilderPage() {
|
||||
// ✅ Guard to prevent infinite loops when processing ?select / ?remove
|
||||
const processedActionKeyRef = useRef<string>("");
|
||||
|
||||
// ---------- Derived ----------
|
||||
// -----------------------------
|
||||
// Derived collections
|
||||
// -----------------------------
|
||||
const partsByCategory: Record<CategoryId, Part[]> = useMemo(() => {
|
||||
const grouped = {} as Record<CategoryId, Part[]>;
|
||||
for (const category of CATEGORIES) {
|
||||
@@ -177,8 +228,26 @@ export default function GunbuilderPage() {
|
||||
});
|
||||
}, [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
|
||||
// NOTE: defensive while backend roles/mappings evolve
|
||||
// -----------------------------
|
||||
const resolveCategoryId = (normalizedRole: string): CategoryId | null => {
|
||||
if (normalizedRole === "upper-receiver") return "upper-receiver";
|
||||
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;
|
||||
};
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
// -----------------------------
|
||||
// Selection handler (with hint warnings)
|
||||
// -----------------------------
|
||||
const handleSelectPart = useCallback(
|
||||
(categoryId: CategoryId, partId: string, _opts?: { confirm?: boolean }) => {
|
||||
const warn = (msg: string) => {
|
||||
@@ -305,7 +361,9 @@ export default function GunbuilderPage() {
|
||||
[build]
|
||||
);
|
||||
|
||||
// ---------- Fetch products ----------
|
||||
// -----------------------------
|
||||
// Fetch products (scoped + universal merge)
|
||||
// -----------------------------
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
@@ -317,7 +375,6 @@ export default function GunbuilderPage() {
|
||||
const scopedUrl = `${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent(
|
||||
platform
|
||||
)}`;
|
||||
|
||||
const universalUrl = `${API_BASE_URL}/api/v1/products`;
|
||||
|
||||
const [scopedRes, universalRes] = await Promise.all([
|
||||
@@ -350,6 +407,7 @@ export default function GunbuilderPage() {
|
||||
const categoryId = resolveCategoryId(normalizedRole);
|
||||
if (!categoryId) return null;
|
||||
|
||||
// Only accept universal fetch results for "universal" categories
|
||||
if (
|
||||
data === universalData &&
|
||||
!UNIVERSAL_CATEGORIES.has(categoryId)
|
||||
@@ -377,6 +435,7 @@ export default function GunbuilderPage() {
|
||||
const scopedParts = normalize(scopedData);
|
||||
const universalParts = normalize(universalData);
|
||||
|
||||
// Merge (avoid duplicates across calls)
|
||||
const seen = new Set<string>();
|
||||
const merged: Part[] = [];
|
||||
for (const p of [...scopedParts, ...universalParts]) {
|
||||
@@ -399,7 +458,9 @@ export default function GunbuilderPage() {
|
||||
return () => controller.abort();
|
||||
}, [platform]);
|
||||
|
||||
// ✅ Persist build state whenever it changes
|
||||
// -----------------------------
|
||||
// Persist build to localStorage
|
||||
// -----------------------------
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
@@ -409,7 +470,9 @@ export default function GunbuilderPage() {
|
||||
}
|
||||
}, [build]);
|
||||
|
||||
// When the platform changes, clear ONLY platform-scoped selections.
|
||||
// -----------------------------
|
||||
// When platform changes, clear ONLY platform-scoped selections
|
||||
// -----------------------------
|
||||
useEffect(() => {
|
||||
const prev = lastPlatformRef.current;
|
||||
lastPlatformRef.current = platform;
|
||||
@@ -433,20 +496,25 @@ export default function GunbuilderPage() {
|
||||
});
|
||||
}, [platform]);
|
||||
|
||||
// ✅ Load a saved build from Vault via ?load=<uuid>
|
||||
// -----------------------------
|
||||
// Load a saved build from Vault via ?load=<uuid> OR legacy ?build=<uuid>
|
||||
// -----------------------------
|
||||
useEffect(() => {
|
||||
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 () => {
|
||||
try {
|
||||
setShareStatus("Loading build…");
|
||||
|
||||
// NOTE: your backend GET route is /api/v1/products/builds/{uuid}
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/products/builds/${encodeURIComponent(
|
||||
loadUuid
|
||||
)}`,
|
||||
`${API_BASE_URL}/api/v1/builds/me/${encodeURIComponent(uuidToLoad)}`,
|
||||
{ credentials: "include" }
|
||||
);
|
||||
|
||||
@@ -466,10 +534,15 @@ export default function GunbuilderPage() {
|
||||
setBuild(nextBuild);
|
||||
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());
|
||||
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) {
|
||||
setShareStatus(e?.message ?? "Failed to load build");
|
||||
} finally {
|
||||
@@ -481,9 +554,86 @@ export default function GunbuilderPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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:
|
||||
// - ?select=categoryId:partId
|
||||
// - ?remove=categoryId
|
||||
// -----------------------------
|
||||
useEffect(() => {
|
||||
const selectParam = searchParams.get("select");
|
||||
const removeParam = searchParams.get("remove");
|
||||
@@ -522,12 +672,11 @@ export default function GunbuilderPage() {
|
||||
|
||||
const nextPlatform = isValidPlatform(qpPlatform) ? qpPlatform : platform;
|
||||
|
||||
// Keep URL clean/stable
|
||||
const nextUrl = `/builder?platform=${encodeURIComponent(nextPlatform)}`;
|
||||
if (typeof window !== "undefined") {
|
||||
const current = `${window.location.pathname}${window.location.search}`;
|
||||
if (current !== nextUrl) {
|
||||
router.replace(nextUrl, { scroll: false });
|
||||
}
|
||||
if (current !== nextUrl) router.replace(nextUrl, { scroll: false });
|
||||
} else {
|
||||
router.replace(nextUrl, { scroll: false });
|
||||
}
|
||||
@@ -541,7 +690,7 @@ export default function GunbuilderPage() {
|
||||
}
|
||||
}, [searchParams, platform]);
|
||||
|
||||
// Build share URL whenever build changes
|
||||
// Build share URL whenever build changes (client-side only)
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
@@ -554,9 +703,7 @@ export default function GunbuilderPage() {
|
||||
const payload = JSON.stringify(build);
|
||||
const encoded = window.btoa(payload);
|
||||
const origin = window.location?.origin ?? "";
|
||||
const url = `${origin}/builder/build?build=${encodeURIComponent(
|
||||
encoded
|
||||
)}`;
|
||||
const url = `${origin}/builder/build?build=${encodeURIComponent(encoded)}`;
|
||||
setShareUrl(url);
|
||||
} catch {
|
||||
setShareUrl("");
|
||||
@@ -571,53 +718,7 @@ export default function GunbuilderPage() {
|
||||
[]
|
||||
);
|
||||
|
||||
// Handler to Save user build to account
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// Copy summary text to clipboard
|
||||
const handleShare = async () => {
|
||||
if (selectedParts.length === 0) {
|
||||
setShareStatus("Add a few parts before sharing your build.");
|
||||
@@ -625,7 +726,7 @@ export default function GunbuilderPage() {
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push("B Build");
|
||||
lines.push("Battl Build");
|
||||
lines.push("");
|
||||
lines.push(`Total: $${totalPrice.toFixed(2)}`);
|
||||
lines.push("");
|
||||
@@ -685,8 +786,8 @@ export default function GunbuilderPage() {
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: "Shadow Standard — The Armory Build",
|
||||
text: "Check out my Shadow Standard Armory build.",
|
||||
title: "BATTL — The Builder",
|
||||
text: "Check out my Battl Build.",
|
||||
url: shareUrl,
|
||||
});
|
||||
setShareStatus("Share dialog opened.");
|
||||
@@ -702,6 +803,99 @@ export default function GunbuilderPage() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
{/* Header */}
|
||||
<header className="mb-6">
|
||||
@@ -728,6 +922,7 @@ export default function GunbuilderPage() {
|
||||
const next = e.target.value as (typeof PLATFORMS)[number];
|
||||
setPlatform(next);
|
||||
|
||||
// Keep URL in sync, and clear transient action params
|
||||
const qp = new URLSearchParams(searchParams.toString());
|
||||
qp.set("platform", next);
|
||||
qp.delete("select");
|
||||
@@ -760,6 +955,7 @@ export default function GunbuilderPage() {
|
||||
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-amber-300">
|
||||
My Build Breakdown
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<div className="text-xs text-zinc-400">Current build total</div>
|
||||
<div className="text-2xl font-semibold text-amber-300">
|
||||
@@ -787,25 +983,25 @@ export default function GunbuilderPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Reset local build state
|
||||
setBuild({});
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
showToast({ type: "info", message: "Build cleared." });
|
||||
}}
|
||||
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 ${
|
||||
selectedParts.length === 0
|
||||
? "opacity-40 cursor-not-allowed"
|
||||
: ""
|
||||
selectedParts.length === 0 ? "opacity-40 cursor-not-allowed" : ""
|
||||
}`}
|
||||
>
|
||||
Clear Build
|
||||
</button>
|
||||
|
||||
{/* Save Build */}
|
||||
{/* Save As… triggers modal */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveToAccount}
|
||||
onClick={() => setSaveAsOpen(true)}
|
||||
disabled={selectedParts.length === 0}
|
||||
className={`w-full sm:w-auto rounded-md px-3 py-2 text-sm font-semibold transition-colors border ${
|
||||
selectedParts.length === 0
|
||||
@@ -813,8 +1009,16 @@ export default function GunbuilderPage() {
|
||||
: "bg-amber-400 text-black border-amber-300 hover:bg-amber-300"
|
||||
}`}
|
||||
>
|
||||
Save Build
|
||||
Save As…
|
||||
</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>
|
||||
|
||||
@@ -829,6 +1033,7 @@ export default function GunbuilderPage() {
|
||||
Share this link to let others view your build or bookmark it
|
||||
to come back later.
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex flex-col gap-2 md:flex-row md:items-center">
|
||||
<div className="flex-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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 md:flex-none md:mt-5">
|
||||
<button
|
||||
type="button"
|
||||
@@ -849,6 +1055,7 @@ export default function GunbuilderPage() {
|
||||
>
|
||||
Share
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyLink}
|
||||
@@ -968,7 +1175,8 @@ export default function GunbuilderPage() {
|
||||
|
||||
<tbody>
|
||||
{groupCategories.map((category) => {
|
||||
const categoryParts = partsByCategory[category.id] ?? [];
|
||||
const categoryParts =
|
||||
partsByCategory[category.id] ?? [];
|
||||
const selectedPartId = build[category.id];
|
||||
|
||||
const selectedPart = selectedPartId
|
||||
@@ -995,7 +1203,7 @@ export default function GunbuilderPage() {
|
||||
{selectedPart.name}
|
||||
</div>
|
||||
|
||||
{/* ✅ Category overlap chips */}
|
||||
{/* Overlap chips help explain conflicts / dependencies */}
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
{getOverlapChipsForCategory(
|
||||
category.id as CategoryId,
|
||||
@@ -1038,7 +1246,7 @@ export default function GunbuilderPage() {
|
||||
—
|
||||
</td>
|
||||
|
||||
{/* Caliber placeholder */}
|
||||
{/* Caliber placeholder (future: derive from build profile) */}
|
||||
<td className="px-3 py-2 align-top text-zinc-400">
|
||||
—
|
||||
</td>
|
||||
|
||||
@@ -591,7 +591,7 @@ export default function ProductDetailsPage() {
|
||||
|
||||
{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">
|
||||
BEST
|
||||
BEST DEAL
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
+290
-175
@@ -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">
|
||||
@@ -136,11 +217,12 @@ export default function BuildsPage() {
|
||||
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>
|
||||
|
||||
+2
-2
@@ -25,12 +25,12 @@ const themeScript = `
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<html lang="en" suppressHydrationWarning className="dark">
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||
</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>
|
||||
<Banner />
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ export default function HomePage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center">
|
||||
<Image
|
||||
src="/battl/battl-logo-mark-2.svg"
|
||||
src="/battl/battl-logo-mark-f.svg"
|
||||
alt="Battl Builders Logo"
|
||||
width={260} // adjust to taste
|
||||
height={48} // adjust to taste
|
||||
|
||||
@@ -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 we’ll 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="What’s 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
@@ -8,8 +8,8 @@ import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
|
||||
type BuildDto = {
|
||||
id: string;
|
||||
uuid: string; // UUID string
|
||||
id: string; // internal numeric id (not used by UI)
|
||||
uuid: string; // public identifier
|
||||
title: string;
|
||||
description?: string | null;
|
||||
isPublic?: boolean | null;
|
||||
@@ -20,6 +20,12 @@ type BuildDto = {
|
||||
const API_BASE_URL =
|
||||
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) {
|
||||
if (!d) return "—";
|
||||
const dt = new Date(d);
|
||||
@@ -37,6 +43,7 @@ export default function VaultPage() {
|
||||
|
||||
const authed = !!user;
|
||||
|
||||
// Redirect if not logged in
|
||||
useEffect(() => {
|
||||
if (!loading && !authed) {
|
||||
router.replace("/login");
|
||||
@@ -45,6 +52,7 @@ export default function VaultPage() {
|
||||
|
||||
const headers = useMemo(() => getAuthHeaders(), [getAuthHeaders]);
|
||||
|
||||
// Fetch user builds
|
||||
useEffect(() => {
|
||||
if (loading || !authed) return;
|
||||
|
||||
@@ -55,9 +63,10 @@ export default function VaultPage() {
|
||||
setError(null);
|
||||
|
||||
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",
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Vault</h1>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -107,21 +118,20 @@ export default function VaultPage() {
|
||||
>
|
||||
New Build
|
||||
</Link>
|
||||
<Link
|
||||
href="/account"
|
||||
className="text-sm opacity-80 hover:underline"
|
||||
>
|
||||
<Link href="/account" className="text-sm opacity-80 hover:underline">
|
||||
Account →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mb-4 rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{/* Builds list */}
|
||||
<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="text-sm font-medium">My Builds</div>
|
||||
@@ -132,39 +142,109 @@ export default function VaultPage() {
|
||||
|
||||
{builds.length === 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
<ul className="divide-y divide-white/10">
|
||||
{builds.map((b) => (
|
||||
<li key={b.uuid} className="p-4 flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{b.title}</div>
|
||||
<div className="mt-1 text-xs opacity-70">
|
||||
Updated: {fmt(b.updatedAt)} • UUID:{" "}
|
||||
<span className="font-mono">{b.uuid}</span>
|
||||
</div>
|
||||
{b.description ? (
|
||||
<div className="mt-2 text-sm opacity-80 line-clamp-2">
|
||||
{b.description}
|
||||
{builds.map((b) => {
|
||||
const valid = !!b?.uuid && isUuid(String(b.uuid));
|
||||
const published = Boolean(b.isPublic);
|
||||
|
||||
const openHref = valid
|
||||
? `/builder?load=${encodeURIComponent(b.uuid)}`
|
||||
: "#";
|
||||
|
||||
const detailsHref = valid
|
||||
? `/vault/${encodeURIComponent(b.uuid)}/edit`
|
||||
: "#";
|
||||
|
||||
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>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* Placeholder: wire this to "load into builder" next */}
|
||||
<Link
|
||||
href={`/builder?load=${b.uuid}`}
|
||||
<div className="mt-1 text-xs opacity-70">
|
||||
Updated: {fmt(b.updatedAt)} • UUID:{" "}
|
||||
<span className="font-mono">{b.uuid}</span>
|
||||
</div>
|
||||
|
||||
// future href to support platform
|
||||
// href={`/builder?platform=${encodeURIComponent(platform)}&load=${b.uuid}`}
|
||||
className="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs hover:bg-white/10"
|
||||
>
|
||||
Open
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{b.description && (
|
||||
<div className="mt-2 text-sm opacity-80 line-clamp-2">
|
||||
{b.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,7 @@ export function TopNav() {
|
||||
className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-400"
|
||||
>
|
||||
<Image
|
||||
src="/battl/battl-logo-mark-2.svg"
|
||||
src="/battl/battl-logo-mark-f.svg"
|
||||
alt="Battl Builders Logo"
|
||||
width={260}
|
||||
height={48}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
@@ -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 |
Reference in New Issue
Block a user