'use client'; import { useState, useEffect, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import * as Icons from './Icons'; import SaveBar from './SaveBar'; // 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 { resolveExifDisplay, resolveWatermarkOpacity } from '@/lib/config/schema'; import { SUPPORTED_LOCALES } from '@/lib/i18n'; interface Settings { title?: string; subtitle?: 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']; /** * 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, }, }; 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' }, ]; interface SettingsEditorProps { /** Sub-section to show, taken from the /admin/settings/[section] route. */ section?: string; } /** * 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({ section }: SettingsEditorProps) { const router = useRouter(); // 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({}); const [loading, setLoading] = useState(true); // 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 [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]); // The About panel has its own route, so load its content when that section opens useEffect(() => { if (activeSection === 'about') loadAboutContent(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeSection]); 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(() => { if (activeSection === 'about') { if (aboutDirty && !aboutSaving) saveAboutContent(); return; } if (dirty && !saving) { handleSave(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ dirty, saving, settings, activeSection, 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]); // ── Unsaved changes guard ──────────────────────────────────── useEffect(() => { function handleBeforeUnload(e: BeforeUnloadEvent) { if (dirty) { e.preventDefault(); e.returnValue = ''; } } window.addEventListener('beforeunload', handleBeforeUnload); return () => window.removeEventListener('beforeunload', handleBeforeUnload); }, [dirty]); async function loadSettings() { setLoading(true); try { const res = await fetch('/api/admin/settings'); if (res.ok) { const { settings: data } = await res.json(); setSettings(data || {}); } } catch (err) { console.error('Failed to load settings:', err); } 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() { 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 { const err = await res.json(); setSaveMessage(`Error: ${err.error}`); } } catch { setSaveMessage('Error: Failed to save'); } finally { setSaving(false); } } async function loadAboutContent() { setAboutLoading(true); try { const res = await fetch('/api/admin/about'); if (res.ok) { const data = await res.json(); setAboutMeta(data.meta || {}); setAboutBody(data.body || ''); setAboutGearText(data.meta?.gear?.join('\n') || ''); } } catch (err) { console.error('Failed to load about content:', err); } finally { setAboutLoading(false); } } async function saveAboutContent() { 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); } } function updateAboutMeta(key: string, value: unknown) { setAboutMeta((m) => ({ ...m, [key]: value })); setAboutDirty(true); setAboutMessage(''); } if (loading) { return (
); } // 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, }); return (
{/* The About panel edits about.md, not settings.yaml, so it drives the bar itself. */} {activeSection === '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="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="Measurement" description="Cookieless and self-hosted — nothing leaves your server." >
{ 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.

{PRESETS.map((p) => { const info = THEME_INFO[p] || { label: p, desc: '', bg: '#fff', tile: '#eee', accent: '#333', font: 'System', type: 'sans' as const, radius: 0, frame: 'none' as const, gap: 4, }; const isActive = (settings.theme?.preset || DEFAULT_PRESET) === p; return ( ); })}

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.

{PHOTO_FRAMES.map((f) => { const info = { 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' }, }[f] || { label: f, desc: '' }; const isActive = (settings.theme?.photoFrame || 'none') === f; return ( ); })}
{HERO_STYLES.map((s) => { const info = { 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', }, }[s] || { label: s, desc: '' }; const isActive = (settings.theme?.heroStyle || 'split') === s; return ( ); })}

Finishing Touches

Enable optional visual effects and indicators.

)} {activeSection === 'grid' && (

Grid & Layout Engine

Configure photography gallery column structures and thumbnail aspect ratios.

{LAYOUTS.map((l) => { const info = { 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', }, }[l] || { label: l, desc: '' }; const isActive = (settings.grid?.layout || 'masonry') === l; return ( ); })}
{ASPECT_RATIOS.map((r) => { const info = { '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', }, }[r] || { label: r, desc: '' }; const isActive = (settings.grid?.aspectRatio || '1') === r; return ( ); })}

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.

{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" />