371 lines
12 KiB
TypeScript
371 lines
12 KiB
TypeScript
"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;
|
|
bestPrice?: number | string | null; // backend may serialize BigDecimal as string
|
|
}>;
|
|
};
|
|
|
|
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<BuildDto | null>(null);
|
|
const [form, setForm] = useState({
|
|
title: "",
|
|
description: "",
|
|
isPublic: false,
|
|
});
|
|
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [savedMsg, setSavedMsg] = useState<string | null>(null);
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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 (
|
|
<main className="mx-auto max-w-3xl p-6">
|
|
<div className="mb-4 flex items-center justify-between gap-3">
|
|
<div>
|
|
<h1 className="text-lg font-semibold">Edit Build</h1>
|
|
<div className="mt-1 text-xs text-zinc-500">
|
|
UUID: <span className="font-mono">{uuid}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Link
|
|
href="/vault"
|
|
className="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs hover:bg-white/10"
|
|
>
|
|
← Vault
|
|
</Link>
|
|
|
|
<button
|
|
className="rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300 disabled:opacity-50"
|
|
disabled={!uuid}
|
|
onClick={() =>
|
|
router.push(`/builder?load=${encodeURIComponent(uuid)}`)
|
|
}
|
|
title="Open this build in the builder to swap parts"
|
|
>
|
|
Open in Builder
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{authLoading && <div className="text-sm text-zinc-400">Loading…</div>}
|
|
|
|
{!authLoading && error && (
|
|
<pre className="mb-4 whitespace-pre-wrap rounded-md border border-red-500/30 bg-red-500/10 p-3 text-xs text-red-200">
|
|
{error}
|
|
</pre>
|
|
)}
|
|
|
|
{!authLoading && build && (
|
|
<div className="space-y-4">
|
|
{/* meta */}
|
|
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<div className="text-xs text-zinc-400">
|
|
Created: {fmt(build.createdAt)} • Updated:{" "}
|
|
{fmt(build.updatedAt)}
|
|
</div>
|
|
|
|
<span
|
|
className={`rounded-full border px-2 py-0.5 text-[11px] ${
|
|
form.isPublic
|
|
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
|
|
: "border-white/10 bg-white/5 text-white/70"
|
|
}`}
|
|
>
|
|
{form.isPublic ? "Published" : "Draft"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* parts */}
|
|
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<div className="text-sm font-semibold">Parts in this build</div>
|
|
<div className="text-xs text-zinc-400">
|
|
{build.items?.length ? `${build.items.length} items` : "—"}
|
|
</div>
|
|
</div>
|
|
|
|
{!build.items?.length ? (
|
|
<div className="text-sm opacity-70">No parts saved yet.</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{Object.entries(
|
|
build.items.reduce<Record<string, typeof build.items>>(
|
|
(acc, it) => {
|
|
(acc[it.slot] ||= []).push(it);
|
|
return acc;
|
|
},
|
|
{}
|
|
)
|
|
).map(([slot, items]) => (
|
|
<div key={slot}>
|
|
<div className="mb-2 text-xs font-semibold uppercase tracking-wider text-zinc-400">
|
|
{slot}
|
|
</div>
|
|
|
|
<div className="rounded-lg border border-white/10 bg-black/20">
|
|
{items.map((it, idx) => (
|
|
<div
|
|
key={`${it.slot}-${it.productId}-${idx}`}
|
|
className="flex items-center justify-between gap-4 px-3 py-2 text-sm border-b border-white/5 last:border-b-0"
|
|
>
|
|
<div className="min-w-0">
|
|
<div className="truncate text-zinc-100">
|
|
{it.productName ?? `Product #${it.productId}`}
|
|
</div>
|
|
<div className="text-xs text-zinc-400">
|
|
ID:{" "}
|
|
<span className="font-mono">{it.productId}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="shrink-0 text-right">
|
|
<div className="font-semibold">
|
|
{typeof it.bestPrice === "number"
|
|
? `$${it.bestPrice.toFixed(2)}`
|
|
: it.bestPrice
|
|
? `$${Number(it.bestPrice).toFixed(2)}`
|
|
: "—"}
|
|
</div>
|
|
<div className="text-[11px] text-zinc-500">
|
|
Best price
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* form */}
|
|
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
|
|
<label className="block text-xs font-medium text-zinc-300">
|
|
Title
|
|
</label>
|
|
<input
|
|
value={form.title}
|
|
onChange={(e) =>
|
|
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}
|
|
/>
|
|
|
|
<label className="mt-4 block text-xs font-medium text-zinc-300">
|
|
Notes / Description
|
|
</label>
|
|
<textarea
|
|
value={form.description}
|
|
onChange={(e) =>
|
|
setForm((f) => ({ ...f, description: e.target.value }))
|
|
}
|
|
className="mt-2 min-h-[120px] 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="Why this build? Goals, suppressor setup, ammo, intended use, etc."
|
|
maxLength={2000}
|
|
/>
|
|
|
|
<div className="mt-4 flex items-center justify-between gap-3">
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
checked={form.isPublic}
|
|
onChange={(e) =>
|
|
setForm((f) => ({ ...f, isPublic: e.target.checked }))
|
|
}
|
|
/>
|
|
<span className="text-zinc-200">Publish to community</span>
|
|
</label>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{savedMsg && (
|
|
<span className="text-xs text-emerald-300">{savedMsg}</span>
|
|
)}
|
|
|
|
<button
|
|
onClick={onSave}
|
|
disabled={busy || !dirty}
|
|
className="rounded-md bg-white px-3 py-1.5 text-xs font-semibold text-black hover:bg-zinc-200 disabled:opacity-50"
|
|
>
|
|
{busy ? "Working…" : dirty ? "Save" : "Saved"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* danger zone */}
|
|
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4">
|
|
<div className="text-sm font-semibold text-red-200">
|
|
Danger zone
|
|
</div>
|
|
<div className="mt-1 text-xs text-red-200/70">
|
|
Deleting a build is permanent.
|
|
</div>
|
|
|
|
<button
|
|
onClick={onDelete}
|
|
disabled={busy}
|
|
className="mt-3 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-xs font-semibold text-red-100 hover:bg-red-500/15 disabled:opacity-50"
|
|
>
|
|
Delete build
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</main>
|
|
);
|
|
}
|