'use client'; import { useState, type CSSProperties } from 'react'; import * as Icons from '../Icons'; import OptionGrid, { toOptions } from '../fields/OptionGrid'; import { DEFAULT_PRESET } from '@/lib/config/theme'; import { effectiveTheme, FeatureRow } from './fields'; import type { SectionProps } from './types'; const PRESETS = ['studio-modern', 'studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph']; const PHOTO_FRAMES = ['none', 'passepartout', 'shadow']; const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic', 'cover']; 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' && (
)}
); } /** * 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} ); } export default function ThemeSection({ settings, update }: SectionProps) { // Read off the page on mount: the toggle previews light and dark on the // admin itself, and the page may already be in either. const [currentMode, setCurrentMode] = useState<'dark' | 'light'>(() => typeof document !== 'undefined' && document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark', ); const toggleMode = (mode: 'dark' | 'light') => { setCurrentMode(mode); if (typeof document !== 'undefined') { document.documentElement.setAttribute('data-theme', mode); localStorage.setItem('theme', mode); } }; // Same idea for the theme: an unset field shows the preset's own value, not // a fallback of this form's — Classic used to read "split hero, no frame". const theme = effectiveTheme(settings.theme); return (

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 CSSProperties; }} />
Color Mode

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 = theme.accent.toLowerCase() === swatch.hex.toLowerCase(); return (
update('theme.accent', e.target.value)} /> update('theme.accent', e.target.value)} placeholder={theme.accent} />

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) => } />

Look & motion

Effects and small controls on every page.

} title="Page transitions" description="A short fade between pages" checked={settings.transitions !== false} onToggle={() => update('transitions', settings.transitions === false)} /> } title="Scroll-to-top button" description="A floating arrow on long pages" checked={settings.scrollToTop !== false} onToggle={() => update('scrollToTop', settings.scrollToTop === false)} /> } title="Film grain" description="Analog noise over the background" checked={theme.grain} onToggle={() => update('theme.grain', !theme.grain)} /> } title="Header accent dot" description="A dot beside the active section heading" checked={theme.headerDot} onToggle={() => update('theme.headerDot', !theme.headerDot)} />
); }