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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user