feat: add structured admin content and translation editors

This commit is contained in:
2026-09-15 21:48:09 +03:30
parent 131f7efbf3
commit 9319c05710
6 changed files with 321 additions and 0 deletions

52
src/app/admin/admin.css Normal file
View File

@@ -0,0 +1,52 @@
* { box-sizing: border-box; }
.admin-body { margin: 0; background: #f5f6f8; color: #192631; font: 15px/1.6 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
a { color: #245ba4; text-underline-offset: 3px; }
button, input, textarea, select { font: inherit; }
button, .upload-button { cursor: pointer; }
button { border: 1px solid #cdd5de; background: white; color: #243747; border-radius: 6px; padding: 8px 14px; }
button:hover:not(:disabled) { background: #edf3fa; }
button:disabled { opacity: .5; cursor: not-allowed; }
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, summary:focus-visible, .upload-button:focus-within { outline: 3px solid #6eaae9; outline-offset: 3px; }
h1,h2,h3,p { margin-top: 0; } h1 { font-size: clamp(28px, 3vw, 38px); letter-spacing: -.035em; line-height: 1.15; margin-bottom: 12px; } h2 { font-size: 20px; line-height: 1.3; margin-bottom: 0; } h3 { font-size: 17px; }
.muted { color: #61717f; } .small { font-size: 13px; } .eyebrow { color: #60778d; letter-spacing: .13em; font-size: 11px; font-weight: 700; margin-bottom: 12px; }
.admin-shell { display: grid; grid-template-columns: 230px minmax(0,1fr); min-height: 100vh; }
.admin-sidebar { background: #152b3b; color: #d8e2e9; padding: 32px 20px; display: flex; flex-direction: column; position: sticky; top: 0; height: 100vh; }
.admin-brand { display: flex; align-items: center; gap: 12px; color: white; text-decoration: none; line-height: 1.35; padding: 0 10px; margin-bottom: 44px; }
.brand-mark { width: 38px; height: 38px; display: inline-grid; place-items: center; background: #a8d9c9; color: #152b3b; border-radius: 8px; font-weight: 800; font-size: 23px; }
.nav-label { color: #89a0b1; letter-spacing: .12em; font-size: 10px; font-weight: 700; padding-left: 10px; }
.admin-sidebar nav { display: grid; gap: 4px; } .admin-sidebar button { text-align: left; background: transparent; color: #c0cfda; border: 0; padding: 10px 12px; }
.admin-sidebar button[aria-current], .admin-sidebar button:hover:not(:disabled) { background: #294254; color: white; }
.sidebar-bottom { margin-top: auto; padding-top: 30px; display: grid; gap: 8px; } .sidebar-bottom a { color: #c5ded7; padding-left: 12px; text-decoration: none; }
.admin-main { padding: 42px clamp(20px, 4vw, 64px); max-width: 1500px; width: 100%; }
.admin-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 28px; }
.publish-badge { color: #356959; background: #e2eee8; border: 1px solid #c9ddd2; border-radius: 20px; padding: 5px 12px; font-size: 12px; white-space: nowrap; }
.editor-layout { display: grid; grid-template-columns: 190px minmax(0,1fr); align-items: start; gap: 20px; }
.record-list { display: grid; gap: 6px; } .record-list button { text-align: left; padding: 12px; background: transparent; border-color: transparent; overflow-wrap: anywhere; }
.record-list button[aria-current="true"] { background: #e5edf6; border-color: #c3d4e6; }
.record-list strong, .record-list small { display: block; } .record-list strong { font-size: 13px; } .record-list small { font-size: 11px; color: #657888; }
.record-list .add-record { border: 1px dashed #b3c1cf; margin-top: 8px; color: #245ba4; }
.editor-panel { background: white; border: 1px solid #dfe5ea; border-radius: 10px; padding: 28px; min-width: 0; }
.editor-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding-bottom: 22px; border-bottom: 1px solid #e7ebef; margin-bottom: 22px; }
.primary { background: #245ba4; border-color: #245ba4; color: white; font-weight: 600; white-space: nowrap; } .primary:hover:not(:disabled) { background: #194779; }
.secondary { background: #f7f9fb; font-size: 13px; } .danger { color: #a02f32; border-color: #ead1d1; font-size: 13px; }
.field { margin: 0 0 20px; min-width: 0; } .field label, .image-field > label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 7px; }
input:not([type="checkbox"]):not([type="file"]), textarea, select { width: 100%; border: 1px solid #c7d1db; border-radius: 6px; padding: 9px 11px; color: #243747; background: white; min-height: 42px; }
textarea { resize: vertical; } input[aria-invalid="true"], textarea[aria-invalid="true"] { border-color: #b5393e; }
.checkbox-field { display: flex; align-items: center; gap: 12px; } .checkbox-field label { margin: 0; } input[type="checkbox"] { width: 18px; height: 18px; accent-color: #245ba4; }
fieldset { min-width: 0; margin: 0 0 24px; padding: 20px; border: 1px solid #e1e7ed; border-radius: 6px; } legend { padding: 0 7px; font-size: 14px; font-weight: 650; } .object-field > .field:last-child { margin-bottom: 0; }
.array-item { background: #f8fafc; border: 1px solid #e6ebf0; border-radius: 6px; padding: 12px; margin-bottom: 14px; } .array-item fieldset { background: white; margin-bottom: 0; }
.array-actions { display: flex; align-items: center; gap: 6px; margin-bottom: 12px; } .array-actions span { margin-right: auto; color: #7c8c99; font-size: 12px; } .array-actions button { padding: 3px 10px; font-size: 12px; }
.count { color: #7c8c99; padding-left: 6px; } .image-field img { width: 160px; height: 100px; object-fit: contain; border-radius: 4px; background: #f0f3f6; display: block; margin-bottom: 14px; }
.upload-button { display: inline-block !important; background: #edf3f9; border: 1px solid #c3d4e6; border-radius: 6px; color: #245ba4; padding: 8px 13px; margin-top: 8px; font-size: 13px; font-weight: 600; }
.upload-button input { position: absolute; width: 1px; height: 1px; overflow: hidden; opacity: 0; }
.language-toolbar { background: #f5f8fb; border-radius: 6px; padding: 16px; margin-bottom: 24px; } .language-toolbar label { display: block; font-size: 12px; font-weight: 700; margin-bottom: 6px; } .language-toolbar select { max-width: 270px; } .language-toolbar p { margin: 10px 0 0; } .language-toolbar button { margin: 8px 0 0 8px; }
.entry-meta { display: grid; grid-template-columns: minmax(0,1fr) 90px 90px; gap: 16px; align-items: end; }
.editor-footer { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 12px; padding-top: 20px; border-top: 1px solid #e7ebef; } .editor-footer span { margin-right: auto; }
.field-error { color: #a02f32; font-size: 13px; margin: 5px 0 0; } .error-summary { padding: 14px; background: #fff0ef; border: 1px solid #ebcccc; border-radius: 6px; margin-bottom: 20px; color: #902e32; }
.notice { background: #e3f1e9; border: 1px solid #c6dfd0; color: #285b43; padding: 12px 18px; border-radius: 6px; }
.message-group { border-bottom: 1px solid #e2e7ed; padding: 12px 0; } summary { cursor: pointer; font-weight: 600; } .message-group[open] summary { margin-bottom: 20px; }
.media-list { padding: 0; list-style: none; } .media-list li { display: flex; gap: 16px; align-items: center; justify-content: space-between; border-top: 1px solid #e5eaf0; padding: 16px 0; } .media-list small { display: block; color: #61717f; } .media-list a { overflow-wrap: anywhere; }
.empty-state { padding: 50px 20px; text-align: center; color: #61717f; }
.login-page { min-height: 100vh; display: grid; place-items: center; padding: 24px; background: #ecf0f3; } .login-card { width: min(440px,100%); padding: 40px; background: white; border: 1px solid #dae2e9; border-radius: 12px; } .login-card .brand-mark { margin-bottom: 30px; } .login-card h1 { font-size: 38px; } .login-card .primary { width: 100%; margin: 4px 0 20px; }
@media(max-width:1100px) { .admin-shell { grid-template-columns: 190px minmax(0,1fr); } .admin-sidebar { padding-inline: 12px; } .editor-layout { grid-template-columns: 1fr; } .record-list { display: flex; overflow-x: auto; margin-bottom: 6px; } .record-list button { min-width: 140px; } }
@media(max-width:700px) { .admin-shell { display: block; } .admin-sidebar { position: static; height: auto; padding: 16px; } .admin-brand { margin: 0 0 16px; } .nav-label { display: none; } .admin-sidebar nav { display: flex; overflow-x: auto; } .admin-sidebar nav button { white-space: nowrap; } .sidebar-bottom { padding: 12px 0 0; display: flex; justify-content: space-between; } .admin-main { padding: 28px 16px; } .admin-heading { align-items: start; } .publish-badge { font-size: 10px; } .editor-panel { padding: 16px; } fieldset { padding: 12px; } .entry-meta { grid-template-columns: 1fr; gap: 0; } .language-toolbar button { margin-left: 0; } .editor-toolbar h2 { font-size: 17px; } .login-card { padding: 28px; } }

6
src/app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,6 @@
import type { Metadata } from 'next';
import './admin.css';
export const metadata: Metadata = { title: 'Portfolio Builder — Admin', robots: { index: false, follow: false } };
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return <html lang="en" dir="ltr"><body className="admin-body">{children}</body></html>;
}

View File

@@ -0,0 +1,23 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
export default function LoginPage() {
const router = useRouter();
const [error, setError] = useState(''); const [busy, setBusy] = useState(false);
return <main className="login-page"><form className="login-card" onSubmit={async event => {
event.preventDefault(); setBusy(true); setError('');
const form = new FormData(event.currentTarget);
try {
const response = await fetch('/api/auth/sign-in/email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: form.get('email'), password: form.get('password') }) });
if (!response.ok) { setError(response.status === 429 ? 'Too many attempts. Wait a minute and try again.' : 'Unable to sign in. Check your email and password.'); return; }
router.replace('/admin'); router.refresh();
} catch { setError('Connection failed. Try again.'); } finally { setBusy(false); }
}}><span className="brand-mark">P</span><p className="eyebrow">PORTFOLIO BUILDER</p><h1>Your work.<br />Your space.</h1><p className="muted">Sign in to manage your portfolio.</p>
<div className="field"><label htmlFor="email">Email</label><input id="email" name="email" type="email" autoComplete="username" required /></div>
<div className="field"><label htmlFor="password">Password</label><input id="password" name="password" type="password" autoComplete="current-password" required /></div>
{error && <p role="alert" className="field-error">{error}</p>}<button className="primary" disabled={busy}>{busy ? 'Signing in…' : 'Sign in'}</button>
<p className="muted small">First installation or forgotten password? Use the owner commands documented in the deployment guide.</p>
<Link href="/">← View portfolio</Link>
</form></main>;
}

15
src/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { getAuth } from '@/server/auth';
import { getDatabase } from '@/server/db';
import { readContent } from '@/server/content';
import { media } from '@/server/schema';
import { AdminDashboard } from '@/components/admin/AdminDashboard';
export const dynamic = 'force-dynamic';
export default async function AdminPage() {
const requestHeaders = await headers();
const owner = await getAuth().api.getSession({ headers: requestHeaders });
if (!owner) redirect('/admin/login');
const connection = getDatabase();
return <AdminDashboard initial={readContent(connection)} initialUploads={connection.db.select().from(media).all().map(item => ({ ...item, url: `/media/${item.key}` }))} />;
}

View File

@@ -0,0 +1,142 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { z } from 'zod';
import type { ContentSnapshot } from '@/server/content';
import { entryKinds, entrySchemas, sectionSchemas, settingsSchema, languageSchema, type EntryKind, type SectionKey } from '@/content/contracts';
import { entrySamples } from '@/content/samples';
import { dictionaries, flattenMessages, messageDefaults } from '@/content/messages';
import { Field, humanize, uploadFile, type FormSchema, type Issue, type Json, type Upload } from './Fields';
type Tab = 'settings' | 'sections' | EntryKind | 'languages' | 'media';
const asJson = (value: unknown) => value as Json;
const schemaFor = (schema: z.ZodType) => z.toJSONSchema(schema, { unrepresentable: 'any' }) as FormSchema;
const labels: Record<Tab, string> = { settings: 'Site settings', sections: 'Profile & sections', projects: 'Projects', experience: 'Experience', skills: 'Skills & tools', education: 'Education', milestones: 'Milestones', languages: 'Languages', media: 'Media library' };
const resources = ['settings', 'languages', 'sections', 'entries'] as const;
export function AdminDashboard({ initial, initialUploads }: { initial: ContentSnapshot; initialUploads: Upload[] }) {
const router = useRouter();
const [snapshot, setSnapshot] = useState(initial); const [uploads, setUploads] = useState(initialUploads);
const [tab, setTab] = useState<Tab>('settings'); const [selected, setSelected] = useState('');
const [dirty, setDirty] = useState(false); const [version, setVersion] = useState(0); const [notice, setNotice] = useState('');
const onUpload = (item: Upload) => setUploads(previous => [...previous, item]);
const navigate = (nextTab: Tab, id = '') => {
if (dirty && !confirm('Discard your unsaved changes?')) return;
setTab(nextTab); setSelected(id); setDirty(false); setNotice(''); setVersion(value => value + 1);
};
const refresh = async () => {
const result = await Promise.all(resources.map(async resource => {
const response = await fetch(`/api/admin/${resource}`, { cache: 'no-store' });
if (!response.ok) throw new Error('Could not reload your content. Refresh the page.');
return [resource, await response.json()];
}));
setSnapshot(Object.fromEntries(result) as ContentSnapshot); setDirty(false); setVersion(value => value + 1);
};
const entries = snapshot.entries.filter(entry => entry.kind === tab);
const choices = tab === 'sections' ? snapshot.sections.map(item => ({ id: item.key, title: humanize(item.key), visible: item.visible }))
: tab === 'languages' ? snapshot.languages.map(item => ({ id: item.code, title: item.name, visible: item.enabled }))
: entries.map(item => ({ id: item.id, title: String(item.shared.title || item.shared.label || item.shared.role || item.shared.degree || item.id), visible: item.visible }));
const activeId = selected || choices[0]?.id || 'new';
return <div className="admin-shell">
<aside className="admin-sidebar"><a className="admin-brand" href="/" target="_blank" rel="noreferrer"><span className="brand-mark">P</span><span>Portfolio<br /><strong>Builder</strong></span></a><p className="nav-label">YOUR WEBSITE</p>
<nav aria-label="Admin navigation">{Object.entries(labels).map(([key, label]) => <button key={key} type="button" aria-current={tab === key ? 'page' : undefined} onClick={() => navigate(key as Tab)}>{label}</button>)}</nav>
<div className="sidebar-bottom"><a href="/" target="_blank" rel="noreferrer">View portfolio ↗</a><button type="button" onClick={async () => {
if (dirty && !confirm('Discard changes and sign out?')) return;
try { const response = await fetch('/api/auth/sign-out', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); if (!response.ok) throw new Error(); router.replace('/admin/login'); router.refresh(); }
catch { setNotice('Sign out failed. Try again.'); }
}}>Sign out</button></div>
</aside>
<main className="admin-main"><header className="admin-heading"><div><p className="eyebrow">MAKE IT YOURS</p><h1>{labels[tab]}</h1><p className="muted">{tab === 'media' ? 'Images and documents for your portfolio.' : 'Changes appear on your website when you save.'}</p></div><span className="publish-badge">{dirty ? 'Unsaved changes' : 'Live website'}</span></header>
{notice && !dirty && <p role="status" className="notice">{notice}</p>}
{tab === 'media' ? <MediaLibrary uploads={uploads} onUpload={onUpload} onDelete={key => setUploads(previous => previous.filter(item => item.key !== key))} /> : <div className={choices.length || tab !== 'settings' ? 'editor-layout' : ''}>
{tab !== 'settings' && <aside className="record-list" aria-label="Content items">{choices.map(item => <button type="button" key={item.id} aria-current={activeId === item.id ? 'true' : undefined} onClick={() => navigate(tab, item.id)}><strong>{item.title}</strong><small>{item.visible ? item.id : 'Hidden'}</small></button>)}
{tab !== 'sections' && <button type="button" className="add-record" onClick={() => navigate(tab, 'new')}>+ Add {tab === 'languages' ? 'language' : 'item'}</button>}
</aside>}
<ContentEditor key={`${tab}-${activeId}-${version}`} tab={tab} selected={activeId} snapshot={snapshot} uploads={uploads} onUpload={onUpload} onDirty={setDirty} onSaved={async id => { await refresh(); setSelected(id); setNotice('Saved. Your changes are live.'); }} onDeleted={async () => { await refresh(); setSelected(''); setNotice('Deleted. Your website has been updated.'); }} />
</div>}
</main>
</div>;
}
type EditorProps = { tab: Exclude<Tab, 'media'>; selected: string; snapshot: ContentSnapshot; uploads: Upload[]; onUpload: (upload: Upload) => void; onDirty: (dirty: boolean) => void; onSaved: (id: string) => Promise<void>; onDeleted: () => Promise<void> };
function ContentEditor({ tab, selected, snapshot, uploads, onUpload, onDirty, onSaved, onDeleted }: EditorProps) {
const isEntry = entryKinds.includes(tab as EntryKind);
const resource = isEntry ? 'entries' : tab;
const original = tab === 'settings' ? snapshot.settings
: tab === 'sections' ? snapshot.sections.find(item => item.key === selected)!
: tab === 'languages' ? snapshot.languages.find(item => item.code === selected) ?? { code: '', name: '', direction: 'ltr', enabled: true, messages: {} }
: snapshot.entries.find(item => item.id === selected) ?? { id: '', kind: tab, position: snapshot.entries.filter(item => item.kind === tab).length, visible: true, shared: entrySamples[tab as EntryKind], translations: {} };
const [draft, setDraft] = useState(() => structuredClone(original) as unknown as Record<string, Json>);
const [locale, setLocale] = useState(snapshot.settings.defaultLanguage);
const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const [issues, setIssues] = useState<Issue[]>([]);
const dirty = JSON.stringify(draft) !== JSON.stringify(original);
useEffect(() => { onDirty(dirty); }, [dirty, onDirty]);
useEffect(() => {
const warn = (event: BeforeUnloadEvent) => { if (dirty) event.preventDefault(); };
window.addEventListener('beforeunload', warn); return () => window.removeEventListener('beforeunload', warn);
}, [dirty]);
const fieldProps = { issues, uploads, onUpload, disabled: busy };
const translations = (draft.translations ?? {}) as Record<string, Record<string, Json>>;
const update = (key: string, value: Json) => setDraft(previous => ({ ...previous, [key]: value }));
const contentSchema = isEntry ? entrySchemas[tab as EntryKind] : tab === 'sections' ? sectionSchemas[selected as SectionKey] : undefined;
const usesShared = isEntry && locale === snapshot.settings.defaultLanguage && !translations[locale];
const contentValue = isEntry ? { ...(draft.shared as object), ...(translations[snapshot.settings.defaultLanguage] ?? {}), ...(translations[locale] ?? {}) }
: tab === 'sections' ? translations[locale] ?? translations[snapshot.settings.defaultLanguage] : {};
const translationPath = usesShared ? 'shared' : `translations.${locale}`;
return <form className="editor-panel" onSubmit={async event => {
event.preventDefault(); setBusy(true); setError(''); setIssues([]);
try {
const response = await fetch(`/api/admin/${resource}${selected === 'new' && isEntry ? '?create=1' : ''}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(draft) });
const result = await response.json();
if (!response.ok) { setError(result.error || 'Save failed'); setIssues(result.issues || []); return; }
await onSaved(String(draft.id || draft.code || draft.key || ''));
} catch (error) { setError((error as Error).message || 'Connection failed. Try again.'); } finally { setBusy(false); }
}}>
<div className="editor-toolbar"><h2>{selected === 'new' ? 'New item' : tab === 'settings' ? 'Site identity' : humanize(selected)}</h2><button className="primary" disabled={busy}>{busy ? 'Saving…' : 'Save changes'}</button></div>
{error && <div role="alert" className="error-summary"><strong>{error}</strong>{issues.length > 0 && <ul>{issues.map(issue => <li key={issue.path}>{humanize(issue.path)}: {issue.message}</li>)}</ul>}</div>}
{tab !== 'languages' && <div className="language-toolbar"><label htmlFor="content-language">Content language</label><select id="content-language" value={locale} onChange={event => setLocale(event.target.value)}>{snapshot.languages.map(item => <option key={item.code} value={item.code}>{item.name}{item.code === snapshot.settings.defaultLanguage ? ' (default)' : ''}</option>)}</select>
{contentSchema && locale !== snapshot.settings.defaultLanguage && !translations[locale] && <p className="muted small">No translation yet. Showing default-language content.</p>}
{contentSchema && locale !== snapshot.settings.defaultLanguage && !translations[locale] && <button type="button" className="secondary" onClick={() => update('translations', { ...translations, [locale]: asJson(contentValue) })}>Create translation from default</button>}
</div>}
{isEntry && <div className="entry-meta"><Field {...fieldProps} label="ID (used in the URL)" path="id" schema={{ type: 'string' }} value={draft.id} disabled={busy || selected !== 'new'} onChange={value => update('id', value)} /><Field {...fieldProps} label="Position" path="position" schema={{ type: 'integer', minimum: 0 }} value={draft.position} onChange={value => update('position', value)} /><Field {...fieldProps} label="Visible" path="visible" schema={{ type: 'boolean' }} value={draft.visible} onChange={value => update('visible', value)} /></div>}
{tab === 'sections' && <Field {...fieldProps} label="Visible" path="visible" schema={{ type: 'boolean' }} value={draft.visible} onChange={value => update('visible', value)} />}
{contentSchema && <Field {...fieldProps} label="Content" path={translationPath} schema={schemaFor(contentSchema)} value={asJson(contentValue)} onChange={value => usesShared ? update('shared', value) : update('translations', { ...translations, [locale]: value })} />}
{tab === 'settings' && <>
<Field {...fieldProps} label="General settings" schema={{ ...schemaFor(settingsSchema.omit({ metadata: true })), properties: { ...schemaFor(settingsSchema.omit({ metadata: true })).properties, defaultLanguage: { type: 'string', enum: snapshot.languages.filter(item => item.enabled).map(item => item.code) } } }} value={draft} onChange={value => setDraft(previous => ({ ...previous, ...(value as object) }))} />
<Field {...fieldProps} label="Search engine metadata" path={`metadata.${locale}`} schema={schemaFor(settingsSchema.shape.metadata.valueType)} value={asJson((draft.metadata as Record<string, Json>)[locale] ?? (draft.metadata as Record<string, Json>)[snapshot.settings.defaultLanguage])} onChange={value => update('metadata', { ...(draft.metadata as object), [locale]: value })} />
</>}
{tab === 'languages' && <>
<Field {...fieldProps} label="Language settings" schema={schemaFor(languageSchema.omit({ messages: true, code: true }))} value={draft} onChange={value => setDraft(previous => ({ ...previous, ...(value as object) }))} />
<Field {...fieldProps} label="Language code (en, fa, de, ar, pt-br…)" path="code" schema={{ type: 'string' }} value={draft.code} disabled={busy || selected !== 'new'} onChange={value => update('code', value)} />
<h3>Public interface labels</h3><p className="muted">Leave a label empty to use its built-in translation or English. Keep placeholders such as {'{title}'} unchanged.</p>
{Object.keys(dictionaries.en).map(group => <details key={group} className="message-group"><summary>{humanize(group)}</summary>{Object.entries(messageDefaults).filter(([key]) => key.startsWith(`${group}.`)).map(([key, fallback]) => {
const builtIn = flattenMessages(dictionaries[String(draft.code)] ?? dictionaries.en)[key] ?? fallback;
return <div className="field" key={key}><label htmlFor={`message-${key}`}>{humanize(key.split('.').slice(1).join(' — '))}</label><textarea id={`message-${key}`} rows={2} dir="auto" placeholder={builtIn} value={String((draft.messages as Record<string, Json>)[key] ?? '')} onChange={event => update('messages', { ...(draft.messages as object), [key]: event.target.value })} /></div>;
})}</details>)}
</>}
<div className="editor-footer"><span className="muted small">{dirty ? 'You have unsaved changes.' : 'All changes saved.'}</span>{isEntry && selected !== 'new' && <button type="button" className="danger" disabled={busy} onClick={async () => {
if (!confirm('Delete this item from your portfolio? This cannot be undone.')) return;
setBusy(true); setError('');
try { const response = await fetch(`/api/admin/entries?id=${encodeURIComponent(selected)}`, { method: 'DELETE' }); const result = await response.json(); if (!response.ok) throw new Error(result.error); await onDeleted(); }
catch (error) { setError((error as Error).message); } finally { setBusy(false); }
}}>Delete item</button>}<button className="primary" disabled={busy}>{busy ? 'Saving…' : 'Save changes'}</button></div>
</form>;
}
function MediaLibrary({ uploads, onUpload, onDelete }: { uploads: Upload[]; onUpload: (upload: Upload) => void; onDelete: (key: string) => void }) {
const [error, setError] = useState(''); const [busy, setBusy] = useState(false);
return <section className="editor-panel"><div className="editor-toolbar"><h2>{uploads.length} files</h2><label className="upload-button">{busy ? 'Uploading…' : '+ Upload file'}<input type="file" accept="image/jpeg,image/png,image/webp,application/pdf" disabled={busy} onChange={async event => {
const file = event.target.files?.[0]; if (!file) return; setBusy(true); setError('');
try { onUpload(await uploadFile(file)); } catch (error) { setError((error as Error).message); } finally { setBusy(false); event.target.value = ''; }
}} /></label></div><p className="muted">JPEG, PNG, WebP, or PDF. Up to 10 MB per file. Images are optimized on upload.</p>
{error && <p role="alert" className="field-error">{error}</p>}
{uploads.length === 0 && <div className="empty-state"><h3>Your media library is ready.</h3><p>Upload an avatar, project image, or certificate to get started.</p></div>}
<ul className="media-list">{uploads.map(item => <li key={item.key}><div><a href={item.url} target="_blank" rel="noreferrer">{item.originalName}</a><small>{item.mime} · {Math.ceil(item.size / 1024)} KB</small></div><button type="button" className="danger" disabled={busy} onClick={async () => {
if (!confirm('Permanently delete this upload?')) return;
setBusy(true); setError('');
try { const response = await fetch(`/api/admin/media?key=${item.key}`, { method: 'DELETE' }); const result = await response.json(); if (!response.ok) throw new Error(result.error); onDelete(item.key); }
catch (error) { setError((error as Error).message); } finally { setBusy(false); }
}}>Delete</button></li>)}</ul>
</section>;
}

View File

@@ -0,0 +1,83 @@
'use client';
import { useId, useState } from 'react';
import Image from 'next/image';
export type Json = string | number | boolean | null | Json[] | { [key: string]: Json };
export type FormSchema = { type?: string; properties?: Record<string, FormSchema>; items?: FormSchema; enum?: string[]; anyOf?: FormSchema[]; minLength?: number; maxLength?: number; minimum?: number; maximum?: number; maxItems?: number; required?: string[] };
export type Upload = { key: string; url: string; originalName: string; mime: string; width: number | null; height: number | null; size: number };
export type Issue = { path: string; message: string };
export const humanize = (text: string) => text.replace(/([a-z])([A-Z])/g, '$1 $2').replaceAll('_', ' ').replace(/^./, char => char.toUpperCase());
export function emptyValue(schema: FormSchema): Json {
if (schema.anyOf) return emptyValue(schema.anyOf[0]);
if (schema.enum) return schema.enum[0];
if (schema.type === 'object') return Object.fromEntries(Object.entries(schema.properties ?? {}).map(([key, value]) => [key, emptyValue(value)]));
if (schema.type === 'array') return [];
if (schema.type === 'boolean') return false;
if (schema.type === 'number' || schema.type === 'integer') return schema.minimum ?? 0;
return '';
}
export async function uploadFile(file: File) {
const form = new FormData(); form.append('file', file);
const response = await fetch('/api/admin/media', { method: 'POST', body: form });
const result = await response.json();
if (!response.ok) throw new Error(result.error || 'Upload failed');
return result as Upload;
}
type Props = { label: string; path?: string; schema: FormSchema; value: Json; onChange: (value: Json) => void; issues?: Issue[]; uploads: Upload[]; onUpload: (upload: Upload) => void; disabled?: boolean };
export function Field({ label, path = '', schema: inputSchema, value, onChange, issues = [], uploads, onUpload, disabled }: Props) {
const id = useId();
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState('');
const issue = issues.find(issue => issue.path === path);
const common = { id, disabled, 'aria-invalid': Boolean(issue), 'aria-describedby': issue ? `${id}-error` : undefined };
const schema = inputSchema.anyOf?.[0] ?? inputSchema;
const nested = { issues, uploads, onUpload, disabled };
const record = (value && typeof value === 'object' && !Array.isArray(value) ? value : {}) as Record<string, Json>;
if (schema.type === 'object' && schema.properties?.src && schema.properties?.width) {
return <fieldset className="image-field"><legend>{label}</legend>
{typeof record.src === 'string' && record.src && <Image src={record.src} width={160} height={100} alt={`${label} preview`} unoptimized />}
<label htmlFor={id}>Choose image</label>
<select {...common} value={String(record.src || '')} onChange={event => {
const media = uploads.find(item => item.url === event.target.value);
if (media) onChange({ src: media.url, width: media.width!, height: media.height! });
}}>
<option value={String(record.src || '')}>Current image</option>
{uploads.filter(item => item.mime.startsWith('image/')).map(item => <option key={item.key} value={item.url}>{item.originalName}</option>)}
</select>
<label className="upload-button">{uploading ? 'Uploading…' : 'Upload image'}<input type="file" accept="image/jpeg,image/png,image/webp" disabled={disabled || uploading} onChange={async event => {
const file = event.target.files?.[0]; if (!file) return;
setUploading(true); setUploadError('');
try { const uploaded = await uploadFile(file); onUpload(uploaded); onChange({ src: uploaded.url, width: uploaded.width!, height: uploaded.height! }); }
catch (error) { setUploadError((error as Error).message); } finally { setUploading(false); }
}} /></label>
{(issue || uploadError) && <p role="alert" className="field-error">{issue?.message || uploadError}</p>}
{issues.filter(item => item.path.startsWith(`${path}.`)).map(item => <p key={item.path} role="alert" className="field-error">{item.message}</p>)}
</fieldset>;
}
if (schema.type === 'object') return <fieldset className="object-field"><legend>{label}</legend>
{Object.entries(schema.properties ?? {}).map(([key, property]) => <Field key={key} {...nested} label={humanize(key)} path={path ? `${path}.${key}` : key} schema={property} value={record[key] ?? emptyValue(property)} onChange={next => onChange({ ...record, [key]: next })} />)}
</fieldset>;
if (schema.type === 'array') {
const items = Array.isArray(value) ? value : [];
const move = (index: number, delta: number) => { const next = [...items]; [next[index], next[index + delta]] = [next[index + delta], next[index]]; onChange(next); };
return <fieldset className="array-field"><legend>{label} <span className="count">{items.length}</span></legend>
{items.map((item, index) => <div className="array-item" key={index}>
<div className="array-actions"><span>{index + 1}</span><button type="button" disabled={disabled || index === 0} onClick={() => move(index, -1)} aria-label={`Move ${label} item ${index + 1} up`}>↑</button><button type="button" disabled={disabled || index === items.length - 1} onClick={() => move(index, 1)} aria-label={`Move ${label} item ${index + 1} down`}>↓</button><button type="button" disabled={disabled} onClick={() => { if (confirm('Remove this item?')) onChange(items.filter((_, i) => i !== index)); }}>Remove</button></div>
<Field {...nested} label={`${label} item ${index + 1}`} path={`${path}.${index}`} schema={schema.items!} value={item} onChange={next => onChange(items.map((previous, i) => i === index ? next : previous))} />
</div>)}
<button type="button" className="secondary" disabled={disabled || items.length >= (schema.maxItems ?? 200)} onClick={() => onChange([...items, emptyValue(schema.items!)])}>+ Add {label.toLowerCase()} item</button>
{issue && <p id={`${id}-error`} className="field-error">{issue.message}</p>}
</fieldset>;
}
const long = /description|summary|introduction|challenge|subtitle|ownership|outcomes|lessons|constraints|bullets/i.test(label) || String(value).length > 160;
return <div className={`field ${schema.type === 'boolean' ? 'checkbox-field' : ''}`}>
<label htmlFor={id}>{label}</label>
{schema.enum ? <select {...common} value={String(value)} onChange={event => onChange(event.target.value)}>{schema.enum.map(option => <option key={option} value={option}>{humanize(option)}</option>)}</select>
: schema.type === 'boolean' ? <input {...common} type="checkbox" checked={Boolean(value)} onChange={event => onChange(event.target.checked)} />
: schema.type === 'number' || schema.type === 'integer' ? <input {...common} type="number" min={schema.minimum} max={schema.maximum} value={Number(value)} onChange={event => onChange(Number(event.target.value))} />
: long ? <textarea {...common} dir="auto" rows={4} maxLength={schema.maxLength} value={String(value ?? '')} onChange={event => onChange(event.target.value)} />
: <input {...common} dir="auto" type="text" maxLength={schema.maxLength} value={String(value ?? '')} onChange={event => onChange(event.target.value)} />}
{issue && <p id={`${id}-error`} className="field-error">{issue.message}</p>}
{schema.type === 'string' && /url|link/i.test(label) && uploads.some(item => item.mime === 'application/pdf') && <select aria-label={`Choose attachment for ${label}`} value="" disabled={disabled} onChange={event => onChange(event.target.value)}><option value="">Choose an uploaded attachment…</option>{uploads.filter(item => item.mime === 'application/pdf').map(item => <option key={item.key} value={item.url}>{item.originalName}</option>)}</select>}
</div>;
}