'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 { PRESET_IDS, PRESET_REGISTRY } from '@/lib/config/presets'; import { effectiveTheme, FeatureRow, FieldError } from './fields'; import type { SectionProps } from './types'; 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 text masthead, no photos' }, 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' && ( <>
)} {/* Text only, like the hero it stands for: the typographic hero renders no photo, so the card must not promise any. */} {value === 'typographic' && ( <>
)} {value === 'cover' && (
)} {value === 'mosaic' && (
)}
); } /** * Card metadata for the theme picker, from the preset registry * (lib/config/presets.ts). `font`, `radius` and `frame` are the preset's own * values 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; } > = Object.fromEntries( PRESET_REGISTRY.map((p) => [ p.id, { label: p.label, desc: p.description, ...p.card, font: p.theme.fonts.heading, radius: p.theme.radius, frame: p.theme.photoFrame, }, ]), ); /* 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 = PRESET_IDS.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, fieldErrors }: 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} aria-invalid={fieldErrors?.['theme.accent'] ? true : undefined} aria-describedby={fieldErrors?.['theme.accent'] ? 'theme-accent-error' : undefined} />

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