618 lines
21 KiB
TypeScript
618 lines
21 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";
|
||
import Image from "next/image";
|
||
|
||
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<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);
|
||
|
||
// photo upload UI state
|
||
const [previews, setPreviews] = useState<LocalPreview[]>([]);
|
||
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 (
|
||
<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="overflow-hidden rounded-lg border border-white/10 bg-black/20">
|
||
{/* header */}
|
||
<div className="grid grid-cols-12 gap-3 border-b border-white/10 px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-zinc-400">
|
||
<div className="col-span-5">Product</div>
|
||
<div className="col-span-4">Component (Role)</div>
|
||
<div className="col-span-2">Brand</div>
|
||
<div className="col-span-1 text-right">Price</div>
|
||
</div>
|
||
|
||
{/* 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 (
|
||
<div
|
||
key={`${it.slot}-${it.productId}-${idx}`}
|
||
className="grid grid-cols-12 gap-2 border-b border-white/5 px-3 py-2 text-sm last:border-b-0"
|
||
>
|
||
{/* Product */}
|
||
<div className="col-span-5 min-w-0">
|
||
<div className="truncate text-zinc-100">
|
||
{it.productName ?? `Product #${it.productId}`}
|
||
</div>
|
||
<div className="text-xs text-zinc-500">
|
||
ID:{" "}
|
||
<span className="font-mono">{it.productId}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Component (Role) */}
|
||
<div className="col-span-4 truncate text-zinc-200">
|
||
{it.slot}
|
||
</div>
|
||
|
||
{/* Brand */}
|
||
<div className="col-span-2 truncate text-zinc-300">
|
||
{it.productBrand ?? "—"}
|
||
</div>
|
||
|
||
{/* Price */}
|
||
<div className="col-span-1 text-right font-semibold text-zinc-100">
|
||
{price != null && !Number.isNaN(price)
|
||
? `$${price.toFixed(2)}`
|
||
: "—"}
|
||
</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>
|
||
|
||
{/* Build Photos */}
|
||
<div className="mt-8 rounded-xl border border-white/10 bg-white/5 p-4">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<div className="text-sm font-semibold">Build Photos</div>
|
||
<div className="text-xs opacity-70">
|
||
Select photos, remove any you don’t want, then upload.
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
{/* Select (no upload) */}
|
||
<label
|
||
className={`cursor-pointer rounded-md border border-white/10 bg-white/5 px-3 py-2 text-xs hover:bg-white/10 ${
|
||
uploading ? "opacity-60 pointer-events-none" : ""
|
||
}`}
|
||
title={uploading ? "Uploading…" : "Select photos"}
|
||
>
|
||
Select Photos
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const files = Array.from(e.target.files ?? []).filter(
|
||
isImage
|
||
);
|
||
if (!files.length) return;
|
||
|
||
const local: LocalPreview[] = files.map((f) => ({
|
||
id:
|
||
typeof crypto !== "undefined" &&
|
||
"randomUUID" in crypto
|
||
? crypto.randomUUID()
|
||
: `${Date.now()}-${Math.random()}`,
|
||
file: f,
|
||
url: URL.createObjectURL(f),
|
||
}));
|
||
|
||
setPreviews((prev) => [...local, ...prev]);
|
||
e.target.value = ""; // reset input so re-selecting same file works
|
||
}}
|
||
/>
|
||
</label>
|
||
|
||
{/* Upload (real upload) */}
|
||
<button
|
||
type="button"
|
||
disabled={uploading || previews.length === 0 || !build?.uuid}
|
||
className="rounded-md bg-amber-400 px-3 py-2 text-xs font-semibold text-black hover:bg-amber-300 disabled:opacity-50"
|
||
onClick={async () => {
|
||
if (!build?.uuid || previews.length === 0) return;
|
||
|
||
setError(null);
|
||
setUploading(true);
|
||
|
||
// snapshot so user can keep selecting more while current batch uploads
|
||
const batch = [...previews];
|
||
|
||
try {
|
||
for (const p of batch) {
|
||
// if user removed it before we got to it, skip
|
||
const stillThere = previews.some((x) => x.id === p.id);
|
||
if (!stillThere) continue;
|
||
|
||
const created = await fileToUpload(p.file, build.uuid);
|
||
|
||
setBuild((prev) =>
|
||
prev
|
||
? {
|
||
...prev,
|
||
photos: [created, ...(prev.photos ?? [])],
|
||
}
|
||
: prev
|
||
);
|
||
|
||
// remove preview after success
|
||
setPreviews((prev) => {
|
||
const hit = prev.find((x) => x.id === p.id);
|
||
if (hit) URL.revokeObjectURL(hit.url);
|
||
return prev.filter((x) => x.id !== p.id);
|
||
});
|
||
}
|
||
} catch (err: any) {
|
||
setError(err?.message ?? "Photo upload failed");
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
}}
|
||
>
|
||
{uploading
|
||
? "Uploading…"
|
||
: `Upload${previews.length ? ` (${previews.length})` : ""}`}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Gallery */}
|
||
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||
{/* Local previews (pending upload) */}
|
||
{previews.map((p) => (
|
||
<div
|
||
key={p.id}
|
||
className="relative overflow-hidden rounded-lg border border-white/10 bg-black/20"
|
||
>
|
||
{/* remove X */}
|
||
<button
|
||
type="button"
|
||
aria-label="Remove"
|
||
className="absolute right-2 top-2 rounded-full bg-black/60 px-2 py-1 text-xs text-white hover:bg-black/80"
|
||
onClick={() => {
|
||
setPreviews((prev) => {
|
||
const hit = prev.find((x) => x.id === p.id);
|
||
if (hit) URL.revokeObjectURL(hit.url);
|
||
return prev.filter((x) => x.id !== p.id);
|
||
});
|
||
}}
|
||
disabled={uploading}
|
||
title={
|
||
uploading
|
||
? "Wait for upload to finish"
|
||
: "Remove from upload list"
|
||
}
|
||
>
|
||
✕
|
||
</button>
|
||
|
||
<Image
|
||
src={p.url}
|
||
alt="Pending upload"
|
||
className="h-32 w-full object-cover"
|
||
/>
|
||
<div className="p-2 text-xs opacity-80">
|
||
{uploading ? "Uploading…" : "Pending upload"}
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{/* Uploaded photos */}
|
||
{(build?.photos ?? []).map((p) => (
|
||
<div
|
||
key={p.uuid}
|
||
className="overflow-hidden rounded-lg border border-white/10 bg-black/20"
|
||
>
|
||
<Image
|
||
src={p.url}
|
||
alt={p.caption ?? "Build photo"}
|
||
className="h-32 w-full object-cover"
|
||
/>
|
||
<div className="p-2 text-xs opacity-80 line-clamp-2">
|
||
{p.caption ?? "—"}
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{previews.length === 0 && (build?.photos?.length ?? 0) === 0 && (
|
||
<div className="col-span-full text-xs opacity-70">
|
||
No photos yet. Select a few and upload when ready.
|
||
</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>
|
||
);
|
||
}
|