initial commit
This commit is contained in:
98
components/TaxonomyTable.tsx
Normal file
98
components/TaxonomyTable.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/client";
|
||||
import { categoryLabel, slugify } from "@/lib/validate";
|
||||
|
||||
type Item = { name: string; count: number };
|
||||
type Props = { kind: "category" | "tag"; items: Item[]; revision: string };
|
||||
|
||||
/** Rename / merge (both kinds) and remove (tags only) across every post. */
|
||||
export default function TaxonomyTable({ kind, items, revision }: Props) {
|
||||
const router = useRouter();
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{ kind: "ok" | "error"; text: string } | null>(null);
|
||||
|
||||
const endpoint = kind === "category" ? "/api/categories" : "/api/tags";
|
||||
const names = new Set(items.map((i) => i.name));
|
||||
|
||||
async function apply(from: string, to: string) {
|
||||
const target = kind === "category" ? slugify(to) : to.trim();
|
||||
if (kind === "category" && !target) return;
|
||||
if (target === from) return;
|
||||
if (!target && !window.confirm(`Remove the tag "${from}" from every post?`)) return;
|
||||
if (target && names.has(target) && !window.confirm(`"${target}" already exists. Merge "${from}" into it?`)) return;
|
||||
|
||||
setBusy(from);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await api<{ changed: number }>(endpoint, { method: "PATCH", json: { from, to: target, revision } });
|
||||
const verb = target ? `renamed to "${target}"` : "removed";
|
||||
setMessage({ kind: "ok", text: `"${from}" ${verb} on ${res.changed} post(s).` });
|
||||
setDrafts((d) => ({ ...d, [from]: "" }));
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setMessage({ kind: "error", text: (err as Error).message });
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{message && <div className={`notice ${message.kind}`}>{message.text}</div>}
|
||||
<table className="list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{kind}</th>
|
||||
{kind === "category" && <th>displayed as</th>}
|
||||
<th>posts</th>
|
||||
<th>rename / merge into</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => {
|
||||
const draft = drafts[item.name] ?? "";
|
||||
return (
|
||||
<tr key={item.name}>
|
||||
<td>
|
||||
<span className="chip">{item.name}</span>
|
||||
</td>
|
||||
{kind === "category" && <td>{categoryLabel(item.name)}</td>}
|
||||
<td className="mono small">{item.count}</td>
|
||||
<td>
|
||||
<input
|
||||
value={draft}
|
||||
placeholder={kind === "category" ? "new-category-slug" : "new tag"}
|
||||
onChange={(e) => setDrafts((d) => ({ ...d, [item.name]: e.target.value }))}
|
||||
onKeyDown={(e) => e.key === "Enter" && draft.trim() && apply(item.name, draft)}
|
||||
/>
|
||||
</td>
|
||||
<td className="actions">
|
||||
<button disabled={!draft.trim() || busy === item.name} onClick={() => apply(item.name, draft)}>
|
||||
{names.has(kind === "category" ? slugify(draft) : draft.trim()) ? "Merge" : "Rename"}
|
||||
</button>
|
||||
{kind === "tag" && (
|
||||
<button className="danger" disabled={busy === item.name} onClick={() => apply(item.name, "")}>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="muted">
|
||||
None yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user