feat(38-02): tag edit page + enable Tags sidebar link

- Create admin/tags.$tagId.tsx with rename, reparent (cycle-safe), and delete
- getDescendantIds excludes self + all descendants from parent picker
- getDeleteConfirmText builds impact-aware confirmation (item count + child count)
- Delete confirmation modal with This cannot be undone
- Enable Tags sidebar Link in admin.tsx (remove disabled div + Soon badge)
This commit is contained in:
2026-04-19 22:31:45 +02:00
parent 1f8b85dc62
commit 0571ee47fb
2 changed files with 236 additions and 8 deletions

View File

@@ -39,17 +39,16 @@ function AdminLayout() {
<span>Items</span> <span>Items</span>
</Link> </Link>
{/* Tags — disabled (phase 38) */} {/* Tags — active (phase 38) */}
<div <Link
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-300 cursor-not-allowed" to="/admin/tags"
title="Coming in a future release" activeProps={{ className: "bg-gray-100 text-gray-900 font-medium" }}
inactiveProps={{ className: "text-gray-600 hover:bg-gray-50" }}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors"
> >
<LucideIcon name="tag" size={16} /> <LucideIcon name="tag" size={16} />
<span>Tags</span> <span>Tags</span>
<span className="ml-auto text-xs bg-gray-100 text-gray-400 px-1.5 py-0.5 rounded"> </Link>
Soon
</span>
</div>
</aside> </aside>
{/* Main content */} {/* Main content */}

View File

@@ -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<number> {
const result = new Set<number>();
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 (
<div className="max-w-2xl mx-auto">
<div className="h-4 w-16 bg-gray-100 rounded animate-pulse mb-6" />
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-10 bg-gray-100 rounded-lg animate-pulse" />
))}
</div>
</div>
);
}
if (isError || !tag) {
return (
<div className="max-w-2xl mx-auto text-center py-12">
<p className="text-sm text-red-500">Failed to load tag. Please try again.</p>
</div>
);
}
return (
<div className="max-w-2xl mx-auto">
{/* Back link */}
<button
type="button"
onClick={() => navigate({ to: "/admin/tags" })}
className="text-sm text-gray-400 hover:text-gray-600 transition-colors mb-6 block"
>
Tags
</button>
{/* Page heading */}
<div className="mb-6">
<h1 className="text-lg font-semibold text-gray-900">{tag.name}</h1>
<p className="text-sm text-gray-400 mt-0.5">
{tag.itemCount > 0
? `${tag.itemCount} items use this tag`
: "Not used by any items"}
</p>
</div>
<form onSubmit={handleSave}>
{/* Name field */}
<div className="mb-4">
<label className={labelClass}>Name</label>
<input
type="text"
value={form.name}
onChange={(e) => handleChange("name", e.target.value)}
className={inputClass}
/>
</div>
{/* Parent field */}
<div className="mb-4">
<label className={labelClass}>Parent Tag</label>
<select
value={form.parentId ?? ""}
onChange={(e) =>
handleChange("parentId", e.target.value ? Number(e.target.value) : null)
}
className={`${inputClass} appearance-none bg-white`}
>
<option value="">No parent (top-level)</option>
{parentOptions.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
{/* Actions row */}
<div className="flex items-center justify-between mt-8 pt-6 border-t border-gray-100">
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
disabled={deleteMutation.isPending}
className="px-4 py-2 rounded-lg border border-red-200 text-red-600 hover:bg-red-50 text-sm font-medium transition-colors disabled:opacity-50"
>
Delete Tag
</button>
<button
type="submit"
disabled={updateMutation.isPending}
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium transition-colors disabled:opacity-50"
>
{updateMutation.isPending ? "Saving..." : "Save Changes"}
</button>
</div>
{/* Save error */}
{updateMutation.isError && (
<p className="text-sm text-red-500 mt-2 text-right">
Failed to save. Please try again.
</p>
)}
</form>
{/* Delete confirmation dialog */}
{showDeleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/30"
onClick={() => setShowDeleteConfirm(false)}
/>
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
<h2 className="text-lg font-semibold text-gray-900 mb-2">
Delete "{tag.name}"?
</h2>
<p className="text-sm text-gray-600 mb-6">
{getDeleteConfirmText(tag, childCount)}
</p>
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={() => setShowDeleteConfirm(false)}
disabled={deleteMutation.isPending}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg disabled:opacity-50"
>
{deleteMutation.isPending ? "Deleting..." : "Delete"}
</button>
</div>
</div>
</div>
)}
</div>
);
}