templates2.Content.Next/components/admin/PostsManager.tsx

352 lines
12 KiB
TypeScript
Raw Normal View History

2026-10-11 20:29:57 +00:00
'use client';
import { useCallback, useEffect, useState } from 'react';
import { Badge } from '@/components/ui';
import { Markdown } from '@/components/content/Markdown';
import { t } from '@/lib/i18n';
import { formatDate, slugify } from '@/lib/utils';
type PostTag = { tag: { id: string; name: string; slug: string } };
type Post = {
id: string;
slug: string;
title: string;
excerpt: string;
body: string;
status: string;
featured: boolean;
readingMinutes: number;
publishedAt: string | null;
updatedAt: string;
author: { id: string; name: string };
tags: PostTag[];
};
type AuthorOption = { id: string; name: string };
type FormState = {
id: string | null;
title: string;
slug: string;
slugEdited: boolean;
excerpt: string;
body: string;
status: 'draft' | 'published';
featured: boolean;
authorId: string;
tags: string;
};
const EMPTY = (authorId: string): FormState => ({
id: null,
title: '',
slug: '',
slugEdited: false,
excerpt: '',
body: '',
status: 'draft',
featured: false,
authorId,
tags: '',
});
export function PostsManager({ authors, tagOptions }: { authors: AuthorOption[]; tagOptions: string[] }) {
const [items, setItems] = useState<Post[]>([]);
const [status, setStatus] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [form, setForm] = useState<FormState | null>(null);
const [showPreview, setShowPreview] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/admin/posts${status ? `?status=${status}` : ''}`);
if (!res.ok) throw new Error('request failed');
const data = (await res.json()) as { posts: Post[] };
setItems(data.posts);
} catch {
setError(t('common.error'));
} finally {
setLoading(false);
}
}, [status]);
useEffect(() => {
void load();
}, [load]);
function openCreate() {
setFormError(null);
setShowPreview(false);
setForm(EMPTY(authors[0]?.id ?? ''));
}
function openEdit(post: Post) {
setFormError(null);
setShowPreview(false);
setForm({
id: post.id,
title: post.title,
slug: post.slug,
slugEdited: true,
excerpt: post.excerpt,
body: post.body,
status: post.status === 'published' ? 'published' : 'draft',
featured: post.featured,
authorId: post.author.id,
tags: post.tags.map(({ tag }) => tag.name).join(', '),
});
}
function setField<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((prev) => (prev ? { ...prev, [key]: value } : prev));
}
async function save() {
if (!form) return;
setBusy(true);
setFormError(null);
const payload = {
title: form.title,
slug: form.slug,
excerpt: form.excerpt,
body: form.body,
status: form.status,
featured: form.featured,
authorId: form.authorId,
tags: form.tags
.split(',')
.map((s) => s.trim())
.filter(Boolean),
};
const res = await fetch(form.id ? `/api/admin/posts/${form.id}` : '/api/admin/posts', {
method: form.id ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
setBusy(false);
if (!res.ok) {
setFormError(((await res.json().catch(() => ({}))) as { error?: string }).error || t('common.error'));
return;
}
setForm(null);
await load();
}
async function toggleStatus(post: Post) {
setBusy(true);
const next = post.status === 'published' ? 'draft' : 'published';
await fetch(`/api/admin/posts/${post.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: post.title,
slug: post.slug,
excerpt: post.excerpt,
body: post.body,
status: next,
featured: post.featured,
authorId: post.author.id,
tags: post.tags.map(({ tag }) => tag.name),
}),
});
setBusy(false);
await load();
}
async function remove(post: Post) {
if (!window.confirm(`${t('admin.delete')} “${post.title}”? This cannot be undone.`)) return;
setBusy(true);
await fetch(`/api/admin/posts/${post.id}`, { method: 'DELETE' });
setBusy(false);
await load();
}
const previewBody = form?.body ?? '';
const suggestedSlug = form ? (form.slugEdited ? form.slug : slugify(form.title)) : '';
return (
<div className="space-y-6">
<header className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold">{t('admin.posts')}</h1>
<p className="muted mt-1 text-sm">{items.length} posts</p>
</div>
<button type="button" className="btn-primary" onClick={openCreate} disabled={authors.length === 0}>
{t('admin.newPost')}
</button>
</header>
<div className="card flex flex-wrap items-end gap-3">
<label className="text-sm">
{t('admin.status')}
<select className="input mt-1" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">{t('admin.all')}</option>
<option value="draft">{t('admin.draft')}</option>
<option value="published">{t('admin.published')}</option>
</select>
</label>
</div>
{form && (
<div className="card space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{form.id ? t('admin.editPost') : t('admin.newPost')}</h2>
<button type="button" className="btn-ghost" onClick={() => setShowPreview((v) => !v)}>
{showPreview ? t('admin.editPost') : t('admin.livePreview')}
</button>
</div>
{showPreview ? (
<div className="rounded-xl border p-4" style={{ borderColor: 'var(--border)' }}>
<h3 className="text-xl font-bold">{form.title || t('admin.untitled')}</h3>
<p className="muted mt-1 text-xs">{t('admin.previewHint')}</p>
<Markdown body={previewBody} />
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
<label className="text-sm sm:col-span-2">
{t('admin.title')}
<input
className="input mt-1"
required
value={form.title}
onChange={(e) => {
const title = e.target.value;
setForm((prev) => (prev ? { ...prev, title, slug: prev.slugEdited ? prev.slug : slugify(title) } : prev));
}}
/>
</label>
<label className="text-sm">
{t('admin.slug')}
<input
className="input mt-1"
value={suggestedSlug}
onChange={(e) => setForm((prev) => (prev ? { ...prev, slug: e.target.value, slugEdited: true } : prev))}
/>
</label>
<label className="text-sm">
{t('admin.author')}
<select className="input mt-1" value={form.authorId} onChange={(e) => setField('authorId', e.target.value)}>
{authors.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
</label>
<label className="text-sm sm:col-span-2">
{t('admin.excerpt')}
<input className="input mt-1" value={form.excerpt} onChange={(e) => setField('excerpt', e.target.value)} />
</label>
<label className="text-sm sm:col-span-2">
{t('admin.tags')}
<input
className="input mt-1"
list="tag-options"
value={form.tags}
onChange={(e) => setField('tags', e.target.value)}
placeholder={tagOptions.join(', ')}
/>
<datalist id="tag-options">
{tagOptions.map((tag) => (
<option key={tag} value={tag} />
))}
</datalist>
</label>
<label className="text-sm">
{t('admin.status')}
<select
className="input mt-1"
value={form.status}
onChange={(e) => setField('status', e.target.value as FormState['status'])}
>
<option value="draft">{t('admin.draft')}</option>
<option value="published">{t('admin.published')}</option>
</select>
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.featured} onChange={(e) => setField('featured', e.target.checked)} />
{t('admin.featured')}
</label>
<label className="text-sm sm:col-span-2">
{t('admin.body')}
<textarea
className="input mt-1 min-h-64 font-mono"
value={form.body}
onChange={(e) => setField('body', e.target.value)}
/>
</label>
</div>
)}
{formError && <p className="text-sm text-red-600">{formError}</p>}
<div className="flex gap-2">
<button type="button" className="btn-primary" disabled={busy} onClick={save}>
{t('admin.save')}
</button>
<button type="button" className="btn-secondary" onClick={() => setForm(null)}>
{t('admin.cancel')}
</button>
</div>
</div>
)}
{error && <p className="text-sm text-red-600">{error}</p>}
{loading ? (
<p className="muted text-sm">{t('common.loading')}</p>
) : items.length === 0 ? (
<p className="muted py-6 text-center text-sm">{t('admin.empty')}</p>
) : (
<div className="overflow-x-auto rounded-xl border" style={{ borderColor: 'var(--border)' }}>
<table className="w-full text-sm">
<thead className="muted text-left text-xs uppercase">
<tr>
<th className="px-4 py-3">{t('admin.title')}</th>
<th className="px-4 py-3">{t('admin.author')}</th>
<th className="px-4 py-3">{t('admin.tags')}</th>
<th className="px-4 py-3">{t('admin.status')}</th>
<th className="px-4 py-3">{t('admin.updated')}</th>
<th className="px-4 py-3 text-right">{t('admin.actions')}</th>
</tr>
</thead>
<tbody>
{items.map((post) => (
<tr key={post.id} className="border-t" style={{ borderColor: 'var(--border)' }}>
<td className="px-4 py-3 font-medium">
{post.title}
{post.featured && <Badge tone="brand">★</Badge>}
</td>
<td className="px-4 py-3">{post.author.name}</td>
<td className="px-4 py-3">{post.tags.map(({ tag }) => tag.name).join(', ') || t('admin.none')}</td>
<td className="px-4 py-3">
<Badge tone={post.status === 'published' ? 'success' : 'warning'}>
{post.status === 'published' ? t('admin.published') : t('admin.draft')}
</Badge>
</td>
<td className="px-4 py-3">{formatDate(post.updatedAt)}</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<button type="button" className="btn-secondary" onClick={() => openEdit(post)}>
{t('admin.edit')}
</button>
<button type="button" className="btn-secondary" disabled={busy} onClick={() => toggleStatus(post)}>
{post.status === 'published' ? t('admin.unpublish') : t('admin.publish')}
</button>
<button type="button" className="btn-danger" disabled={busy} onClick={() => remove(post)}>
{t('admin.delete')}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}