/** * DevToolbar — floating visual builder for theme previewing. * * Only renders when NODE_ENV === 'development'. * Modifies data-* attributes on to live-preview themes, * photo frames, dark/light mode, and grain without a rebuild. * * Hero styles and grid layouts require server re-render, so * those sections show a copyable YAML snippet instead. * * Layout: tabbed panel (Theme / Grid / Server) anchored bottom-right, * away from Next.js debug toolbar which sits bottom-left. */ 'use client'; import React, { useState, useEffect, useCallback, useLayoutEffect } from 'react'; import { IconPalette, IconX } from './Icons'; const PRESETS = ['studio', 'studio-modern', 'minimal', 'editorial', 'classic', 'noir', 'monograph']; const FRAMES = ['none', 'passepartout', 'shadow']; const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic']; const GRID_LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow']; const ASPECT_RATIOS = ['1', '3/2', '2/3', '4/3', '16/9', 'auto']; type Tab = 'theme' | 'grid' | 'server'; // Font stacks for each preset (must match THEME_PRESETS in config.ts) const PRESET_FONTS: Record = { studio: { heading: 'Playfair Display', body: 'DM Sans', caption: 'EB Garamond' }, 'studio-modern': { heading: 'Archivo', body: 'Archivo', caption: 'IBM Plex Mono' }, minimal: { heading: 'Geist', body: 'Geist', caption: 'IBM Plex Mono' }, editorial: { heading: 'Bodoni Moda', body: 'Newsreader', caption: 'Spectral' }, classic: { heading: 'Cinzel', body: 'Crimson Pro', caption: 'Crimson Pro' }, noir: { heading: 'Libre Baskerville', body: 'Source Sans 3', caption: 'Space Mono' }, monograph: { heading: 'Instrument Serif', body: 'Inter', caption: 'IBM Plex Mono' }, }; const PRESET_ACCENTS: Record = { studio: 'var(--accent-studio, #e60012)', 'studio-modern': 'var(--accent-studio-modern, #e60012)', minimal: 'var(--accent-minimal, #000000)', editorial: 'var(--accent-editorial, #8B2500)', classic: 'var(--accent-classic, #c49a3c)', noir: 'var(--accent-noir, #ff6b35)', monograph: 'var(--accent-monograph, #333333)', }; function getAttr(attr: string): string { return document.documentElement.getAttribute(attr) ?? ''; } function setAttr(attr: string, value: string) { document.documentElement.setAttribute(attr, value); } function setVar(name: string, value: string) { document.documentElement.style.setProperty(name, value); } export function DevToolbar() { const [open, setOpen] = useState(false); const [tab, setTab] = useState('theme'); const [preset, setPreset] = useState(''); const [frame, setFrame] = useState(''); const [theme, setTheme] = useState(''); const [grain, setGrain] = useState(''); const [exifOnHover, setExifOnHover] = useState(true); const [copiedYaml, setCopiedYaml] = useState(''); const [gridCols, setGridCols] = useState(3); const [gridGap, setGridGap] = useState(12); const [aspectRatio, setAspectRatio] = useState('1'); const [gridLayout, setGridLayout] = useState('masonry'); // Read initial values from DOM useLayoutEffect(() => { setPreset(getAttr('data-preset')); // eslint-disable-line react-hooks/set-state-in-effect setFrame(getAttr('data-photo-frame')); setTheme(getAttr('data-theme') || 'dark'); setGrain(getAttr('data-grain')); // Read grid vars const style = getComputedStyle(document.documentElement); const cols = parseInt(style.getPropertyValue('--grid-columns') || '3', 10); const gap = parseInt(style.getPropertyValue('--grid-gap') || '12', 10); const ar = style.getPropertyValue('--grid-aspect-ratio').trim() || '1'; const layout = getAttr('data-grid-layout') || 'masonry'; if (!isNaN(cols)) setGridCols(cols); if (!isNaN(gap)) setGridGap(gap); setAspectRatio(ar); setGridLayout(layout); }, []); const switchPreset = useCallback((p: string) => { setAttr('data-preset', p); setPreset(p); // Update accent + fonts const accent = PRESET_ACCENTS[p] || 'var(--accent-studio, #e60012)'; setVar('--accent', accent); // Note: since accent might be a CSS var, appending hex opacity won't work in all browsers if it's a var, // but we provide it for backward compatibility where possible. Or use color-mix if supported. // For now we set it to same var or use a generic dim if needed. setVar('--accent-dim', `color-mix(in srgb, ${accent} 12%, transparent)`); const fonts = PRESET_FONTS[p]; if (fonts) { setVar('--font-serif', `'${fonts.heading}', Georgia, 'Times New Roman', serif`); setVar( '--font-sans', `'${fonts.body}', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`, ); setVar('--font-caption', `'${fonts.caption}', Georgia, serif`); // Dynamically load Google Fonts if not already loaded const families = [fonts.heading, fonts.body, fonts.caption] .filter((f, i, a) => a.indexOf(f) === i) .map( (f) => `family=${f.replace(/\s+/g, '+')}:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400`, ) .join('&'); const href = `https://fonts.googleapis.com/css2?${families}&display=swap`; // Check if already loaded const existing = document.querySelector(`link[href="${href}"]`); if (!existing) { const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = href; document.head.appendChild(link); } } }, []); const switchFrame = useCallback((f: string) => { setAttr('data-photo-frame', f); setFrame(f); }, []); const switchTheme = useCallback((t: string) => { setAttr('data-theme', t); setTheme(t); }, []); const toggleGrain = useCallback(() => { const next = grain === 'true' ? 'false' : 'true'; setAttr('data-grain', next); setGrain(next); }, [grain]); const updateGridCols = useCallback((cols: number) => { setGridCols(cols); // Update all photo-grid elements document.querySelectorAll('.photo-grid').forEach((el) => { el.style.setProperty('--grid-columns', String(cols)); }); }, []); const updateGridGap = useCallback((gap: number) => { setGridGap(gap); document.querySelectorAll('.photo-grid').forEach((el) => { el.style.setProperty('--grid-gap', `${gap}px`); }); }, []); const updateAspectRatio = useCallback((ar: string) => { setAspectRatio(ar); document.querySelectorAll('.photo-grid').forEach((el) => { el.style.setProperty('--grid-aspect-ratio', ar); }); }, []); const toggleExifOnHover = useCallback(() => { setExifOnHover((prev) => !prev); }, []); const copyYaml = useCallback((section: string, yaml: string) => { navigator.clipboard.writeText(yaml); setCopiedYaml(section); setTimeout(() => setCopiedYaml(''), 1500); }, []); const currentYaml = `theme: preset: "${preset}" photoFrame: "${frame}" grain: ${grain === 'true'} grid: columns: ${gridCols} gap: ${gridGap} aspectRatio: "${aspectRatio}" layout: "${gridLayout}" exifOnHover: ${exifOnHover}`; // Keyboard shortcut: Ctrl/Cmd + Shift + T useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'T') { e.preventDefault(); setOpen((o) => !o); } }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, []); return ( <> {/* ── Floating Toggle Button — bottom-left ── */} {/* ── Panel — anchored bottom-left ────────── */} {open && (
{/* ── Header ──────────────────────────── */}
DEV ONLY
{/* ── Tab Bar ─────────────────────────── */}
{(['theme', 'grid', 'server'] as Tab[]).map((t) => ( ))}
{/* ── Tab Content ──────────────────────── */}
{/* ── THEME TAB ── */} {tab === 'theme' && (
)} {/* ── GRID TAB ── */} {tab === 'grid' && (
updateGridCols(Number(e.target.value))} aria-label="Grid Columns" style={{ width: '100%', accentColor: '#e4e4e7', marginTop: 6 }} />
updateGridGap(Number(e.target.value))} aria-label="Grid Gap" style={{ width: '100%', accentColor: '#e4e4e7', marginTop: 6 }} />
)} {/* ── SERVER TAB ── */} {tab === 'server' && (
copyYaml('hero', `theme:\n heroStyle: "${h}"`)} /> {copiedYaml === 'hero' && }
{ setGridLayout(l); document.documentElement.setAttribute('data-grid-layout', l); copyYaml('layout', `grid:\n layout: "${l}"`); }} /> {copiedYaml === 'layout' && }

These settings require a server re-render. Click to copy the YAML snippet, then paste into content/settings.yaml.

)}
{/* ── Export YAML ──────────────────────── */}
)} ); } /* ── Sub-components ───────────────────────────────── */ function Label({ children }: { children: React.ReactNode }) { return (
{children}
); } function ToggleButton({ on, onClick }: { on: boolean; onClick: () => void }) { return ( ); } const pillStyle: React.CSSProperties = { padding: '5px 11px', borderRadius: 8, fontSize: 11, fontWeight: 500, cursor: 'pointer', transition: 'all 0.12s ease', whiteSpace: 'nowrap', }; function PillGrid({ items, active, onSelect, }: { items: string[]; active: string; onSelect: (item: string) => void; }) { return (
{items.map((item) => ( ))}
); } function CopyBadge() { return ( ✓ YAML copied — paste into settings.yaml ); }