first commit
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { EDITABLE_SECTIONS } from '@/lib/content/sections';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function AdminShell({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
async function logout() {
|
||||
await fetch('/api/admin/logout', { method: 'POST' });
|
||||
router.replace('/admin/login');
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div dir="ltr" className="flex min-h-screen bg-base-900 text-slate-200">
|
||||
{/* Sidebar */}
|
||||
<aside className="hidden w-64 shrink-0 flex-col border-e border-white/8 bg-base-900/60 p-4 md:flex">
|
||||
<Link href="/admin" className="mb-6 flex items-center gap-2 px-2">
|
||||
<span className="grid h-8 w-8 place-items-center rounded-lg bg-electric/15 font-mono text-sm font-bold text-electric">
|
||||
SA
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-white">Content CMS</span>
|
||||
</Link>
|
||||
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
<SideLink href="/admin" active={pathname === '/admin'}>
|
||||
Dashboard
|
||||
</SideLink>
|
||||
<div className="mt-3 px-3 pb-1 font-mono text-[0.6rem] uppercase tracking-wider text-slate-600">
|
||||
Sections
|
||||
</div>
|
||||
{EDITABLE_SECTIONS.map((s) => {
|
||||
const href = `/admin/sections/${s.key}`;
|
||||
return (
|
||||
<SideLink key={s.key} href={href} active={pathname === href}>
|
||||
{s.label.en}
|
||||
</SideLink>
|
||||
);
|
||||
})}
|
||||
<div className="mt-3 px-3 pb-1 font-mono text-[0.6rem] uppercase tracking-wider text-slate-600">
|
||||
Content
|
||||
</div>
|
||||
<SideLink href="/admin/posts" active={pathname.startsWith('/admin/posts')}>
|
||||
Journal articles
|
||||
</SideLink>
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-1 pt-4">
|
||||
<a
|
||||
href="/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-lg px-3 py-2 text-sm text-slate-400 transition-colors hover:bg-white/[0.04] hover:text-white"
|
||||
>
|
||||
View site ↗
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="rounded-lg px-3 py-2 text-start text-sm text-slate-400 transition-colors hover:bg-white/[0.04] hover:text-white"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Mobile top bar */}
|
||||
<div className="flex min-w-0 grow flex-col">
|
||||
<header className="flex items-center justify-between border-b border-white/8 px-5 py-3 md:hidden">
|
||||
<Link href="/admin" className="text-sm font-semibold text-white">
|
||||
Content CMS
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="text-sm text-slate-400 hover:text-white"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Mobile section selector */}
|
||||
<nav className="flex gap-2 overflow-x-auto border-b border-white/8 px-5 py-2 md:hidden">
|
||||
<MobileChip href="/admin" active={pathname === '/admin'} label="Dashboard" />
|
||||
{EDITABLE_SECTIONS.map((s) => (
|
||||
<MobileChip
|
||||
key={s.key}
|
||||
href={`/admin/sections/${s.key}`}
|
||||
active={pathname === `/admin/sections/${s.key}`}
|
||||
label={s.label.en}
|
||||
/>
|
||||
))}
|
||||
<MobileChip
|
||||
href="/admin/posts"
|
||||
active={pathname.startsWith('/admin/posts')}
|
||||
label="Articles"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<main className="grow px-6 py-6 sm:px-8">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SideLink({
|
||||
href,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
active: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
'rounded-lg px-3 py-2 text-sm transition-colors',
|
||||
active
|
||||
? 'bg-electric/12 font-medium text-electric'
|
||||
: 'text-slate-400 hover:bg-white/[0.04] hover:text-white',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileChip({
|
||||
href,
|
||||
active,
|
||||
label,
|
||||
}: {
|
||||
href: string;
|
||||
active: boolean;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
'shrink-0 rounded-full border px-3 py-1 text-xs transition-colors',
|
||||
active
|
||||
? 'border-electric/40 bg-electric/10 text-electric'
|
||||
: 'border-white/10 text-slate-400',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
export type JsonValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| JsonValue[]
|
||||
| { [k: string]: JsonValue };
|
||||
|
||||
const IMAGE_KEYS = new Set(['cover', 'image', 'avatar', 'gallery', 'logo', 'icon']);
|
||||
const IMAGE_RE = /\.(png|jpe?g|svg|webp|gif|avif)$/i;
|
||||
|
||||
function looksLikeImage(key: string | undefined, value: string): boolean {
|
||||
if (key && IMAGE_KEYS.has(key)) return true;
|
||||
return IMAGE_RE.test(value) || value.startsWith('/api/uploads/') || value.startsWith('/portfolio/');
|
||||
}
|
||||
|
||||
/** Produce an "empty" clone of a sample value, for new array entries. */
|
||||
function emptyLike(sample: JsonValue | undefined): JsonValue {
|
||||
if (sample === undefined || sample === null) return '';
|
||||
if (typeof sample === 'string') return '';
|
||||
if (typeof sample === 'number') return 0;
|
||||
if (typeof sample === 'boolean') return false;
|
||||
if (Array.isArray(sample)) return [];
|
||||
const out: Record<string, JsonValue> = {};
|
||||
for (const [k, v] of Object.entries(sample)) out[k] = emptyLike(v);
|
||||
return out;
|
||||
}
|
||||
|
||||
function humanize(key: string): string {
|
||||
return key
|
||||
.replace(/[_-]/g, ' ')
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.replace(/^\w/, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
export function JsonForm({
|
||||
value,
|
||||
onChange,
|
||||
fieldKey,
|
||||
depth = 0,
|
||||
}: {
|
||||
value: JsonValue;
|
||||
onChange: (v: JsonValue) => void;
|
||||
fieldKey?: string;
|
||||
depth?: number;
|
||||
}) {
|
||||
// ---- Primitive: string ----
|
||||
if (typeof value === 'string') {
|
||||
if (looksLikeImage(fieldKey, value)) {
|
||||
return <ImageInput value={value} onChange={onChange} />;
|
||||
}
|
||||
const multiline = value.length > 64 || value.includes('\n');
|
||||
return multiline ? (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={Math.min(8, Math.max(2, Math.ceil(value.length / 60)))}
|
||||
className="w-full resize-y rounded-lg border border-white/10 bg-base-900/60 px-3 py-2 text-sm text-slate-100 outline-none focus:border-electric/60"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full rounded-lg border border-white/10 bg-base-900/60 px-3 py-2 text-sm text-slate-100 outline-none focus:border-electric/60"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Primitive: number ----
|
||||
if (typeof value === 'number') {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value === '' ? 0 : Number(e.target.value))}
|
||||
className="w-40 rounded-lg border border-white/10 bg-base-900/60 px-3 py-2 text-sm text-slate-100 outline-none focus:border-electric/60"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Primitive: boolean ----
|
||||
if (typeof value === 'boolean') {
|
||||
return (
|
||||
<label className="inline-flex cursor-pointer items-center gap-2 text-sm text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="h-4 w-4 accent-electric"
|
||||
/>
|
||||
{value ? 'true' : 'false'}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- null ----
|
||||
if (value === null) {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value=""
|
||||
placeholder="(empty)"
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full rounded-lg border border-white/10 bg-base-900/60 px-3 py-2 text-sm text-slate-100 outline-none focus:border-electric/60"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Array ----
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{value.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-xl border border-white/8 bg-white/[0.015] p-3"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="font-mono text-[0.65rem] uppercase tracking-wider text-slate-500">
|
||||
{fieldKey ? humanize(fieldKey).replace(/s$/, '') : 'Item'} #{i + 1}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{i > 0 && (
|
||||
<MiniBtn label="↑" title="Move up" onClick={() => {
|
||||
const cp = [...value];
|
||||
[cp[i - 1], cp[i]] = [cp[i], cp[i - 1]];
|
||||
onChange(cp);
|
||||
}} />
|
||||
)}
|
||||
{i < value.length - 1 && (
|
||||
<MiniBtn label="↓" title="Move down" onClick={() => {
|
||||
const cp = [...value];
|
||||
[cp[i + 1], cp[i]] = [cp[i], cp[i + 1]];
|
||||
onChange(cp);
|
||||
}} />
|
||||
)}
|
||||
<MiniBtn
|
||||
label="✕"
|
||||
title="Remove"
|
||||
danger
|
||||
onClick={() => onChange(value.filter((_, j) => j !== i))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<JsonForm
|
||||
value={item}
|
||||
fieldKey={fieldKey}
|
||||
depth={depth + 1}
|
||||
onChange={(nv) => {
|
||||
const cp = [...value];
|
||||
cp[i] = nv;
|
||||
onChange(cp);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange([...value, emptyLike(value[0])])}
|
||||
className="self-start rounded-lg border border-electric/30 bg-electric/5 px-3 py-1.5 text-xs font-medium text-electric transition-colors hover:bg-electric/10"
|
||||
>
|
||||
+ Add {fieldKey ? humanize(fieldKey).replace(/s$/, '').toLowerCase() : 'item'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Object ----
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
depth === 0
|
||||
? 'flex flex-col gap-5'
|
||||
: 'flex flex-col gap-4 rounded-xl border-s-2 border-white/10 ps-4'
|
||||
}
|
||||
>
|
||||
{Object.entries(value).map(([k, v]) => {
|
||||
const primitive =
|
||||
v === null || ['string', 'number', 'boolean'].includes(typeof v);
|
||||
return (
|
||||
<div
|
||||
key={k}
|
||||
className={primitive ? 'flex flex-col gap-1.5' : 'flex flex-col gap-2'}
|
||||
>
|
||||
<label className="font-mono text-[0.68rem] uppercase tracking-wider text-slate-400">
|
||||
{humanize(k)}
|
||||
</label>
|
||||
<JsonForm
|
||||
value={v}
|
||||
fieldKey={k}
|
||||
depth={depth + 1}
|
||||
onChange={(nv) => onChange({ ...value, [k]: nv })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniBtn({
|
||||
label,
|
||||
title,
|
||||
onClick,
|
||||
danger,
|
||||
}: {
|
||||
label: string;
|
||||
title: string;
|
||||
onClick: () => void;
|
||||
danger?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
className={
|
||||
'inline-flex h-6 w-6 items-center justify-center rounded-md border text-xs transition-colors ' +
|
||||
(danger
|
||||
? 'border-magenta/30 text-magenta hover:bg-magenta/10'
|
||||
: 'border-white/10 text-slate-400 hover:bg-white/[0.06] hover:text-white')
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageInput({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
async function upload(file: File) {
|
||||
setUploading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await fetch('/api/admin/upload', { method: 'POST', body: fd });
|
||||
if (!res.ok) throw new Error(`upload failed (${res.status})`);
|
||||
const json = await res.json();
|
||||
if (json?.url) onChange(json.url as string);
|
||||
else throw new Error('no url returned');
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'upload error');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="relative h-16 w-24 shrink-0 overflow-hidden rounded-lg border border-white/10 bg-base-900/60">
|
||||
{value ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={value} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<span className="flex h-full w-full items-center justify-center text-[0.6rem] text-slate-600">
|
||||
no image
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex grow flex-col gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="/api/uploads/… or /portfolio/…"
|
||||
className="w-full rounded-lg border border-white/10 bg-base-900/60 px-3 py-2 text-sm text-slate-100 outline-none focus:border-electric/60"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-slate-200 transition-colors hover:bg-white/[0.08] disabled:opacity-50"
|
||||
>
|
||||
{uploading ? 'Uploading…' : 'Upload image'}
|
||||
</button>
|
||||
{err && <span className="text-xs text-magenta">{err}</span>}
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) upload(f);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { JsonForm, type JsonValue } from './JsonForm';
|
||||
|
||||
type Status = 'idle' | 'saving' | 'saved' | 'error';
|
||||
|
||||
export function SectionEditor({
|
||||
sectionKey,
|
||||
title,
|
||||
initial,
|
||||
isOverridden,
|
||||
}: {
|
||||
sectionKey: string;
|
||||
title: string;
|
||||
initial: { fa: JsonValue; en: JsonValue };
|
||||
isOverridden: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<{ fa: JsonValue; en: JsonValue }>(initial);
|
||||
const [tab, setTab] = useState<'fa' | 'en'>('fa');
|
||||
const [status, setStatus] = useState<Status>('idle');
|
||||
const [overridden, setOverridden] = useState(isOverridden);
|
||||
|
||||
async function save() {
|
||||
setStatus('saving');
|
||||
try {
|
||||
const res = await fetch('/api/admin/section', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ key: sectionKey, data }),
|
||||
});
|
||||
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 section to its built-in default? Your edits will be removed.')) return;
|
||||
setStatus('saving');
|
||||
try {
|
||||
const res = await fetch(`/api/admin/section?key=${encodeURIComponent(sectionKey)}`, {
|
||||
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 === '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>
|
||||
|
||||
{/* The form for the active locale. FA renders RTL. */}
|
||||
<div dir={tab === 'fa' ? 'rtl' : 'ltr'} className="pb-24">
|
||||
<JsonForm
|
||||
key={tab}
|
||||
value={data[tab]}
|
||||
onChange={(nv) => setData((d) => ({ ...d, [tab]: 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user