318 lines
11 KiB
TypeScript
318 lines
11 KiB
TypeScript
|
|
'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 Doc = {
|
||
|
|
id: string;
|
||
|
|
slug: string;
|
||
|
|
title: string;
|
||
|
|
section: string;
|
||
|
|
order: number;
|
||
|
|
body: string;
|
||
|
|
status: string;
|
||
|
|
updatedAt: string;
|
||
|
|
};
|
||
|
|
type FormState = {
|
||
|
|
id: string | null;
|
||
|
|
title: string;
|
||
|
|
slug: string;
|
||
|
|
slugEdited: boolean;
|
||
|
|
section: string;
|
||
|
|
order: number;
|
||
|
|
body: string;
|
||
|
|
status: 'draft' | 'published';
|
||
|
|
};
|
||
|
|
|
||
|
|
const EMPTY = (section: string): FormState => ({
|
||
|
|
id: null,
|
||
|
|
title: '',
|
||
|
|
slug: '',
|
||
|
|
slugEdited: false,
|
||
|
|
section,
|
||
|
|
order: 1,
|
||
|
|
body: '',
|
||
|
|
status: 'published',
|
||
|
|
});
|
||
|
|
|
||
|
|
export function DocsManager({ sectionOptions }: { sectionOptions: string[] }) {
|
||
|
|
const [items, setItems] = useState<Doc[]>([]);
|
||
|
|
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/docs${status ? `?status=${status}` : ''}`);
|
||
|
|
if (!res.ok) throw new Error('request failed');
|
||
|
|
const data = (await res.json()) as { docs: Doc[] };
|
||
|
|
setItems(data.docs);
|
||
|
|
} catch {
|
||
|
|
setError(t('common.error'));
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
}, [status]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
void load();
|
||
|
|
}, [load]);
|
||
|
|
|
||
|
|
function openCreate() {
|
||
|
|
setFormError(null);
|
||
|
|
setShowPreview(false);
|
||
|
|
setForm(EMPTY(sectionOptions[0] ?? 'Getting started'));
|
||
|
|
}
|
||
|
|
|
||
|
|
function openEdit(doc: Doc) {
|
||
|
|
setFormError(null);
|
||
|
|
setShowPreview(false);
|
||
|
|
setForm({
|
||
|
|
id: doc.id,
|
||
|
|
title: doc.title,
|
||
|
|
slug: doc.slug,
|
||
|
|
slugEdited: true,
|
||
|
|
section: doc.section,
|
||
|
|
order: doc.order,
|
||
|
|
body: doc.body,
|
||
|
|
status: doc.status === 'draft' ? 'draft' : 'published',
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
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 res = await fetch(form.id ? `/api/admin/docs/${form.id}` : '/api/admin/docs', {
|
||
|
|
method: form.id ? 'PATCH' : 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({
|
||
|
|
title: form.title,
|
||
|
|
slug: form.slug,
|
||
|
|
section: form.section,
|
||
|
|
order: Number(form.order) || 1,
|
||
|
|
body: form.body,
|
||
|
|
status: form.status,
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
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(doc: Doc) {
|
||
|
|
setBusy(true);
|
||
|
|
await fetch(`/api/admin/docs/${doc.id}`, {
|
||
|
|
method: 'PATCH',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({
|
||
|
|
title: doc.title,
|
||
|
|
slug: doc.slug,
|
||
|
|
section: doc.section,
|
||
|
|
order: doc.order,
|
||
|
|
body: doc.body,
|
||
|
|
status: doc.status === 'published' ? 'draft' : 'published',
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
setBusy(false);
|
||
|
|
await load();
|
||
|
|
}
|
||
|
|
|
||
|
|
async function remove(doc: Doc) {
|
||
|
|
if (!window.confirm(`${t('admin.delete')} “${doc.title}”? This cannot be undone.`)) return;
|
||
|
|
setBusy(true);
|
||
|
|
await fetch(`/api/admin/docs/${doc.id}`, { method: 'DELETE' });
|
||
|
|
setBusy(false);
|
||
|
|
await load();
|
||
|
|
}
|
||
|
|
|
||
|
|
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.docs')}</h1>
|
||
|
|
<p className="muted mt-1 text-sm">{items.length} pages</p>
|
||
|
|
</div>
|
||
|
|
<button type="button" className="btn-primary" onClick={openCreate}>
|
||
|
|
{t('admin.newDoc')}
|
||
|
|
</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.editDoc') : t('admin.newDoc')}</h2>
|
||
|
|
<button type="button" className="btn-ghost" onClick={() => setShowPreview((v) => !v)}>
|
||
|
|
{showPreview ? t('admin.editDoc') : 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={form.body} />
|
||
|
|
</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.section')}
|
||
|
|
<input
|
||
|
|
className="input mt-1"
|
||
|
|
list="section-options"
|
||
|
|
value={form.section}
|
||
|
|
onChange={(e) => setField('section', e.target.value)}
|
||
|
|
/>
|
||
|
|
<datalist id="section-options">
|
||
|
|
{sectionOptions.map((s) => (
|
||
|
|
<option key={s} value={s} />
|
||
|
|
))}
|
||
|
|
</datalist>
|
||
|
|
</label>
|
||
|
|
<label className="text-sm">
|
||
|
|
{t('admin.order')}
|
||
|
|
<input
|
||
|
|
className="input mt-1"
|
||
|
|
type="number"
|
||
|
|
min={0}
|
||
|
|
value={form.order}
|
||
|
|
onChange={(e) => setField('order', Number(e.target.value))}
|
||
|
|
/>
|
||
|
|
</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="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.section')}</th>
|
||
|
|
<th className="px-4 py-3">{t('admin.order')}</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((doc) => (
|
||
|
|
<tr key={doc.id} className="border-t" style={{ borderColor: 'var(--border)' }}>
|
||
|
|
<td className="px-4 py-3 font-medium">{doc.title}</td>
|
||
|
|
<td className="px-4 py-3">{doc.section}</td>
|
||
|
|
<td className="px-4 py-3">{doc.order}</td>
|
||
|
|
<td className="px-4 py-3">
|
||
|
|
<Badge tone={doc.status === 'published' ? 'success' : 'warning'}>
|
||
|
|
{doc.status === 'published' ? t('admin.published') : t('admin.draft')}
|
||
|
|
</Badge>
|
||
|
|
</td>
|
||
|
|
<td className="px-4 py-3">{formatDate(doc.updatedAt)}</td>
|
||
|
|
<td className="px-4 py-3">
|
||
|
|
<div className="flex justify-end gap-2">
|
||
|
|
<button type="button" className="btn-secondary" onClick={() => openEdit(doc)}>
|
||
|
|
{t('admin.edit')}
|
||
|
|
</button>
|
||
|
|
<button type="button" className="btn-secondary" disabled={busy} onClick={() => toggleStatus(doc)}>
|
||
|
|
{doc.status === 'published' ? t('admin.unpublish') : t('admin.publish')}
|
||
|
|
</button>
|
||
|
|
<button type="button" className="btn-danger" disabled={busy} onClick={() => remove(doc)}>
|
||
|
|
{t('admin.delete')}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
))}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|