Add clickable column sorting for admin tags list
This commit is contained in:
@@ -262,3 +262,30 @@
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clickable table sort headers
|
||||||
|
.admin-sort-th {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
text-transform: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export function AdminCategories() {
|
|||||||
const [tagQuery, setTagQuery] = useState("");
|
const [tagQuery, setTagQuery] = useState("");
|
||||||
const [tagFilter, setTagFilter] = useState<"all" | "used" | "unused">("all");
|
const [tagFilter, setTagFilter] = useState<"all" | "used" | "unused">("all");
|
||||||
const [tagPage, setTagPage] = useState(1);
|
const [tagPage, setTagPage] = useState(1);
|
||||||
|
const [sortKey, setSortKey] = useState<"name" | "slug" | "projects">("projects");
|
||||||
|
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||||
|
|
||||||
usePageTitle("Categories & Tags · Admin");
|
usePageTitle("Categories & Tags · Admin");
|
||||||
|
|
||||||
@@ -32,13 +34,29 @@ export function AdminCategories() {
|
|||||||
load();
|
load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Reset page when filters change
|
// Reset page when filters/sort change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTagPage(1);
|
setTagPage(1);
|
||||||
}, [tagQuery, tagFilter]);
|
}, [tagQuery, tagFilter, sortKey, sortDir]);
|
||||||
|
|
||||||
|
function toggleSort(key: "name" | "slug" | "projects") {
|
||||||
|
if (sortKey === key) {
|
||||||
|
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||||
|
} else {
|
||||||
|
setSortKey(key);
|
||||||
|
// Projects default to high→low; name/slug A→Z
|
||||||
|
setSortDir(key === "projects" ? "desc" : "asc");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortLabel(key: "name" | "slug" | "projects", label: string) {
|
||||||
|
if (sortKey !== key) return label;
|
||||||
|
return `${label} ${sortDir === "asc" ? "↑" : "↓"}`;
|
||||||
|
}
|
||||||
|
|
||||||
const filteredTags = useMemo(() => {
|
const filteredTags = useMemo(() => {
|
||||||
const q = tagQuery.trim().toLowerCase();
|
const q = tagQuery.trim().toLowerCase();
|
||||||
|
const dir = sortDir === "asc" ? 1 : -1;
|
||||||
return tags
|
return tags
|
||||||
.filter((t) => {
|
.filter((t) => {
|
||||||
if (q && !t.name.toLowerCase().includes(q) && !t.slug.toLowerCase().includes(q)) {
|
if (q && !t.name.toLowerCase().includes(q) && !t.slug.toLowerCase().includes(q)) {
|
||||||
@@ -50,13 +68,22 @@ export function AdminCategories() {
|
|||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
// Used first by count desc, then name
|
if (sortKey === "projects") {
|
||||||
const ua = tagUsage(a);
|
const diff = tagUsage(a) - tagUsage(b);
|
||||||
const ub = tagUsage(b);
|
if (diff !== 0) return diff * dir;
|
||||||
if (ua !== ub) return ub - ua;
|
return a.name.localeCompare(b.name);
|
||||||
return a.name.localeCompare(b.name);
|
}
|
||||||
|
if (sortKey === "slug") {
|
||||||
|
const diff = a.slug.localeCompare(b.slug);
|
||||||
|
if (diff !== 0) return diff * dir;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
}
|
||||||
|
// name
|
||||||
|
const diff = a.name.localeCompare(b.name);
|
||||||
|
if (diff !== 0) return diff * dir;
|
||||||
|
return tagUsage(b) - tagUsage(a);
|
||||||
});
|
});
|
||||||
}, [tags, tagQuery, tagFilter]);
|
}, [tags, tagQuery, tagFilter, sortKey, sortDir]);
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filteredTags.length / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil(filteredTags.length / PAGE_SIZE));
|
||||||
const page = Math.min(tagPage, totalPages);
|
const page = Math.min(tagPage, totalPages);
|
||||||
@@ -232,9 +259,46 @@ export function AdminCategories() {
|
|||||||
<table className="admin-table">
|
<table className="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>
|
||||||
<th>Slug</th>
|
<button
|
||||||
<th>Projects</th>
|
type="button"
|
||||||
|
className="admin-sort-th"
|
||||||
|
onClick={() => toggleSort("name")}
|
||||||
|
aria-sort={
|
||||||
|
sortKey === "name" ? (sortDir === "asc" ? "ascending" : "descending") : "none"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sortLabel("name", "Name")}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-sort-th"
|
||||||
|
onClick={() => toggleSort("slug")}
|
||||||
|
aria-sort={
|
||||||
|
sortKey === "slug" ? (sortDir === "asc" ? "ascending" : "descending") : "none"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sortLabel("slug", "Slug")}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-sort-th"
|
||||||
|
onClick={() => toggleSort("projects")}
|
||||||
|
aria-sort={
|
||||||
|
sortKey === "projects"
|
||||||
|
? sortDir === "asc"
|
||||||
|
? "ascending"
|
||||||
|
: "descending"
|
||||||
|
: "none"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sortLabel("projects", "Projects")}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
Reference in New Issue
Block a user