Files
shadow-gunbuilder-ai-proto/app/vault/[uuid]/edit/page.tsx
T

641 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 well 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="Whats 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>
);
}