641 lines
20 KiB
TypeScript
641 lines
20 KiB
TypeScript
// 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>
|
||
);
|
||
}
|