'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'; interface Settings { title?: string; subtitle?: string; lang?: string; exifOnHover?: 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; }; 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'; }; } const PRESETS = ['studio', 'studio-modern', 'minimal', 'editorial', 'classic', 'noir', 'monograph']; const LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow']; const PHOTO_FRAMES = ['none', 'passepartout', 'shadow']; const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic']; 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' }, ]; interface SettingsEditorProps { /** Sub-section to show, taken from the /admin/settings/[section] route. */ section?: string; } 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); 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(''); 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]); 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 (dirty && !saving) { handleSave(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [dirty, saving, settings]); 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); } } 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); } } if (loading) { return (
); } return (

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

Portfolio Features & Modules

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

{ 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.

{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 || 'studio') === 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' }, }[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' }, }[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.

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