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:
@@ -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<Category[]>([]);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [tags, setTags] = useState<TagRow[]>([]);
|
||||
const [name, setName] = useState("");
|
||||
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() {
|
||||
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.
|
||||
</p>
|
||||
{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}>
|
||||
<input
|
||||
@@ -94,35 +174,128 @@ export function AdminCategories() {
|
||||
</tbody>
|
||||
</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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Slug</th>
|
||||
<th>Projects</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tags.map((t) => (
|
||||
<tr key={t.id}>
|
||||
<td>{t.name}</td>
|
||||
<td>
|
||||
<code>{t.slug}</code>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--danger btn--sm"
|
||||
onClick={() => removeTag(t.id, t.name)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
{pageTags.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} style={{ color: "var(--text-dim)" }}>
|
||||
No tags match this filter.
|
||||
</td>
|
||||
</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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user