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 ;
+}
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
+
+
+ {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 ;
+}
+
+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 {uploads.length} files
JPEG, PNG, WebP, or PDF. Up to 10 MB per file. Images are optimized on upload.
+ {error && {error}
}
+ {uploads.length === 0 && Your media library is ready.
Upload an avatar, project image, or certificate to get started.
}
+ {uploads.map(item => )}
+ ;
+}
diff --git a/src/components/admin/Fields.tsx b/src/components/admin/Fields.tsx
new file mode 100644
index 0000000..98de945
--- /dev/null
+++ b/src/components/admin/Fields.tsx
@@ -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; 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;
+ if (schema.type === 'object' && schema.properties?.src && schema.properties?.width) {
+ return ;
+ }
+ if (schema.type === 'object') return ;
+ 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 ;
+ }
+ const long = /description|summary|introduction|challenge|subtitle|ownership|outcomes|lessons|constraints|bullets/i.test(label) || String(value).length > 160;
+ return ;
+}