Make admin tags list searchable and paginated

Filter by name, used/unused, show project counts, and purge unused
tags so the page stays manageable at scale.
This commit is contained in:
2026-07-24 11:55:33 -04:00
parent a575040217
commit b1ef0d139a
+196 -23
View File
@@ -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 { 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() { export function AdminCategories() {
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [tags, setTags] = useState<Tag[]>([]); const [tags, setTags] = useState<TagRow[]>([]);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [msg, setMsg] = useState<string | null>(null); const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [tagQuery, setTagQuery] = useState("");
const [tagFilter, setTagFilter] = useState<"all" | "used" | "unused">("all");
const [tagPage, setTagPage] = useState(1);
usePageTitle("Categories & Tags · Admin");
function load() { function load() {
api.adminCategories().then(setCategories); api.adminCategories().then(setCategories);
api.adminTags().then(setTags); api.adminTags().then((t) => setTags(t as TagRow[]));
} }
useEffect(() => { useEffect(() => {
load(); 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) { async function addCategory(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!name.trim()) return; if (!name.trim()) return;
await api.createCategory({ name: name.trim() }); setErr(null);
setName(""); try {
setMsg("Category created"); await api.createCategory({ name: name.trim() });
load(); setName("");
setMsg("Category created");
load();
} catch (e) {
setErr((e as Error).message);
}
} }
async function removeCategory(id: string, n: string) { async function removeCategory(id: string, n: string) {
@@ -34,6 +85,34 @@ export function AdminCategories() {
async function removeTag(id: string, n: string) { async function removeTag(id: string, n: string) {
if (!confirm(`Delete tag “${n}”?`)) return; if (!confirm(`Delete tag “${n}”?`)) return;
await api.deleteTag(id); 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(); load();
} }
@@ -44,6 +123,7 @@ export function AdminCategories() {
Categories are fixed taxonomy; tags are free-form labels on projects. Categories are fixed taxonomy; tags are free-form labels on projects.
</p> </p>
{msg && <div className="admin-msg admin-msg--ok">{msg}</div>} {msg && <div className="admin-msg admin-msg--ok">{msg}</div>}
{err && <div className="admin-msg admin-msg--err">{err}</div>}
<form className="admin-page__toolbar" onSubmit={addCategory}> <form className="admin-page__toolbar" onSubmit={addCategory}>
<input <input
@@ -94,35 +174,128 @@ export function AdminCategories() {
</tbody> </tbody>
</table> </table>
<h2 style={{ fontSize: "1rem", marginTop: "2rem" }}>Tags</h2> <div
style={{
marginTop: "2.5rem",
display: "flex",
flexWrap: "wrap",
alignItems: "baseline",
justifyContent: "space-between",
gap: "0.75rem",
}}
>
<h2 style={{ fontSize: "1rem", margin: 0 }}>
Tags{" "}
<span style={{ color: "var(--text-dim)", fontWeight: 400, fontSize: "0.9rem" }}>
({filteredTags.length}
{filteredTags.length !== tags.length ? ` of ${tags.length}` : ""})
</span>
</h2>
<button type="button" className="btn btn--ghost btn--sm" onClick={purgeUnused}>
Delete unused tags
</button>
</div>
<div className="admin-page__toolbar" style={{ marginTop: "0.75rem" }}>
<input
type="search"
placeholder="Search tags…"
value={tagQuery}
onChange={(e) => 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",
}}
/>
<select
value={tagFilter}
onChange={(e) => setTagFilter(e.target.value as typeof tagFilter)}
aria-label="Filter tags by usage"
style={{
background: "var(--bg-elevated)",
border: "1px solid var(--border)",
borderRadius: 8,
padding: "0.55rem 0.8rem",
}}
>
<option value="all">All tags</option>
<option value="used">Used on projects</option>
<option value="unused">Unused only</option>
</select>
</div>
<table className="admin-table"> <table className="admin-table">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Slug</th> <th>Slug</th>
<th>Projects</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{tags.map((t) => ( {pageTags.length === 0 ? (
<tr key={t.id}> <tr>
<td>{t.name}</td> <td colSpan={4} style={{ color: "var(--text-dim)" }}>
<td> No tags match this filter.
<code>{t.slug}</code>
</td>
<td>
<button
type="button"
className="btn btn--danger btn--sm"
onClick={() => removeTag(t.id, t.name)}
>
Delete
</button>
</td> </td>
</tr> </tr>
))} ) : (
pageTags.map((t) => (
<tr key={t.id}>
<td>{t.name}</td>
<td>
<code>{t.slug}</code>
</td>
<td style={{ color: tagUsage(t) ? "var(--text-muted)" : "var(--text-dim)" }}>
{tagUsage(t)}
</td>
<td>
<button
type="button"
className="btn btn--danger btn--sm"
onClick={() => removeTag(t.id, t.name)}
>
Delete
</button>
</td>
</tr>
))
)}
</tbody> </tbody>
</table> </table>
{filteredTags.length > PAGE_SIZE && (
<div
className="admin-page__toolbar"
style={{ marginTop: "1rem", justifyContent: "center", gap: "1rem" }}
>
<button
type="button"
className="btn btn--ghost btn--sm"
disabled={page <= 1}
onClick={() => setTagPage((p) => Math.max(1, p - 1))}
>
Previous
</button>
<span style={{ color: "var(--text-muted)", fontSize: "0.9rem" }}>
Page {page} of {totalPages}
</span>
<button
type="button"
className="btn btn--ghost btn--sm"
disabled={page >= totalPages}
onClick={() => setTagPage((p) => Math.min(totalPages, p + 1))}
>
Next
</button>
</div>
)}
</div> </div>
); );
} }