diff --git a/src/client/routes/admin.tsx b/src/client/routes/admin.tsx index 6fa9b59..b64b8b2 100644 --- a/src/client/routes/admin.tsx +++ b/src/client/routes/admin.tsx @@ -39,17 +39,16 @@ function AdminLayout() { Items - {/* Tags — disabled (phase 38) */} -
Tags - - Soon - -
+ {/* Main content */} diff --git a/src/client/routes/admin/tags.$tagId.tsx b/src/client/routes/admin/tags.$tagId.tsx new file mode 100644 index 0000000..c8c7db8 --- /dev/null +++ b/src/client/routes/admin/tags.$tagId.tsx @@ -0,0 +1,229 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { + useAdminTag, + useAdminTags, + useDeleteAdminTag, + useUpdateAdminTag, + type AdminTag, +} from "../../hooks/useAdminTags"; + +export const Route = createFileRoute("/admin/tags/$tagId")({ + component: AdminTagEditPage, +}); + +// ── Helper functions ──────────────────────────────────────────────── + +function getDescendantIds(allTags: AdminTag[], tagId: number): Set { + const result = new Set(); + const children = allTags.filter((t) => t.parentId === tagId); + for (const child of children) { + result.add(child.id); + for (const id of getDescendantIds(allTags, child.id)) result.add(id); + } + return result; +} + +function getDeleteConfirmText(tag: AdminTag, childCount: number): string { + const parts: string[] = []; + if (tag.itemCount > 0) { + parts.push( + `${tag.itemCount} ${tag.itemCount === 1 ? "item uses" : "items use"} this tag.`, + ); + } + if (childCount > 0) { + parts.push( + `Its ${childCount} child ${childCount === 1 ? "tag" : "tags"} will become top-level.`, + ); + } + parts.push("This cannot be undone."); + return parts.join(" "); +} + +// ── CSS constants ─────────────────────────────────────────────────── + +const inputClass = + "w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-300"; +const labelClass = "block text-sm font-medium text-gray-700 mb-1"; + +// ── Edit page component ───────────────────────────────────────────── + +function AdminTagEditPage() { + const { tagId } = Route.useParams(); + const id = Number(tagId); + const navigate = useNavigate(); + + const { data: tag, isLoading, isError } = useAdminTag(isNaN(id) ? null : id); + const { data: allTags } = useAdminTags(); + const updateMutation = useUpdateAdminTag(); + const deleteMutation = useDeleteAdminTag(); + + const [form, setForm] = useState({ name: "", parentId: null as number | null }); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + useEffect(() => { + if (tag) { + setForm({ name: tag.name, parentId: tag.parentId }); + } + }, [tag]); + + function handleChange(field: keyof typeof form, value: string | number | null) { + setForm((prev) => ({ ...prev, [field]: value })); + } + + async function handleSave(e: React.FormEvent) { + e.preventDefault(); + await updateMutation.mutateAsync({ + id, + data: { name: form.name || undefined, parentId: form.parentId }, + }); + } + + async function handleDelete() { + await deleteMutation.mutateAsync(id); + navigate({ to: "/admin/tags" }); + } + + // Computed: exclude self + all descendants from parent picker + const excludedIds = new Set([id, ...getDescendantIds(allTags ?? [], id)]); + const parentOptions = (allTags ?? []).filter((t) => !excludedIds.has(t.id)); + const childCount = (allTags ?? []).filter((t) => t.parentId === id).length; + + if (isLoading) { + return ( +
+
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+
+ ); + } + + if (isError || !tag) { + return ( +
+

Failed to load tag. Please try again.

+
+ ); + } + + return ( +
+ {/* Back link */} + + + {/* Page heading */} +
+

{tag.name}

+

+ {tag.itemCount > 0 + ? `${tag.itemCount} items use this tag` + : "Not used by any items"} +

+
+ +
+ {/* Name field */} +
+ + handleChange("name", e.target.value)} + className={inputClass} + /> +
+ + {/* Parent field */} +
+ + +
+ + {/* Actions row */} +
+ + +
+ + {/* Save error */} + {updateMutation.isError && ( +

+ Failed to save. Please try again. +

+ )} +
+ + {/* Delete confirmation dialog */} + {showDeleteConfirm && ( +
+
setShowDeleteConfirm(false)} + /> +
+

+ Delete "{tag.name}"? +

+

+ {getDeleteConfirmText(tag, childCount)} +

+
+ + +
+
+
+ )} +
+ ); +}