99 lines
3.7 KiB
TypeScript
99 lines
3.7 KiB
TypeScript
"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>
|
|
</>
|
|
);
|
|
}
|