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}
}