166 lines
5.2 KiB
TypeScript
166 lines
5.2 KiB
TypeScript
|
|
'use client';
|
|||
|
|
|
|||
|
|
import { useState } from 'react';
|
|||
|
|
import { useRouter } from 'next/navigation';
|
|||
|
|
import { JsonForm, type JsonValue } from './JsonForm';
|
|||
|
|
|
|||
|
|
type Status = 'idle' | 'saving' | 'saved' | 'error';
|
|||
|
|
type Tab = 'meta' | 'fa' | 'en';
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Edits a single blog article body. Top-level `date` / `accent` live in the
|
|||
|
|
* "Meta" tab; the long-form FA and EN articles each get their own tab so the
|
|||
|
|
* Persian body renders RTL. Saving stores the whole PostContent under the
|
|||
|
|
* article's slug via /api/admin/posts.
|
|||
|
|
*/
|
|||
|
|
export function PostEditor({
|
|||
|
|
slug,
|
|||
|
|
title,
|
|||
|
|
initial,
|
|||
|
|
isOverridden,
|
|||
|
|
}: {
|
|||
|
|
slug: string;
|
|||
|
|
title: string;
|
|||
|
|
initial: { date: JsonValue; accent: JsonValue; fa: JsonValue; en: JsonValue };
|
|||
|
|
isOverridden: boolean;
|
|||
|
|
}) {
|
|||
|
|
const router = useRouter();
|
|||
|
|
const [data, setData] = useState(initial);
|
|||
|
|
const [tab, setTab] = useState<Tab>('meta');
|
|||
|
|
const [status, setStatus] = useState<Status>('idle');
|
|||
|
|
const [overridden, setOverridden] = useState(isOverridden);
|
|||
|
|
|
|||
|
|
async function save() {
|
|||
|
|
setStatus('saving');
|
|||
|
|
try {
|
|||
|
|
const payload = {
|
|||
|
|
date: data.date,
|
|||
|
|
accent: data.accent,
|
|||
|
|
en: data.en,
|
|||
|
|
fa: data.fa,
|
|||
|
|
};
|
|||
|
|
const res = await fetch('/api/admin/posts', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'content-type': 'application/json' },
|
|||
|
|
body: JSON.stringify({ slug, data: payload }),
|
|||
|
|
});
|
|||
|
|
if (!res.ok) throw new Error(String(res.status));
|
|||
|
|
setStatus('saved');
|
|||
|
|
setOverridden(true);
|
|||
|
|
router.refresh();
|
|||
|
|
setTimeout(() => setStatus('idle'), 2500);
|
|||
|
|
} catch {
|
|||
|
|
setStatus('error');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function reset() {
|
|||
|
|
if (!confirm('Revert this article to its built-in default? Your edits will be removed.')) return;
|
|||
|
|
setStatus('saving');
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/admin/posts?slug=${encodeURIComponent(slug)}`, {
|
|||
|
|
method: 'DELETE',
|
|||
|
|
});
|
|||
|
|
if (!res.ok) throw new Error(String(res.status));
|
|||
|
|
router.refresh();
|
|||
|
|
window.location.reload();
|
|||
|
|
} catch {
|
|||
|
|
setStatus('error');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="flex flex-col gap-6">
|
|||
|
|
{/* Toolbar */}
|
|||
|
|
<div className="sticky top-0 z-10 -mx-6 flex flex-wrap items-center justify-between gap-3 border-b border-white/8 bg-base-900/80 px-6 py-3 backdrop-blur sm:-mx-8 sm:px-8">
|
|||
|
|
<div className="flex items-center gap-1 rounded-full border border-white/10 bg-white/[0.02] p-1">
|
|||
|
|
<TabBtn active={tab === 'meta'} onClick={() => setTab('meta')}>Meta</TabBtn>
|
|||
|
|
<TabBtn active={tab === 'fa'} onClick={() => setTab('fa')}>FA · فارسی</TabBtn>
|
|||
|
|
<TabBtn active={tab === 'en'} onClick={() => setTab('en')}>EN · English</TabBtn>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
{status === 'saved' && <span className="text-sm text-emerald">Saved ✓</span>}
|
|||
|
|
{status === 'error' && <span className="text-sm text-magenta">Save failed</span>}
|
|||
|
|
{overridden && (
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={reset}
|
|||
|
|
className="rounded-lg border border-white/10 px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/[0.05]"
|
|||
|
|
>
|
|||
|
|
Reset to default
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={save}
|
|||
|
|
disabled={status === 'saving'}
|
|||
|
|
className="rounded-lg bg-electric px-4 py-2 text-sm font-semibold text-base-900 transition-opacity hover:opacity-90 disabled:opacity-50"
|
|||
|
|
>
|
|||
|
|
{status === 'saving' ? 'Saving…' : 'Save changes'}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{tab === 'meta' && (
|
|||
|
|
<div className="flex flex-col gap-5 pb-24">
|
|||
|
|
<p className="rounded-lg border border-white/8 bg-white/[0.02] p-3 text-xs text-slate-400">
|
|||
|
|
Editing <span className="font-mono text-electric">{slug}</span>. Accent must be one of:
|
|||
|
|
electric, violet, magenta, emerald, cyan. The card title/excerpt live under the
|
|||
|
|
<span className="font-mono"> Journal</span> section.
|
|||
|
|
</p>
|
|||
|
|
<JsonForm
|
|||
|
|
value={{ date: data.date, accent: data.accent }}
|
|||
|
|
onChange={(nv) => {
|
|||
|
|
const o = nv as { date: JsonValue; accent: JsonValue };
|
|||
|
|
setData((d) => ({ ...d, date: o.date, accent: o.accent }));
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{tab === 'fa' && (
|
|||
|
|
<div dir="rtl" className="pb-24">
|
|||
|
|
<JsonForm
|
|||
|
|
key="fa"
|
|||
|
|
value={data.fa}
|
|||
|
|
onChange={(nv) => setData((d) => ({ ...d, fa: nv }))}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{tab === 'en' && (
|
|||
|
|
<div dir="ltr" className="pb-24">
|
|||
|
|
<JsonForm
|
|||
|
|
key="en"
|
|||
|
|
value={data.en}
|
|||
|
|
onChange={(nv) => setData((d) => ({ ...d, en: nv }))}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function TabBtn({
|
|||
|
|
active,
|
|||
|
|
onClick,
|
|||
|
|
children,
|
|||
|
|
}: {
|
|||
|
|
active: boolean;
|
|||
|
|
onClick: () => void;
|
|||
|
|
children: React.ReactNode;
|
|||
|
|
}) {
|
|||
|
|
return (
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={onClick}
|
|||
|
|
className={
|
|||
|
|
'rounded-full px-4 py-1.5 text-sm font-medium transition-colors ' +
|
|||
|
|
(active ? 'bg-electric text-base-900' : 'text-slate-300 hover:text-white')
|
|||
|
|
}
|
|||
|
|
>
|
|||
|
|
{children}
|
|||
|
|
</button>
|
|||
|
|
);
|
|||
|
|
}
|