From 9319c05710b44648e3a8574fff3299876fdaf7fd Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Tue, 15 Sep 2026 21:48:09 +0330 Subject: [PATCH] feat: add structured admin content and translation editors --- src/app/admin/admin.css | 52 +++++++++ src/app/admin/layout.tsx | 6 + src/app/admin/login/page.tsx | 23 ++++ src/app/admin/page.tsx | 15 +++ src/components/admin/AdminDashboard.tsx | 142 ++++++++++++++++++++++++ src/components/admin/Fields.tsx | 83 ++++++++++++++ 6 files changed, 321 insertions(+) create mode 100644 src/app/admin/admin.css create mode 100644 src/app/admin/layout.tsx create mode 100644 src/app/admin/login/page.tsx create mode 100644 src/app/admin/page.tsx create mode 100644 src/components/admin/AdminDashboard.tsx create mode 100644 src/components/admin/Fields.tsx diff --git a/src/app/admin/admin.css b/src/app/admin/admin.css new file mode 100644 index 0000000..0c870cd --- /dev/null +++ b/src/app/admin/admin.css @@ -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; } } diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 0000000..7aee679 --- /dev/null +++ b/src/app/admin/layout.tsx @@ -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 {children}; +} diff --git a/src/app/admin/login/page.tsx b/src/app/admin/login/page.tsx new file mode 100644 index 0000000..ae4d546 --- /dev/null +++ b/src/app/admin/login/page.tsx @@ -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
{ + 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); } + }}>P

PORTFOLIO BUILDER

Your work.
Your space.

Sign in to manage your portfolio.

+
+
+ {error &&

{error}

} +

First installation or forgotten password? Use the owner commands documented in the deployment guide.

+ ← View portfolio +
; +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..c63e27f --- /dev/null +++ b/src/app/admin/page.tsx @@ -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 ({ ...item, url: `/media/${item.key}` }))} />; +} diff --git a/src/components/admin/AdminDashboard.tsx b/src/components/admin/AdminDashboard.tsx new file mode 100644 index 0000000..36237a6 --- /dev/null +++ b/src/components/admin/AdminDashboard.tsx @@ -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 = { 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('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
+ +

MAKE IT YOURS

{labels[tab]}

{tab === 'media' ? 'Images and documents for your portfolio.' : 'Changes appear on your website when you save.'}

{dirty ? 'Unsaved changes' : 'Live website'}
+ {notice && !dirty &&

{notice}

} + {tab === 'media' ? setUploads(previous => previous.filter(item => item.key !== key))} /> :
+ {tab !== 'settings' && } + { await refresh(); setSelected(id); setNotice('Saved. Your changes are live.'); }} onDeleted={async () => { await refresh(); setSelected(''); setNotice('Deleted. Your website has been updated.'); }} /> +
} +
+
; +} + +type EditorProps = { tab: Exclude; selected: string; snapshot: ContentSnapshot; uploads: Upload[]; onUpload: (upload: Upload) => void; onDirty: (dirty: boolean) => void; onSaved: (id: string) => Promise; onDeleted: () => Promise }; +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); + const [locale, setLocale] = useState(snapshot.settings.defaultLanguage); + const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const [issues, setIssues] = useState([]); + 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>; + 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
{ + 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); } + }}> +

{selected === 'new' ? 'New item' : tab === 'settings' ? 'Site identity' : humanize(selected)}

+ {error &&
{error}{issues.length > 0 &&
    {issues.map(issue =>
  • {humanize(issue.path)}: {issue.message}
  • )}
}
} + {tab !== 'languages' &&
+ {contentSchema && locale !== snapshot.settings.defaultLanguage && !translations[locale] &&

No translation yet. Showing default-language content.

} + {contentSchema && locale !== snapshot.settings.defaultLanguage && !translations[locale] && } +
} + {isEntry &&
update('id', value)} /> update('position', value)} /> update('visible', value)} />
} + {tab === 'sections' && update('visible', value)} />} + {contentSchema && usesShared ? update('shared', value) : update('translations', { ...translations, [locale]: value })} />} + {tab === 'settings' && <> + item.enabled).map(item => item.code) } } }} value={draft} onChange={value => setDraft(previous => ({ ...previous, ...(value as object) }))} /> + )[locale] ?? (draft.metadata as Record)[snapshot.settings.defaultLanguage])} onChange={value => update('metadata', { ...(draft.metadata as object), [locale]: value })} /> + } + {tab === 'languages' && <> + setDraft(previous => ({ ...previous, ...(value as object) }))} /> + update('code', value)} /> +

Public interface labels

Leave a label empty to use its built-in translation or English. Keep placeholders such as {'{title}'} unchanged.

+ {Object.keys(dictionaries.en).map(group =>
{humanize(group)}{Object.entries(messageDefaults).filter(([key]) => key.startsWith(`${group}.`)).map(([key, fallback]) => { + const builtIn = flattenMessages(dictionaries[String(draft.code)] ?? dictionaries.en)[key] ?? fallback; + return