128 lines
4.1 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|