'use client'; import { useState, useEffect, useCallback } from 'react'; import { useParams, useRouter } from 'next/navigation'; import Link from 'next/link'; import * as Icons from './Icons'; import SaveBar from './SaveBar'; import { useUnsavedGuard } from './useUnsavedGuard'; import { reportIfSessionExpired } from './sessionExpiry'; import ToggleCard from './fields/ToggleCard'; import OptionGrid, { toOptions } from './fields/OptionGrid'; // Direct import from the theme module, not from '@/lib/config': the config // index pulls in `fs` and cannot be bundled into a client component. import { DEFAULT_PRESET } from '@/lib/config/theme'; import { PHOTO_GRID_COLUMNS_MAX, PHOTO_GRID_COLUMNS_MIN, PHOTO_GRID_GAP_MAX, resolveExifDisplay, resolveWatermarkOpacity, } from '@/lib/config/schema'; import { SUPPORTED_LOCALES } from '@/lib/i18n'; interface Settings { title?: string; subtitle?: string; /** Absolute site URL for sitemap, feed and structured data (#472). */ url?: string; lang?: string; sitePassword?: string; mode?: 'light' | 'dark' | 'auto'; exifOnHover?: boolean; exif?: { camera?: boolean; settings?: boolean; location?: boolean; caption?: boolean; }; map?: boolean; transitions?: boolean; scrollToTop?: boolean; analytics?: boolean; theme?: { preset?: string; accent?: string; photoFrame?: string; grain?: boolean; headerDot?: boolean; heroStyle?: string; }; grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string; }; footer?: { name?: string; instagram?: string; email?: string; website?: string; }; /** EXPERIMENTAL: external links appended to the header navigation */ navLinks?: Array<{ label?: string; url?: string }>; legal?: { enabled?: boolean; name?: string; address?: string; zipCity?: string; country?: string; email?: string; phone?: string; taxId?: string; vatId?: string; extraInfo?: string; }; seo?: { title?: string; description?: string; titleTemplate?: string; noIndex?: boolean; noFollow?: boolean; }; protection?: { disableRightClick?: boolean; disableImageDrag?: boolean; }; proofing?: { enabled?: boolean; allowMailto?: boolean; }; watermark?: { enabled?: boolean; text?: string; opacity?: number; position?: 'bottom-right' | 'bottom-left' | 'center'; }; about?: { enabled?: boolean }; } const PRESETS = ['studio-modern', 'studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph']; const LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow', 'justified']; const PHOTO_FRAMES = ['none', 'passepartout', 'shadow']; const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic', 'cover']; const ASPECT_RATIOS = ['1', '3/2', '2/3', '16/9', 'auto']; const PHOTO_FRAME_INFO: Record = { none: { label: 'None', desc: 'Flush image with crisp edges' }, passepartout: { label: 'Passepartout', desc: 'Classic gallery matting border', }, shadow: { label: 'Shadow', desc: 'Soft floating drop shadow' }, }; const PHOTO_FRAME_OPTIONS = toOptions(PHOTO_FRAMES, PHOTO_FRAME_INFO); function PhotoFramePreview({ value }: { value: string }) { return (
); } const HERO_STYLE_INFO: Record = { split: { label: 'Split', desc: 'Side-by-side title & photo' }, fullbleed: { label: 'Fullbleed', desc: 'Edge-to-edge full width banner' }, minimal: { label: 'Minimal', desc: 'Centered title with subtle photo' }, stacked: { label: 'Stacked', desc: 'Title stacked directly over photo' }, typographic: { label: 'Typographic', desc: 'Oversized magazine masthead' }, mosaic: { label: 'Mosaic', desc: 'Dynamic photo collage layout' }, cover: { label: 'Cover (Experimental)', desc: 'Fullscreen splash with a single Enter link', }, }; const HERO_STYLE_OPTIONS = toOptions(HERO_STYLES, HERO_STYLE_INFO); function HeroStylePreview({ value }: { value: string }) { return (
{value === 'split' && ( <>
)} {value === 'fullbleed' && (
)} {value === 'minimal' && ( <>
)} {value === 'stacked' && ( <>
)} {value === 'typographic' && ( <>
)} {value === 'mosaic' && (
)}
); } const LAYOUT_INFO: Record = { masonry: { label: 'Masonry', desc: 'Dynamic pinterest-style staggered columns', }, uniform: { label: 'Uniform Grid', desc: 'Clean equal aspect ratio grid' }, showcase: { label: 'Showcase', desc: 'Featured hero photos mixed with smaller tiles', }, filmstrip: { label: 'Filmstrip', desc: 'Horizontal scrollable film strip timeline', }, 'editorial-flow': { label: 'Editorial Flow', desc: 'Magazine story layout with varying photo sizes', }, justified: { label: 'Justified (Experimental)', desc: 'Equal-height rows that fill the full width', }, }; const LAYOUT_OPTIONS = toOptions(LAYOUTS, LAYOUT_INFO); function LayoutPreview({ value }: { value: string }) { return (
{value === 'masonry' && (
)} {value === 'uniform' && (
)} {value === 'showcase' && (
)} {value === 'filmstrip' && (
)} {value === 'editorial-flow' && (
)} {value === 'justified' && (
)}
); } const ASPECT_RATIO_INFO: Record = { '1': { label: 'Square (1:1)', desc: '1:1 ratio square crops' }, '3/2': { label: 'Landscape (3:2)', desc: 'Standard 35mm DSLR landscape' }, '2/3': { label: 'Portrait (2:3)', desc: 'Vertical portrait orientation' }, '16/9': { label: 'Cinema (16:9)', desc: 'Widescreen 16:9 cinematic ratio' }, auto: { label: 'Original Auto', desc: 'Uncropped original image proportions', }, }; const ASPECT_RATIO_OPTIONS = toOptions(ASPECT_RATIOS, ASPECT_RATIO_INFO); function AspectRatioPreview({ value }: { value: string }) { return (
); } /** * Card metadata for the theme picker. `font`, `radius` and `frame` mirror the * real preset definitions in lib/config/theme.ts so the mini mockups show what * the preset actually does; `gap` is the visual density of its gallery grid. */ const THEME_INFO: Record< string, { desc: string; label: string; accent: string; bg: string; tile: string; font: string; type: 'serif' | 'sans' | 'mono'; radius: number; frame: 'none' | 'passepartout' | 'shadow'; gap: number; } > = { studio: { label: 'Studio', desc: 'Clean, high-contrast grid with sans-serif type.', bg: '#141414', tile: '#242424', accent: '#e60012', font: 'Playfair Display', type: 'serif', radius: 0, frame: 'passepartout', gap: 4, }, 'studio-modern': { label: 'Studio Modern', desc: 'Leica precision: Archivo grotesque, mono EXIF, red signal accents.', bg: '#121212', tile: '#191919', accent: '#e60012', font: 'Archivo', type: 'sans', radius: 0, frame: 'none', gap: 3, }, minimal: { label: 'Minimal', desc: 'Pure raw layouts with tiny gaps and high whitespace.', bg: '#ffffff', tile: '#f3f3f3', accent: '#111111', font: 'Geist', type: 'sans', radius: 0, frame: 'none', gap: 2, }, editorial: { label: 'Editorial', desc: 'Warm backgrounds, elegant serifs and large headers.', bg: '#fbf9f4', tile: '#e5dfd4', accent: '#b89053', font: 'Bodoni Moda', type: 'serif', radius: 0, frame: 'shadow', gap: 8, }, classic: { label: 'Classic', desc: 'Soft traditional photographer portfolio with shadows.', bg: '#f7f7f7', tile: '#ffffff', accent: '#444444', font: 'Cinzel', type: 'serif', radius: 12, frame: 'passepartout', gap: 7, }, noir: { label: 'Noir', desc: 'High drama absolute pitch black, stark high-fashion look.', bg: '#000000', tile: '#151515', accent: '#ffffff', font: 'Libre Baskerville', type: 'serif', radius: 0, frame: 'passepartout', gap: 6, }, monograph: { label: 'Monograph', desc: 'Typewriter monospace font, grid borders and document feel.', bg: '#f4f4f6', tile: '#ffffff', accent: '#555555', font: 'Instrument Serif', type: 'mono', radius: 0, frame: 'none', gap: 5, }, }; /* The preset group sits below THEME_INFO on purpose. PRESET_OPTIONS is built at module evaluation and reads it through themeInfo(); declared above, it hit the temporal dead zone and the whole Settings page failed to load with "Cannot access 'THEME_INFO' before initialization". tsc does not catch it because the read goes through a hoisted function, and a production build succeeds because the module is only evaluated in the browser (#542). */ /** The theme picker's own metadata, with the fallback the card grid needs. */ function themeInfo(value: string) { return ( THEME_INFO[value] || { label: value, desc: '', bg: '#fff', tile: '#eee', accent: '#333', font: 'System', type: 'sans' as const, radius: 0, frame: 'none' as const, gap: 4, } ); } const PRESET_OPTIONS = PRESETS.map((value) => ({ value, label: themeInfo(value).label, desc: themeInfo(value).desc, })); function PresetPreview({ value }: { value: string }) { const i = themeInfo(value); return (
Aa
); } function PresetSpecs({ value }: { value: string }) { const i = themeInfo(value); return ( {i.font} · radius {i.radius} · {i.frame} ); } const SETTINGS_SECTIONS = [ { id: 'general', label: 'General' }, { id: 'theme', label: 'Theme' }, { id: 'grid', label: 'Grid' }, { id: 'footer', label: 'Footer' }, { id: 'legal', label: 'Legal' }, { id: 'seo', label: 'SEO' }, { id: 'security', label: 'Security & Protection' }, { id: 'about', label: 'About' }, ]; /** * One labelled block of related switches. * * The features panel was a single flat grid of identical cards, so nothing said * which switches belonged together — and a switch that publishes private notes * looked exactly like one that controls a fade (#510). */ function FeatureGroup({ icon, title, description, chip, children, }: { icon: React.ReactNode; title: string; description: string; /** Short marker for a group that needs its own weight, e.g. what visitors see. */ chip?: string; children: React.ReactNode; }) { return (
{icon} {title} {chip && {chip}} {description}
{children}
); } /** * A switch inside a group panel. Deliberately lighter than a toggle card: these * are settings *of* the panel they sit in, and rendering them as peers of the * top-level cards is what hid the structure in the first place. */ function SettingRow({ title, description, checked, onToggle, disabled, hint, indented, }: { title: string; description: string; checked: boolean; onToggle: () => void; /** A row whose parent switch is off — shown, but inert and explained. */ disabled?: boolean; hint?: string; indented?: boolean; }) { return ( ); } export default function SettingsEditor() { const router = useRouter(); // Read here rather than passed in: the editor is mounted by the settings // layout, which does not receive the child route's params. const section = useParams<{ section?: string }>()?.section; // An unknown section in the URL falls back to General instead of an empty panel. const activeSection = SETTINGS_SECTIONS.some((sec) => sec.id === section) ? (section as string) : 'general'; const [settings, setSettings] = useState({}); /** Resolved site URL and its origin, so the panel can name SITE_URL (#472). */ const [siteUrlInfo, setSiteUrlInfo] = useState<{ effective: string | null; source: 'env' | 'settings' | 'none'; } | null>(null); const [loading, setLoading] = useState(true); /** * Set when the settings could not be fetched. It blocks saving, because an * empty form saved over a live settings.yaml replaces it with whatever one * field the user happened to touch. */ const [loadError, setLoadError] = useState(null); // Collapsed by default: the four metadata switches are a detail of one // decision, and showing them permanently is what made the section a wall (#510). const [metadataOpen, setMetadataOpen] = useState(false); const [saving, setSaving] = useState(false); const [dirty, setDirty] = useState(false); const [saveMessage, setSaveMessage] = useState(''); const [currentMode, setCurrentMode] = useState<'dark' | 'light'>('dark'); const [faviconUploading, setFaviconUploading] = useState(false); const [faviconMessage, setFaviconMessage] = useState(''); // About content state (independent of main settings save) const [aboutMeta, setAboutMeta] = useState<{ portrait?: string; name?: string; location?: string; gear?: string[]; }>({}); const [aboutBody, setAboutBody] = useState(''); const [aboutLoading, setAboutLoading] = useState(false); const [aboutLoaded, setAboutLoaded] = useState(false); const [aboutSaving, setAboutSaving] = useState(false); const [aboutDirty, setAboutDirty] = useState(false); const [aboutMessage, setAboutMessage] = useState(''); const [aboutGearText, setAboutGearText] = useState(''); useEffect(() => { loadSettings(); if (typeof window !== 'undefined') { const mode = (document.documentElement.getAttribute('data-theme') as 'dark' | 'light') || 'dark'; setCurrentMode(mode); } }, []); // Sync picked preset & accent color to document element immediately for live feedback useEffect(() => { if (typeof document !== 'undefined') { if (settings.theme?.preset) { document.documentElement.setAttribute('data-preset', settings.theme.preset); } if (settings.theme?.accent) { document.documentElement.style.setProperty('--accent', settings.theme.accent); document.documentElement.style.setProperty('--admin-accent', settings.theme.accent); } } }, [settings.theme?.preset, settings.theme?.accent]); // Loaded the first time the About section opens, and only then: the editor // stays mounted across sections, so reloading on every visit would overwrite // unsaved About edits with the file on disk. useEffect(() => { if (activeSection === 'about' && !aboutLoaded) loadAboutContent(); }, [activeSection, aboutLoaded]); const toggleMode = (mode: 'dark' | 'light') => { setCurrentMode(mode); if (typeof document !== 'undefined') { document.documentElement.setAttribute('data-theme', mode); localStorage.setItem('theme', mode); } }; // ── Keyboard shortcut: ⌘+S / Ctrl+S ───────────────────────── const handleSaveRef = useCallback(() => { saveAll(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [dirty, saving, settings, aboutDirty, aboutSaving, aboutMeta, aboutBody, aboutGearText]); useEffect(() => { function handleKeyDown(e: KeyboardEvent) { if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); handleSaveRef(); } } window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [handleSaveRef]); useUnsavedGuard(dirty || aboutDirty); async function loadSettings() { setLoading(true); setLoadError(null); try { const res = await fetch('/api/admin/settings'); if (!res.ok) { // 401 is the common one: the session lasts 24h and nothing re-checks // it, so a tab left open overnight lands here. reportIfSessionExpired // also drops the whole panel back to the login screen (#596); the // thrown message below still covers the brief moment before that // re-render happens, and any other non-ok status. reportIfSessionExpired(res); throw new Error( res.status === 401 ? 'Your session has expired. Sign in again to continue.' : `The server answered ${res.status}.`, ); } const { settings: data, siteUrl } = await res.json(); setSettings(data || {}); setSiteUrlInfo(siteUrl ?? null); } catch (err) { console.error('Failed to load settings:', err); setLoadError(err instanceof Error ? err.message : 'The settings could not be loaded.'); } finally { setLoading(false); } } /** Write several paths in one state update — used by the metadata master switch. */ function updateMany(entries: Record) { setSettings((s) => { const copy = JSON.parse(JSON.stringify(s)); for (const [path, value] of Object.entries(entries)) { const parts = path.split('.'); let obj = copy; for (let i = 0; i < parts.length - 1; i++) { if (!obj[parts[i]]) obj[parts[i]] = {}; obj = obj[parts[i]]; } obj[parts[parts.length - 1]] = value; } return copy; }); setDirty(true); setSaveMessage(''); } function update(path: string, value: unknown) { setSettings((s) => { const copy = JSON.parse(JSON.stringify(s)); const parts = path.split('.'); let obj = copy; for (let i = 0; i < parts.length - 1; i++) { if (!obj[parts[i]]) obj[parts[i]] = {}; obj = obj[parts[i]]; } const key = parts[parts.length - 1]; if (value === '' || value === undefined) { delete obj[key]; } else { obj[key] = value; } return copy; }); setDirty(true); setSaveMessage(''); } async function handleSave() { // The form is not rendered in this state, but Cmd+S still reaches here. if (loadError) return; setSaving(true); setSaveMessage(''); // Clean up empty objects const cleaned = JSON.parse(JSON.stringify(settings)); for (const key of Object.keys(cleaned)) { if (typeof cleaned[key] === 'object' && Object.keys(cleaned[key]).length === 0) { delete cleaned[key]; } } try { const res = await fetch('/api/admin/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ settings: cleaned }), }); if (res.ok) { const data = await res.json(); setDirty(false); setSaveMessage(data.message || 'Saved!'); router.refresh(); setTimeout(() => setSaveMessage(''), 5000); } else if (!reportIfSessionExpired(res)) { const err = await res.json(); // A rejected save names the fields that caused it. Listing them beats // "could not be saved" over a form with forty inputs; putting the // message next to each input is the job of #600. const fields: string[] = Array.isArray(err.fields) ? err.fields.map((f: { field?: string }) => f.field || 'settings') : []; setSaveMessage(`Error: ${err.error}${fields.length ? ` — ${fields.join(', ')}` : ''}`); } } catch { setSaveMessage('Error: Failed to save'); } finally { setSaving(false); } } async function loadAboutContent() { setAboutLoading(true); setAboutMessage(''); try { const res = await fetch('/api/admin/about'); if (!res.ok) { throw new Error( res.status === 401 ? 'Your session has expired. Sign in again to continue.' : `The server answered ${res.status}.`, ); } const data = await res.json(); setAboutMeta(data.meta || {}); setAboutBody(data.body || ''); setAboutGearText(data.meta?.gear?.join('\n') || ''); setAboutLoaded(true); } catch (err) { console.error('Failed to load about content:', err); // aboutLoaded stays false, which is what blocks the save below: an empty // editor written to about.md would replace the page with nothing. setAboutMessage( `Error: ${err instanceof Error ? err.message : 'The About page could not be loaded.'}`, ); } finally { setAboutLoading(false); } } async function saveAboutContent() { if (!aboutLoaded) return; setAboutSaving(true); setAboutMessage(''); const cleanedMeta = { ...aboutMeta }; for (const [k, v] of Object.entries(cleanedMeta)) { if (v === '' || v === undefined) delete cleanedMeta[k as keyof typeof cleanedMeta]; } const gearLines = aboutGearText .split('\n') .map((s) => s.trim()) .filter(Boolean); if (gearLines.length > 0) { cleanedMeta.gear = gearLines; } else { delete cleanedMeta.gear; } try { const res = await fetch('/api/admin/about', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ meta: cleanedMeta, body: aboutBody }), }); if (res.ok) { const data = await res.json(); setAboutDirty(false); setAboutMessage(data.message || 'Saved!'); setTimeout(() => setAboutMessage(''), 4000); } else { const err = await res.json(); setAboutMessage(`Error: ${err.error}`); } } catch { setAboutMessage('Error: Failed to save'); } finally { setAboutSaving(false); } } /** * Save whatever is unsaved, in either file. The owner edits one set of * settings; that About lives in about.md and the rest in settings.yaml is * not theirs to keep track of. */ function saveAll() { if (dirty && !saving) handleSave(); if (aboutDirty && !aboutSaving) saveAboutContent(); } function updateAboutMeta(key: string, value: unknown) { setAboutMeta((m) => ({ ...m, [key]: value })); setAboutDirty(true); setAboutMessage(''); } if (loading) { return (
); } /** * Deliberately replaces the form rather than sitting above it. An empty form * is indistinguishable from a site with no settings yet, and editing one * field in it used to publish that field as the entire settings.yaml. */ if (loadError) { return (
Settings could not be loaded. {loadError}

Nothing has been changed. Saving stays disabled until they load, so an empty form cannot overwrite your configuration.

); } // Resolved the same way the site resolves it, so the switches show what a // visitor actually sees — including a config that only ever set the older // `exifOnHover`. const exif = resolveExifDisplay(settings.exif, settings.exifOnHover); // The master switch has no key of its own and needs none: "off" is all four // groups off, a state the site already acts on — with nothing left to show, // the lightbox withdraws its info button and the `i` key entirely (#506). const anyMetadata = exif.camera || exif.settings || exif.location || exif.caption; const metadataSummary = [ exif.camera && 'Camera & lens', exif.settings && 'Exposure', exif.location && 'Location', exif.caption && 'Description', ] .filter(Boolean) .join(' · ') || 'Nothing published — the lightbox hides its info panel'; /** Turning it back on selects everything; the details below narrow it again. */ const toggleMetadata = () => updateMany({ 'exif.camera': !anyMetadata, 'exif.settings': !anyMetadata, 'exif.location': !anyMetadata, 'exif.caption': !anyMetadata, }); // An error from either save wins over the other's success. const saveBarMessage = [saveMessage, aboutMessage].find((m) => m.startsWith('Error')) || saveMessage || aboutMessage; return (
{/* One bar for both files, on every section: an About edit stays visible from Theme, and a settings edit from About. */}

Live Sync (No Docker restart required)

{/* Sidebar */} {/* Content */}
{activeSection === 'general' && (

General Site Settings

Configure basic site identity, language, and core feature toggles.

update('title', e.target.value)} placeholder="My Portfolio" />
update('subtitle', e.target.value)} placeholder="A visual journal" />

The visitor-facing interface is translated for {SUPPORTED_LOCALES.join(', ')}. Other languages still set <html lang> and date formatting, but show the English interface. The admin panel is always English.

Portfolio Features & Modules

Enable or disable optional portfolio modules, privacy analytics, and map widgets.

} title="Pages & motion" description="Chrome the visitor sees on every page." >
} title="About Page" description="Show a portrait, bio, and gear section on your portfolio" checked={settings.about?.enabled !== false} onToggle={() => update('about.enabled', settings.about?.enabled === false)} /> } title="Smooth Page Transitions" description="Enable subtle fade-in animations between page navigation" checked={settings.transitions !== false} onToggle={() => update('transitions', settings.transitions === false)} /> } title="Scroll-to-Top Button" description="Show a floating arrow that returns visitors to the top of long pages" checked={settings.scrollToTop !== false} onToggle={() => update('scrollToTop', settings.scrollToTop === false)} />
} title="What each photo reveals" description="Published to anyone who opens a photo." chip="visible to visitors" >
{metadataOpen && (
update('exif.camera', !exif.camera)} /> update('exif.settings', !exif.settings)} /> update('exif.location', !exif.location)} /> update('exif.caption', !exif.caption)} /> {/* Dependent, not a peer: this only controls the grid overlay, and the overlay carries camera and lens. With those off it has nothing to show, which used to leave it switched on and silently doing nothing (#510). */} update('exifOnHover', settings.exifOnHover === false)} />
)}
} title="What visitors can do" description="Optional pages and interactions." >
} title="Interactive GPS Map" description="Enable /map view showing photo locations on a world map" checked={settings.map === true} onToggle={() => update('map', !settings.map)} /> } title="Client Proofing & Favorites" description="Allow visitors & clients to heart, filter, and export favorite photo selections" checked={settings.proofing?.enabled !== false} onToggle={() => update('proofing.enabled', settings.proofing?.enabled === false) } />
} title="Measurement" description="Cookieless and self-hosted — nothing leaves your server." >
} title="Analytics Tracking" description="Collect anonymous privacy-friendly visit statistics" checked={settings.analytics !== false} onToggle={() => update('analytics', settings.analytics === false)} />
{ const file = e.target.files?.[0]; if (!file) return; setFaviconMessage(''); setFaviconUploading(true); const form = new FormData(); form.append('file', file); try { const res = await fetch('/api/admin/favicon', { method: 'PUT', body: form, }); const data = await res.json(); setFaviconMessage(res.ok ? data.message : `Error: ${data.error}`); } catch { setFaviconMessage('Error: Upload failed'); } finally { setFaviconUploading(false); e.target.value = ''; } }} /> {faviconUploading &&
}
{faviconMessage && (

{faviconMessage}

)} SVG, PNG, ICO, or JPEG — max 512 kB. Stored in the content volume. Reset restores the bundled default.
)} {activeSection === 'theme' && (

Theme Presets & Color Mode

Choose a typography preset and preview in Light or Dark mode.

What a first-time visitor sees. The light/dark toggle in the site header still lets them choose, and their choice is remembered on their device. The preview below is your own, and does not change this.

update('theme.preset', v)} cardClassName="theme-preset-card" renderPreview={(o) => } renderSpecs={(o) => } cardStyle={(o) => { const i = themeInfo(o.value); return { '--preset-accent': i.accent, '--preset-bg': i.bg, '--preset-tile': i.tile, '--preset-radius': `${i.radius}px`, '--preset-gap': `${i.gap}px`, } as React.CSSProperties; }} />

Presets like Editorial, Minimal &{' '} Classic feature warm light/cream backgrounds in Light Mode and charcoal in Dark Mode.

Accent Color

Pick a primary accent color for links, buttons, and highlights.

{[ { hex: '#e60012', name: 'Studio Red' }, { hex: '#b89053', name: 'Editorial Gold' }, { hex: '#10b981', name: 'Emerald' }, { hex: '#3b82f6', name: 'Sapphire' }, { hex: '#8b5cf6', name: 'Violet' }, { hex: '#ffffff', name: 'Monochrome White' }, { hex: '#000000', name: 'Obsidian Black' }, ].map((swatch) => { const isSelected = (settings.theme?.accent || '#e60012').toLowerCase() === swatch.hex.toLowerCase(); return (
update('theme.accent', e.target.value)} /> update('theme.accent', e.target.value)} placeholder="#e60012" />

Photo Frame & Layout

Customize image presentation borders and hero layouts.

update('theme.photoFrame', v)} cardClassName="frame-card" renderPreview={(o) => } /> update('theme.heroStyle', v)} cardClassName="hero-card" renderPreview={(o) => } />

Finishing Touches

Enable optional visual effects and indicators.

} title="Film Grain Texture" description="Adds analog noise overlay across portfolio background" checked={settings.theme?.grain === true} onToggle={() => update('theme.grain', !settings.theme?.grain)} /> } title="Header Accent Dot" description="Displays accent dot next to active section header" checked={settings.theme?.headerDot !== false} onToggle={() => update('theme.headerDot', settings.theme?.headerDot === false)} />
)} {activeSection === 'grid' && (

Grid & Layout Engine

Configure photography gallery column structures and thumbnail aspect ratios.

update('grid.layout', v)} cardClassName="grid-layout-card" renderPreview={(o) => } /> update('grid.aspectRatio', v)} cardClassName="ratio-card" renderPreview={(o) => } />

Spacing & Columns

Adjust column counts and grid gap spacing. The column count also tiles the album covers on a subpage; their spacing stays with the theme unless a page overrides it.

update('grid.columns', parseInt(e.target.value) || 3)} />
update('grid.gap', parseInt(e.target.value) || 0)} />
)} {activeSection === 'footer' && (

Footer & Social Links

Display branding, Instagram, email and website links in portfolio footer.

update('footer.name', e.target.value)} placeholder="My Photography" />
update('footer.instagram', e.target.value)} placeholder="https://instagram.com/your-handle" />
update('footer.email', e.target.value)} placeholder="hello@example.com" />
update('footer.website', e.target.value)} placeholder="https://example.com" />

Header Navigation Links (Experimental)

External links shown after your pages in the header menu. Only http(s) URLs are allowed; they open in a new tab.

{(settings.navLinks || []).map((link, i) => (
{ const next = [...(settings.navLinks || [])]; next[i] = { ...next[i], label: e.target.value }; update('navLinks', next); }} placeholder="Shop" />
{ const next = [...(settings.navLinks || [])]; next[i] = { ...next[i], url: e.target.value }; update('navLinks', next); }} placeholder="https://shop.example.com" />
))}
)} {activeSection === 'legal' && (

Legal Notice & Impressum

Configure required legal disclosure page for EU / German Telemediengesetz compliance.

} title="Enable Impressum Page (/impressum)" description="Automatically generates and links /impressum in footer" checked={settings.legal?.enabled === true} onToggle={() => update('legal.enabled', !settings.legal?.enabled)} />
{settings.legal?.enabled && ( <>
update('legal.name', e.target.value)} placeholder="Max Mustermann" />
update('legal.address', e.target.value)} placeholder="Musterstraße 1" />
update('legal.zipCity', e.target.value)} placeholder="12345 Berlin" />
update('legal.country', e.target.value)} placeholder="Germany" />
update('legal.email', e.target.value)} placeholder="legal@example.com" />
update('legal.phone', e.target.value)} placeholder="+49 123 456789" />