"use client"; import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useParams, useRouter } from "next/navigation"; import { useAuth } from "@/context/AuthContext"; import { useApi } from "@/lib/api"; type BuildDto = { uuid: string; title: string; description?: string | null; isPublic?: boolean | null; createdAt?: string | null; updatedAt?: string | null; items?: Array<{ slot: string; productId: number; productName?: string | null; productBrand?: string | null; bestPrice?: number | string | null; // backend may serialize BigDecimal as string }>; photos?: BuildPhotoDto[]; }; type BuildPhotoDto = { uuid: string; url: string; caption?: string | null; sortOrder?: number | null; createdAt?: string | null; }; type LocalPreview = { id: string; file: File; url: string; // object URL }; function fmt(d?: string | null) { if (!d) return "—"; const dt = new Date(d); return Number.isNaN(dt.getTime()) ? d : dt.toLocaleString(); } export default function VaultBuildEditPage() { const { uuid } = useParams<{ uuid: string }>(); const router = useRouter(); const api = useApi(); const { token, loading: authLoading } = useAuth(); const [build, setBuild] = useState(null); const [form, setForm] = useState({ title: "", description: "", isPublic: false, }); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [savedMsg, setSavedMsg] = useState(null); // photo upload UI state const [previews, setPreviews] = useState([]); const [uploading, setUploading] = useState(false); // avoid object URL leaks useEffect(() => { return () => { previews.forEach((p) => URL.revokeObjectURL(p.url)); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const authed = !!token; // Load build once auth is ready + token exists useEffect(() => { if (!uuid) return; if (authLoading) return; if (!authed) { setError("Please log in to edit builds."); return; } let cancelled = false; (async () => { try { setError(null); const res = await api.get( `/api/v1/builds/me/${encodeURIComponent(uuid)}` ); if (!res.ok) { const txt = await res.text().catch(() => ""); throw new Error(txt || `Failed to load build (${res.status})`); } const data = (await res.json()) as BuildDto; if (cancelled) return; setBuild(data); setForm({ title: data.title ?? "", description: data.description ?? "", isPublic: Boolean(data.isPublic), }); } catch (e: any) { if (!cancelled) setError(e?.message ?? "Failed to load build"); } })(); return () => { cancelled = true; }; }, [uuid, authLoading, authed, api]); const dirty = useMemo(() => { if (!build) return false; return ( form.title !== (build.title ?? "") || form.description !== (build.description ?? "") || form.isPublic !== Boolean(build.isPublic) ); }, [build, form]); async function onSave() { if (!uuid) return; setBusy(true); setSavedMsg(null); setError(null); try { const payload = { title: form.title.trim() || "Untitled Build", description: form.description.trim() || null, isPublic: form.isPublic, }; const res = await api.put( `/api/v1/builds/me/${encodeURIComponent(uuid)}`, payload ); if (!res.ok) { const txt = await res.text().catch(() => ""); throw new Error(txt || `Save failed (${res.status})`); } const updated = (await res.json()) as BuildDto; setBuild(updated); setForm({ title: updated.title ?? "", description: updated.description ?? "", isPublic: Boolean(updated.isPublic), }); setSavedMsg("Saved."); setTimeout(() => setSavedMsg(null), 2000); } catch (e: any) { setError(e?.message ?? "Save failed"); } finally { setBusy(false); } } function isImage(file: File) { return file.type.startsWith("image/"); } async function fileToUpload(file: File, buildUuid: string) { // 1) ask backend for presigned upload URL const res = await api.post( `/api/v1/builds/me/${encodeURIComponent(buildUuid)}/photos/upload-url`, { fileName: file.name, contentType: file.type, } ); if (!res.ok) { throw new Error(await res.text().catch(() => "Failed to get upload URL")); } const { uploadUrl, publicUrl } = await res.json(); // 2) upload directly to storage const put = await fetch(uploadUrl, { method: "PUT", headers: { "Content-Type": file.type }, body: file, }); if (!put.ok) throw new Error("Upload failed"); // 3) confirm + create photo record const create = await api.post( `/api/v1/builds/me/${encodeURIComponent(buildUuid)}/photos`, { url: publicUrl, caption: null, sortOrder: 0, } ); if (!create.ok) { throw new Error(await create.text().catch(() => "Failed to save photo")); } return (await create.json()) as BuildPhotoDto; } async function onDelete() { if (!uuid) return; const ok = window.confirm("Delete this build?\n\nThis cannot be undone."); if (!ok) return; setBusy(true); setError(null); try { const res = await api.del( `/api/v1/builds/me/${encodeURIComponent(uuid)}` ); if (!res.ok) { const txt = await res.text().catch(() => ""); throw new Error(txt || `Delete failed (${res.status})`); } router.replace("/vault"); router.refresh(); } catch (e: any) { setError(e?.message ?? "Delete failed"); } finally { setBusy(false); } } return (

Edit Build

UUID: {uuid}
← Vault
{authLoading &&
Loading…
} {!authLoading && error && (
          {error}
        
)} {!authLoading && build && (
{/* meta */}
Created: {fmt(build.createdAt)} • Updated:{" "} {fmt(build.updatedAt)}
{form.isPublic ? "Published" : "Draft"}
{/* parts */}
Parts in this build
{build.items?.length ? `${build.items.length} items` : "—"}
{!build.items?.length ? (
No parts saved yet.
) : (
{/* header */}
Product
Component (Role)
Brand
Price
{/* rows */} {build.items .slice() .sort((a, b) => a.slot.localeCompare(b.slot)) .map((it, idx) => { const price = typeof it.bestPrice === "number" ? it.bestPrice : it.bestPrice ? Number(it.bestPrice) : null; return (
{/* Product */}
{it.productName ?? `Product #${it.productId}`}
ID:{" "} {it.productId}
{/* Component (Role) */}
{it.slot}
{/* Brand */}
{it.productBrand ?? "—"}
{/* Price */}
{price != null && !Number.isNaN(price) ? `$${price.toFixed(2)}` : "—"}
); })}
)}
{/* form */}
setForm((f) => ({ ...f, title: e.target.value })) } className="mt-2 w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm outline-none focus:border-amber-400/50" placeholder="e.g. Suppressed MK2" maxLength={120} />