fixing vault. allow users to delete their builds or update
This commit is contained in:
@@ -12,16 +12,25 @@
|
|||||||
* Notes for devs:
|
* Notes for devs:
|
||||||
* - We intentionally keep ONE save-to-vault handler: handleSaveAs()
|
* - We intentionally keep ONE save-to-vault handler: handleSaveAs()
|
||||||
* - Toast is used for clear user feedback (save success/fail)
|
* - Toast is used for clear user feedback (save success/fail)
|
||||||
|
*
|
||||||
|
* Auth note:
|
||||||
|
* - /api/v1/builds/me/* requires JWT.
|
||||||
|
* - The builder can render before AuthContext hydrates from localStorage, so we
|
||||||
|
* guard “load build” calls until auth is ready to avoid a 401.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo, useState, useEffect, useCallback, useRef } from "react";
|
import { useMemo, useState, useEffect, useCallback, useRef } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
|
import { X, Link2, Square, CheckSquare } from "lucide-react";
|
||||||
|
|
||||||
|
import { useApi } from "@/lib/api";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
|
||||||
import { CATEGORIES } from "@/data/gunbuilderParts";
|
import { CATEGORIES } from "@/data/gunbuilderParts";
|
||||||
import { BUILDER_SLOTS, isSlotSatisfied } from "@/data/builderSlots";
|
import { BUILDER_SLOTS, isSlotSatisfied } from "@/data/builderSlots";
|
||||||
import type { CategoryId, Part } from "@/types/gunbuilder";
|
import type { CategoryId, Part } from "@/types/gunbuilder";
|
||||||
import { PART_ROLE_TO_CATEGORY } from "@/lib/catalogMappings";
|
import { PART_ROLE_TO_CATEGORY } from "@/lib/catalogMappings";
|
||||||
import { X, Link2, Square, CheckSquare } from "lucide-react";
|
|
||||||
|
|
||||||
// ✅ Centralized overlap rules + helpers
|
// ✅ Centralized overlap rules + helpers
|
||||||
import OverlapChip from "@/components/builder/OverlapChip";
|
import OverlapChip from "@/components/builder/OverlapChip";
|
||||||
@@ -132,6 +141,11 @@ export default function GunbuilderPage() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const api = useApi();
|
||||||
|
|
||||||
|
// ✅ Auth: we need the token ready before calling /builds/me/*
|
||||||
|
const { token, loading: authLoading, getAuthHeaders } = useAuth();
|
||||||
|
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
// Save As… modal state (Vault variants)
|
// Save As… modal state (Vault variants)
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
@@ -509,13 +523,30 @@ export default function GunbuilderPage() {
|
|||||||
|
|
||||||
if (!uuidToLoad) return;
|
if (!uuidToLoad) return;
|
||||||
|
|
||||||
|
// ✅ Wait for AuthProvider hydration before calling /me endpoints.
|
||||||
|
if (authLoading) return;
|
||||||
|
|
||||||
|
// ✅ If not logged in, don't spam the backend; show a helpful message instead.
|
||||||
|
if (!token) {
|
||||||
|
setShareStatus("Please log in to load builds from your Vault.");
|
||||||
|
window.setTimeout(() => setShareStatus(null), 4500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
try {
|
try {
|
||||||
setShareStatus("Loading build…");
|
setShareStatus("Loading build…");
|
||||||
|
|
||||||
|
// NOTE: using fetch here avoids any “stale api instance” issues and lets us
|
||||||
|
// explicitly attach JWT headers.
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${API_BASE_URL}/api/v1/builds/me/${encodeURIComponent(uuidToLoad)}`,
|
`${API_BASE_URL}/api/v1/builds/me/${encodeURIComponent(uuidToLoad)}`,
|
||||||
{ credentials: "include" }
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...getAuthHeaders(),
|
||||||
|
},
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -551,8 +582,14 @@ export default function GunbuilderPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
run();
|
run();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [
|
||||||
}, [searchParams, router]);
|
searchParams,
|
||||||
|
router,
|
||||||
|
authLoading,
|
||||||
|
token,
|
||||||
|
getAuthHeaders,
|
||||||
|
// API_BASE_URL is a module constant; no need to include
|
||||||
|
]);
|
||||||
|
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
// SAVE AS… (single source of truth for saving to Vault)
|
// SAVE AS… (single source of truth for saving to Vault)
|
||||||
@@ -584,16 +621,11 @@ export default function GunbuilderPage() {
|
|||||||
setSaveAsSaving(true);
|
setSaveAsSaving(true);
|
||||||
showToast({ type: "info", message: "Saving build…" });
|
showToast({ type: "info", message: "Saving build…" });
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/v1/builds/me`, {
|
const res = await api.post("/api/v1/builds/me", {
|
||||||
method: "POST",
|
title,
|
||||||
headers: { "Content-Type": "application/json" },
|
description: saveAsDesc?.trim() || null,
|
||||||
credentials: "include",
|
isPublic: false,
|
||||||
body: JSON.stringify({
|
items,
|
||||||
title,
|
|
||||||
description: saveAsDesc?.trim() || null,
|
|
||||||
isPublic: false,
|
|
||||||
items,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
+260
-537
@@ -1,305 +1,119 @@
|
|||||||
// app/vault/[uuid]/edit/page.tsx
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { useApi } from "@/lib/api";
|
||||||
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 = {
|
type BuildDto = {
|
||||||
id: string;
|
|
||||||
uuid: string;
|
uuid: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
isPublic?: boolean | null;
|
isPublic?: boolean | null;
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
items?: BuildItemDto[] | null;
|
items?: Array<{
|
||||||
|
slot: string;
|
||||||
// Option B: metadata fields on Build (or related BuildProfile)
|
productId: number;
|
||||||
platform?: string | null;
|
productName?: string | null;
|
||||||
caliber?: string | null;
|
bestPrice?: number | string | null; // backend may serialize BigDecimal as string
|
||||||
// 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 | "";
|
|
||||||
|
|
||||||
// -----------------------------
|
|
||||||
// Build Class (controlled list)
|
|
||||||
// -----------------------------
|
|
||||||
const BUILD_CLASS_OPTIONS = [
|
|
||||||
"Home Defense",
|
|
||||||
"Duty / Patrol",
|
|
||||||
"Training",
|
|
||||||
"Competition",
|
|
||||||
"Hunting",
|
|
||||||
"Range / Fun",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
type BuildClassOption = (typeof BUILD_CLASS_OPTIONS)[number] | "";
|
|
||||||
|
|
||||||
function fmt(d?: string | null) {
|
function fmt(d?: string | null) {
|
||||||
if (!d) return "—";
|
if (!d) return "—";
|
||||||
const dt = new Date(d);
|
const dt = new Date(d);
|
||||||
if (Number.isNaN(dt.getTime())) return d;
|
return Number.isNaN(dt.getTime()) ? d : dt.toLocaleString();
|
||||||
return dt.toLocaleString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUuid(v: string) {
|
export default function VaultBuildEditPage() {
|
||||||
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(
|
const { uuid } = useParams<{ uuid: string }>();
|
||||||
v
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EditBuildPage() {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const api = useApi();
|
||||||
const { user, loading, getAuthHeaders } = useAuth();
|
const { token, loading: authLoading } = 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);
|
const [build, setBuild] = useState<BuildDto | null>(null);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
isPublic: false,
|
||||||
|
});
|
||||||
|
|
||||||
// Editable submission/profile fields
|
const [busy, setBusy] = useState(false);
|
||||||
const [profile, setProfile] = useState<{
|
const [error, setError] = useState<string | null>(null);
|
||||||
title: string;
|
const [savedMsg, setSavedMsg] = useState<string | null>(null);
|
||||||
description: string;
|
|
||||||
isPublic: boolean;
|
|
||||||
|
|
||||||
platform: PlatformOption;
|
const authed = !!token;
|
||||||
caliber: CaliberOption;
|
|
||||||
buildClass: BuildClassOption;
|
|
||||||
|
|
||||||
tagsText: string; // comma-separated for MVP
|
// Load build once auth is ready + token exists
|
||||||
coverImageUrl: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
// Redirect to login if not authed
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && !authed) router.replace("/login");
|
if (!uuid) return;
|
||||||
}, [loading, authed, router]);
|
if (authLoading) return;
|
||||||
|
|
||||||
// Load build
|
if (!authed) {
|
||||||
useEffect(() => {
|
setError("Please log in to edit builds.");
|
||||||
if (loading || !authed) return;
|
|
||||||
|
|
||||||
if (!uuid || !isUuid(uuid)) {
|
|
||||||
setError("Invalid build id.");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
setBusy(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// NOTE: ownership / public checks happen server-side
|
setError(null);
|
||||||
const res = await fetch(
|
const res = await api.get(
|
||||||
`${API_BASE_URL}/api/v1/builds/me/${encodeURIComponent(uuid)}`,
|
`/api/v1/builds/me/${encodeURIComponent(uuid)}`
|
||||||
{
|
|
||||||
credentials: "include",
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const txt = await res.text().catch(() => "");
|
const txt = await res.text().catch(() => "");
|
||||||
throw new Error(txt || `Load failed (${res.status})`);
|
throw new Error(txt || `Failed to load build (${res.status})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dto = (await res.json()) as BuildDto;
|
const data = (await res.json()) as BuildDto;
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
setBuild(data);
|
||||||
setBuild(dto);
|
setForm({
|
||||||
|
title: data.title ?? "",
|
||||||
const normalizedPlatform: PlatformOption = PLATFORM_OPTIONS.includes(
|
description: data.description ?? "",
|
||||||
(dto.platform ?? "") as any
|
isPublic: Boolean(data.isPublic),
|
||||||
)
|
|
||||||
? (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,
|
|
||||||
|
|
||||||
buildClass: (BUILD_CLASS_OPTIONS.includes(
|
|
||||||
(dto.buildClass ?? "") as any
|
|
||||||
)
|
|
||||||
? (dto.buildClass as any)
|
|
||||||
: "") as BuildClassOption,
|
|
||||||
|
|
||||||
tagsText: Array.isArray(dto.tags) ? dto.tags.join(", ") : "",
|
|
||||||
coverImageUrl: dto.coverImageUrl ?? "",
|
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (!cancelled) setError(e?.message || "Failed to load build");
|
if (!cancelled) setError(e?.message ?? "Failed to load build");
|
||||||
} finally {
|
|
||||||
if (!cancelled) setBusy(false);
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [loading, authed, uuid, headers]);
|
}, [uuid, authLoading, authed, api]);
|
||||||
|
|
||||||
// Derived caliber options based on selected platform
|
const dirty = useMemo(() => {
|
||||||
const caliberOptions = useMemo(() => {
|
if (!build) return false;
|
||||||
if (!profile?.platform) return [];
|
|
||||||
return (
|
return (
|
||||||
CALIBERS_BY_PLATFORM[profile.platform as Exclude<PlatformOption, "">] ??
|
form.title !== (build.title ?? "") ||
|
||||||
[]
|
form.description !== (build.description ?? "") ||
|
||||||
|
form.isPublic !== Boolean(build.isPublic)
|
||||||
);
|
);
|
||||||
}, [profile?.platform]);
|
}, [build, form]);
|
||||||
|
|
||||||
// If platform changes, ensure caliber is valid for that platform
|
async function onSave() {
|
||||||
useEffect(() => {
|
if (!uuid) return;
|
||||||
if (!profile) return;
|
setBusy(true);
|
||||||
if (!profile.platform) return;
|
setSavedMsg(null);
|
||||||
|
|
||||||
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);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
title,
|
title: form.title.trim() || "Untitled Build",
|
||||||
description: profile.description?.trim() || null,
|
description: form.description.trim() || null,
|
||||||
isPublic: !!profile.isPublic,
|
isPublic: form.isPublic,
|
||||||
|
|
||||||
// Backend expects buildClass (not purpose)
|
|
||||||
buildClass: profile.buildClass || 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(
|
const res = await api.put(
|
||||||
`${API_BASE_URL}/api/v1/builds/me/${encodeURIComponent(uuid)}`,
|
`/api/v1/builds/me/${encodeURIComponent(uuid)}`,
|
||||||
{
|
payload
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...headers,
|
|
||||||
},
|
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -307,341 +121,250 @@ export default function EditBuildPage() {
|
|||||||
throw new Error(txt || `Save failed (${res.status})`);
|
throw new Error(txt || `Save failed (${res.status})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = (await res.json().catch(() => null)) as BuildDto | null;
|
const updated = (await res.json()) as BuildDto;
|
||||||
if (updated?.uuid) setBuild(updated);
|
setBuild(updated);
|
||||||
|
setForm({
|
||||||
router.replace("/vault");
|
title: updated.title ?? "",
|
||||||
|
description: updated.description ?? "",
|
||||||
|
isPublic: Boolean(updated.isPublic),
|
||||||
|
});
|
||||||
|
setSavedMsg("Saved.");
|
||||||
|
setTimeout(() => setSavedMsg(null), 2000);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.message || "Save failed");
|
setError(e?.message ?? "Save failed");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setBusy(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;
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
<main className="mx-auto max-w-3xl p-6">
|
||||||
<div className="mb-6 flex items-start justify-between gap-4">
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div>
|
||||||
<h1 className="text-2xl font-semibold truncate">Edit Build</h1>
|
<h1 className="text-lg font-semibold">Edit Build</h1>
|
||||||
<div className="mt-1 text-xs opacity-70 font-mono truncate">
|
<div className="mt-1 text-xs text-zinc-500">
|
||||||
{uuid}
|
UUID: <span className="font-mono">{uuid}</span>
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs opacity-60">
|
|
||||||
Updated: {fmt(build?.updatedAt)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
prefetch={false}
|
href="/vault"
|
||||||
href={`/builder?load=${encodeURIComponent(uuid)}`}
|
className="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs hover:bg-white/10"
|
||||||
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
|
← Vault
|
||||||
</Link>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{authLoading && <div className="text-sm text-zinc-400">Loading…</div>}
|
||||||
<div className="mb-4 rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-sm">
|
|
||||||
|
{!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}
|
{error}
|
||||||
</div>
|
</pre>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!profile ? (
|
{!authLoading && build && (
|
||||||
<div className="rounded-xl border border-white/10 bg-white/5 p-4 text-sm opacity-70">
|
<div className="space-y-4">
|
||||||
No build loaded.
|
{/* meta */}
|
||||||
</div>
|
<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="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
<div className="text-xs text-zinc-400">
|
||||||
{/* Left: form */}
|
Created: {fmt(build.createdAt)} • Updated:{" "}
|
||||||
<div className="lg:col-span-2 rounded-xl border border-white/10 bg-white/5 p-4">
|
{fmt(build.updatedAt)}
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* Platform */}
|
<span
|
||||||
<div>
|
className={`rounded-full border px-2 py-0.5 text-[11px] ${
|
||||||
<label className="block text-xs font-medium text-white/70">
|
form.isPublic
|
||||||
Platform
|
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
|
||||||
</label>
|
: "border-white/10 bg-white/5 text-white/70"
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Build Class */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-white/70">
|
|
||||||
Build Class
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={profile.buildClass}
|
|
||||||
onChange={(e) =>
|
|
||||||
setProfile((p) =>
|
|
||||||
p
|
|
||||||
? {
|
|
||||||
...p,
|
|
||||||
buildClass: e.target.value as BuildClassOption,
|
|
||||||
}
|
|
||||||
: 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>
|
|
||||||
{BUILD_CLASS_OPTIONS.map((c) => (
|
|
||||||
<option key={c} value={c}>
|
|
||||||
{c}
|
|
||||||
</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"}
|
{form.isPublic ? "Published" : "Draft"}
|
||||||
</button>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: preview */}
|
{/* parts */}
|
||||||
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
|
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||||
<div className="mb-3">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<div className="text-sm font-medium">Preview</div>
|
<div className="text-sm font-semibold">Parts in this build</div>
|
||||||
<div className="text-xs opacity-70">
|
<div className="text-xs text-zinc-400">
|
||||||
This is roughly what the community card will show.
|
{build.items?.length ? `${build.items.length} items` : "—"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-white/10 bg-black/30 p-3">
|
{!build.items?.length ? (
|
||||||
<div className="text-sm font-semibold truncate">
|
<div className="text-sm opacity-70">No parts saved yet.</div>
|
||||||
{profile.title || "Untitled Build"}
|
) : (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
{/* form */}
|
||||||
<span className="rounded-full border border-white/10 bg-white/5 px-2 py-1">
|
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||||
{profile.platform || "—"}
|
<label className="block text-xs font-medium text-zinc-300">
|
||||||
</span>
|
Title
|
||||||
<span className="rounded-full border border-white/10 bg-white/5 px-2 py-1">
|
</label>
|
||||||
{profile.caliber || "—"}
|
<input
|
||||||
</span>
|
value={form.title}
|
||||||
<span className="rounded-full border border-white/10 bg-white/5 px-2 py-1">
|
onChange={(e) =>
|
||||||
{profile.buildClass || "—"}
|
setForm((f) => ({ ...f, title: e.target.value }))
|
||||||
</span>
|
}
|
||||||
</div>
|
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}
|
||||||
|
/>
|
||||||
|
|
||||||
{profile.tagsText?.trim() ? (
|
<label className="mt-4 block text-xs font-medium text-zinc-300">
|
||||||
<div className="mt-2 text-xs opacity-80">
|
Notes / Description
|
||||||
Tags:{" "}
|
</label>
|
||||||
{profile.tagsText
|
<textarea
|
||||||
.split(",")
|
value={form.description}
|
||||||
.map((t) => t.trim())
|
onChange={(e) =>
|
||||||
.filter(Boolean)
|
setForm((f) => ({ ...f, description: e.target.value }))
|
||||||
.slice(0, 6)
|
}
|
||||||
.join(", ")}
|
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"
|
||||||
</div>
|
placeholder="Why this build? Goals, suppressor setup, ammo, intended use, etc."
|
||||||
) : null}
|
maxLength={2000}
|
||||||
|
/>
|
||||||
|
|
||||||
{profile.description?.trim() ? (
|
<div className="mt-4 flex items-center justify-between gap-3">
|
||||||
<div className="mt-3 text-xs opacity-80 line-clamp-5">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
{profile.description}
|
<input
|
||||||
</div>
|
type="checkbox"
|
||||||
) : (
|
checked={form.isPublic}
|
||||||
<div className="mt-3 text-xs opacity-50">
|
onChange={(e) =>
|
||||||
Add a description to help people understand the “why”.
|
setForm((f) => ({ ...f, isPublic: e.target.checked }))
|
||||||
</div>
|
}
|
||||||
)}
|
/>
|
||||||
|
<span className="text-zinc-200">Publish to community</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div className="mt-3 text-[11px] opacity-60">
|
<div className="flex items-center gap-2">
|
||||||
Build UUID: <span className="font-mono">{uuid}</span>
|
{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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-26
@@ -6,6 +6,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { useApi } from "@/lib/api";
|
||||||
|
|
||||||
type BuildDto = {
|
type BuildDto = {
|
||||||
id: string; // internal numeric id (not used by UI)
|
id: string; // internal numeric id (not used by UI)
|
||||||
@@ -35,26 +36,36 @@ function fmt(d?: string | null) {
|
|||||||
|
|
||||||
export default function VaultPage() {
|
export default function VaultPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, loading, getAuthHeaders } = useAuth();
|
const api = useApi();
|
||||||
|
|
||||||
|
// NOTE:
|
||||||
|
// - `loading` here is AuthContext hydration, not network.
|
||||||
|
// - `token` is the real gate for /me endpoints.
|
||||||
|
const { user, token, loading: authLoading } = useAuth();
|
||||||
|
|
||||||
const [builds, setBuilds] = useState<BuildDto[]>([]);
|
const [builds, setBuilds] = useState<BuildDto[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const authed = !!user;
|
const authed = !!user && !!token;
|
||||||
|
|
||||||
// Redirect if not logged in
|
// Redirect if not logged in (after auth hydrates)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && !authed) {
|
if (!authLoading && !authed) {
|
||||||
router.replace("/login");
|
router.replace("/login");
|
||||||
}
|
}
|
||||||
}, [loading, authed, router]);
|
}, [authLoading, authed, router]);
|
||||||
|
|
||||||
const headers = useMemo(() => getAuthHeaders(), [getAuthHeaders]);
|
// When auth identity changes, clear stale list to avoid “ghost builds”
|
||||||
|
useEffect(() => {
|
||||||
|
if (authLoading) return;
|
||||||
|
setBuilds([]);
|
||||||
|
setError(null);
|
||||||
|
}, [authLoading, user?.uuid, token]);
|
||||||
|
|
||||||
// Fetch user builds
|
// Fetch user builds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading || !authed) return;
|
if (authLoading || !authed) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -63,11 +74,9 @@ export default function VaultPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/v1/builds/me`, {
|
// IMPORTANT:
|
||||||
method: "GET",
|
// Use api wrapper so Authorization: Bearer <token> is consistently applied.
|
||||||
headers,
|
const res = await api.get("/api/v1/builds/me");
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
@@ -86,9 +95,9 @@ export default function VaultPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [loading, authed, headers]);
|
}, [authLoading, authed, api]);
|
||||||
|
|
||||||
if (loading) {
|
if (authLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||||
<div className="text-sm opacity-70">Loading…</div>
|
<div className="text-sm opacity-70">Loading…</div>
|
||||||
@@ -142,7 +151,7 @@ export default function VaultPage() {
|
|||||||
|
|
||||||
{builds.length === 0 ? (
|
{builds.length === 0 ? (
|
||||||
<div className="p-4 text-sm opacity-70">
|
<div className="p-4 text-sm opacity-70">
|
||||||
No builds yet. Create one in the builder and hit “Save Build”.
|
No builds yet. Create one in the builder and hit “Save As…”
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-white/10">
|
<ul className="divide-y divide-white/10">
|
||||||
@@ -186,7 +195,7 @@ export default function VaultPage() {
|
|||||||
{!valid && (
|
{!valid && (
|
||||||
<span
|
<span
|
||||||
className="shrink-0 rounded-full border border-red-500/30 bg-red-500/10 px-2 py-0.5 text-[11px] text-red-200"
|
className="shrink-0 rounded-full border border-red-500/30 bg-red-500/10 px-2 py-0.5 text-[11px] text-red-200"
|
||||||
title="This build record has an invalid UUID. The backend returned a bad identifier."
|
title="This build record has an invalid UUID."
|
||||||
>
|
>
|
||||||
Invalid UUID
|
Invalid UUID
|
||||||
</span>
|
</span>
|
||||||
@@ -215,11 +224,7 @@ export default function VaultPage() {
|
|||||||
? "border-white/10 bg-white/5 hover:bg-white/10"
|
? "border-white/10 bg-white/5 hover:bg-white/10"
|
||||||
: "border-white/10 bg-white/5 opacity-40 pointer-events-none"
|
: "border-white/10 bg-white/5 opacity-40 pointer-events-none"
|
||||||
}`}
|
}`}
|
||||||
title={
|
title={valid ? "Load this build into the builder" : "Invalid build id"}
|
||||||
valid
|
|
||||||
? "Load this build into the builder"
|
|
||||||
: "Invalid build id"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Open
|
Open
|
||||||
</Link>
|
</Link>
|
||||||
@@ -233,11 +238,7 @@ export default function VaultPage() {
|
|||||||
? "border-white/10 bg-white/5 hover:bg-white/10"
|
? "border-white/10 bg-white/5 hover:bg-white/10"
|
||||||
: "border-white/10 bg-white/5 opacity-40 pointer-events-none"
|
: "border-white/10 bg-white/5 opacity-40 pointer-events-none"
|
||||||
}`}
|
}`}
|
||||||
title={
|
title={valid ? "Edit details / publish later" : "Invalid build id"}
|
||||||
valid
|
|
||||||
? "Add details + submit/publish later"
|
|
||||||
: "Invalid build id"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
+3
-12
@@ -13,6 +13,7 @@ const API_BASE_URL =
|
|||||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
type AuthUser = {
|
type AuthUser = {
|
||||||
|
uuid: string;
|
||||||
email: string;
|
email: string;
|
||||||
displayName?: string | null;
|
displayName?: string | null;
|
||||||
role: string;
|
role: string;
|
||||||
@@ -35,14 +36,7 @@ type AuthContextValue = {
|
|||||||
* Used for non-password auth flows (ex: magic link).
|
* Used for non-password auth flows (ex: magic link).
|
||||||
* Persists session exactly like login/register.
|
* Persists session exactly like login/register.
|
||||||
*/
|
*/
|
||||||
setSession: (
|
setSession: (token: string, user: NonNullable<AuthUser>) => void;
|
||||||
token: string,
|
|
||||||
user: {
|
|
||||||
email: string;
|
|
||||||
displayName: string | null;
|
|
||||||
role: string;
|
|
||||||
}
|
|
||||||
) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||||
@@ -95,10 +89,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setSession = useCallback(
|
const setSession = useCallback(
|
||||||
(
|
(nextToken: string, nextUser: NonNullable<AuthUser>) => {
|
||||||
nextToken: string,
|
|
||||||
nextUser: { email: string; displayName: string | null; role: string }
|
|
||||||
) => {
|
|
||||||
setToken(nextToken);
|
setToken(nextToken);
|
||||||
setUser(nextUser);
|
setUser(nextUser);
|
||||||
persistAuth(nextToken, nextUser);
|
persistAuth(nextToken, nextUser);
|
||||||
|
|||||||
+55
-18
@@ -1,26 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
type JsonValue = any;
|
||||||
|
|
||||||
export function useApi() {
|
export function useApi() {
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
|
|
||||||
async function get(path: string) {
|
return useMemo(() => {
|
||||||
return fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}${path}`, {
|
function authHeaders(extra?: HeadersInit): HeadersInit {
|
||||||
headers: {
|
const base: Record<string, string> = {};
|
||||||
Authorization: token ? `Bearer ${token}` : "",
|
if (token) base.Authorization = `Bearer ${token}`;
|
||||||
},
|
return { ...base, ...(extra as any) };
|
||||||
});
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async function post(path: string, body: any) {
|
async function get(path: string, init?: RequestInit) {
|
||||||
return fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}${path}`, {
|
return fetch(`${API_BASE_URL}${path}`, {
|
||||||
method: "POST",
|
...init,
|
||||||
headers: {
|
headers: authHeaders(init?.headers),
|
||||||
"Content-Type": "application/json",
|
});
|
||||||
Authorization: token ? `Bearer ${token}` : "",
|
}
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { get, post };
|
async function post(path: string, body: JsonValue, init?: RequestInit) {
|
||||||
|
return fetch(`${API_BASE_URL}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
...init,
|
||||||
|
headers: authHeaders({
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(init?.headers as any),
|
||||||
|
}),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function put(path: string, body: JsonValue, init?: RequestInit) {
|
||||||
|
return fetch(`${API_BASE_URL}${path}`, {
|
||||||
|
method: "PUT",
|
||||||
|
...init,
|
||||||
|
headers: authHeaders({
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(init?.headers as any),
|
||||||
|
}),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function del(path: string, init?: RequestInit) {
|
||||||
|
return fetch(`${API_BASE_URL}${path}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
...init,
|
||||||
|
headers: authHeaders(init?.headers),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ stable reference unless token changes
|
||||||
|
return { get, post, put, del };
|
||||||
|
}, [token]);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user