initial commit

This commit is contained in:
Hunter
2026-11-01 13:23:44 -05:00
commit e03ea4f770
53 changed files with 10802 additions and 0 deletions

View 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>
</>
);
}