diff --git a/client/src/pages/admin/AdminCategories.tsx b/client/src/pages/admin/AdminCategories.tsx index 699ed4c..682f10b 100644 --- a/client/src/pages/admin/AdminCategories.tsx +++ b/client/src/pages/admin/AdminCategories.tsx @@ -1,28 +1,79 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { api, type Category, type Tag } from "../../lib/api"; +import { usePageTitle } from "../../lib/pageTitle"; + +type TagRow = Tag & { _count?: { projects: number }; projectCount?: number }; + +function tagUsage(t: TagRow): number { + return t.projectCount ?? t._count?.projects ?? 0; +} + +const PAGE_SIZE = 25; export function AdminCategories() { const [categories, setCategories] = useState([]); - const [tags, setTags] = useState([]); + const [tags, setTags] = useState([]); const [name, setName] = useState(""); const [msg, setMsg] = useState(null); + const [err, setErr] = useState(null); + + const [tagQuery, setTagQuery] = useState(""); + const [tagFilter, setTagFilter] = useState<"all" | "used" | "unused">("all"); + const [tagPage, setTagPage] = useState(1); + + usePageTitle("Categories & Tags · Admin"); function load() { api.adminCategories().then(setCategories); - api.adminTags().then(setTags); + api.adminTags().then((t) => setTags(t as TagRow[])); } useEffect(() => { load(); }, []); + // Reset page when filters change + useEffect(() => { + setTagPage(1); + }, [tagQuery, tagFilter]); + + const filteredTags = useMemo(() => { + const q = tagQuery.trim().toLowerCase(); + return tags + .filter((t) => { + if (q && !t.name.toLowerCase().includes(q) && !t.slug.toLowerCase().includes(q)) { + return false; + } + const n = tagUsage(t); + if (tagFilter === "used" && n === 0) return false; + if (tagFilter === "unused" && n > 0) return false; + return true; + }) + .sort((a, b) => { + // Used first by count desc, then name + const ua = tagUsage(a); + const ub = tagUsage(b); + if (ua !== ub) return ub - ua; + return a.name.localeCompare(b.name); + }); + }, [tags, tagQuery, tagFilter]); + + const totalPages = Math.max(1, Math.ceil(filteredTags.length / PAGE_SIZE)); + const page = Math.min(tagPage, totalPages); + const pageTags = filteredTags.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + async function addCategory(e: React.FormEvent) { e.preventDefault(); if (!name.trim()) return; - await api.createCategory({ name: name.trim() }); - setName(""); - setMsg("Category created"); - load(); + setErr(null); + try { + await api.createCategory({ name: name.trim() }); + setName(""); + setMsg("Category created"); + load(); + } catch (e) { + setErr((e as Error).message); + } } async function removeCategory(id: string, n: string) { @@ -34,6 +85,34 @@ export function AdminCategories() { async function removeTag(id: string, n: string) { if (!confirm(`Delete tag “${n}”?`)) return; await api.deleteTag(id); + setMsg(`Deleted tag “${n}”`); + load(); + } + + async function purgeUnused() { + const unused = tags.filter((t) => tagUsage(t) === 0); + if (unused.length === 0) { + setMsg("No unused tags"); + return; + } + if ( + !confirm( + `Delete ${unused.length} unused tag${unused.length === 1 ? "" : "s"}? This cannot be undone.` + ) + ) { + return; + } + setErr(null); + let ok = 0; + for (const t of unused) { + try { + await api.deleteTag(t.id); + ok++; + } catch { + /* continue */ + } + } + setMsg(`Deleted ${ok} unused tag${ok === 1 ? "" : "s"}`); load(); } @@ -44,6 +123,7 @@ export function AdminCategories() { Categories are fixed taxonomy; tags are free-form labels on projects.

{msg &&
{msg}
} + {err &&
{err}
}
-

Tags

+
+

+ Tags{" "} + + ({filteredTags.length} + {filteredTags.length !== tags.length ? ` of ${tags.length}` : ""}) + +

+ +
+ +
+ setTagQuery(e.target.value)} + aria-label="Search tags" + style={{ + flex: "1 1 200px", + minWidth: 160, + background: "var(--bg-elevated)", + border: "1px solid var(--border)", + borderRadius: 8, + padding: "0.55rem 0.8rem", + }} + /> + +
+ + - {tags.map((t) => ( - - - - + - ))} + ) : ( + pageTags.map((t) => ( + + + + + + + )) + )}
Name SlugProjects
{t.name} - {t.slug} - - + {pageTags.length === 0 ? ( +
+ No tags match this filter.
{t.name} + {t.slug} + + {tagUsage(t)} + + +
+ + {filteredTags.length > PAGE_SIZE && ( +
+ + + Page {page} of {totalPages} + + +
+ )} ); }