Files
blog-manager/components/PostTable.tsx
2026-11-01 13:23:44 -05:00

128 lines
4.1 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { api } from "@/lib/client";
import { categoryLabel } from "@/lib/validate";
import type { BlogPost } from "@/lib/types";
type Props = { posts: BlogPost[]; revision: string; categories: string[] };
export default function PostTable({ posts, revision, categories }: Props) {
const router = useRouter();
const [query, setQuery] = useState("");
const [category, setCategory] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
return posts.filter(
(p) =>
(!category || p.category === category) &&
(!q || [p.title, p.slug, p.description, ...p.tags].some((s) => s.toLowerCase().includes(q)))
);
}, [posts, query, category]);
async function remove(post: BlogPost) {
const typed = window.prompt(`Delete "${post.title}"?\n\nType the slug (${post.slug}) to confirm.`);
if (typed === null) return;
if (typed.trim() !== post.slug) {
setError("Slug didn't match; nothing was deleted.");
return;
}
const withImages = window.confirm(
`Also delete the image folder public/img/blog/${post.slug}/?\n\nOK = delete images too, Cancel = keep them.`
);
setBusy(post.slug);
setError(null);
try {
const params = new URLSearchParams({ revision, ...(withImages ? { images: "1" } : {}) });
await api(`/api/posts/${post.slug}?${params}`, { method: "DELETE" });
router.refresh();
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(null);
}
}
return (
<>
<div className="toolbar-row">
<h1 style={{ margin: 0, flex: 1 }}>Posts</h1>
<input placeholder="search title, slug, tags…" value={query} onChange={(e) => setQuery(e.target.value)} />
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="">all categories</option>
{categories.map((c) => (
<option key={c} value={c}>
{categoryLabel(c)}
</option>
))}
</select>
<Link href="/posts/new" className="button primary">
+ New post
</Link>
</div>
{error && <div className="notice error">{error}</div>}
<table className="list">
<thead>
<tr>
<th>title</th>
<th>date</th>
<th>category</th>
<th>tags</th>
<th>images</th>
<th />
</tr>
</thead>
<tbody>
{visible.map((post) => (
<tr key={post.slug}>
<td>
<Link href={`/posts/${post.slug}`} className="title-cell">
{post.title}
</Link>
<div className="mono small muted">/blog/{post.slug}</div>
</td>
<td className="mono small">{post.date}</td>
<td>
<span className="chip">{post.category}</span>
</td>
<td>
{post.tags.map((t) => (
<span key={t} className="chip">
{t}
</span>
))}
</td>
<td className="mono small">{post.images?.length ?? 0}</td>
<td className="actions">
<Link href={`/posts/${post.slug}`} className="button">
Edit
</Link>
<button className="danger" disabled={busy === post.slug} onClick={() => remove(post)}>
Delete
</button>
</td>
</tr>
))}
{visible.length === 0 && (
<tr>
<td colSpan={6} className="muted">
{posts.length ? "No posts match." : "No posts yet."}
</td>
</tr>
)}
</tbody>
</table>
<p className="muted small">
{visible.length} of {posts.length} posts
</p>
</>
);
}