/** * 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. */ 'use client'; import React, { useState, useEffect, useCallback, useLayoutEffect } from 'react'; const PRESETS = ['studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph', 'botanica']; const FRAMES = ['none', 'passepartout', 'shadow']; const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic']; const GRID_LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow']; // 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' }, minimal: { heading: 'Geist', body: 'Geist', caption: 'IBM Plex Mono' }, editorial: { heading: 'Bodoni Moda', body: 'Newsreader', caption: 'IBM Plex Mono' }, classic: { heading: 'Cinzel', body: 'Crimson Pro', caption: 'EB Garamond' }, noir: { heading: 'Libre Baskerville', body: 'Source Sans 3', caption: 'IBM Plex Mono' }, monograph: { heading: 'Instrument Serif', body: 'Inter', caption: 'JetBrains Mono' }, botanica: { heading: 'Cormorant Garamond', body: 'Nunito Sans', caption: 'Lora' }, }; const PRESET_ACCENTS: Record = { studio: '#e60012', minimal: '#000000', editorial: '#8B2500', classic: '#c49a3c', noir: '#ff6b35', monograph: '#333333', botanica: '#4a7c59', }; 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 [preset, setPreset] = useState(''); const [frame, setFrame] = useState(''); const [theme, setTheme] = useState(''); const [grain, setGrain] = useState(''); const [copiedYaml, setCopiedYaml] = useState(''); const [gridCols, setGridCols] = useState(3); const [gridGap, setGridGap] = useState(12); // 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); if (!isNaN(cols)) setGridCols(cols); if (!isNaN(gap)) setGridGap(gap); }, []); const switchPreset = useCallback((p: string) => { setAttr('data-preset', p); setPreset(p); // Update accent + fonts const accent = PRESET_ACCENTS[p] || '#e60012'; setVar('--accent', accent); setVar('--accent-dim', `${accent}1f`); 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 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'}`; // 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 ─────────────────────── */} {/* ── Panel ──────────────────────────────────────── */} {open && (
{/* ── Header ──────────────────────────── */}
🎨 Theme Builder DEV ONLY
{/* ── Preset ──────────────────────────── */}
{/* ── Dark / Light ────────────────────── */}
{/* ── Photo Frame ─────────────────────── */}
{/* ── Grain ───────────────────────────── */}
{/* ── Grid Controls ───────────────────── */}
updateGridCols(Number(e.target.value))} style={{ flex: 1, accentColor: '#a855f7' }} />
updateGridGap(Number(e.target.value))} style={{ flex: 1, accentColor: '#a855f7' }} />
{/* ── Hero Style (info only) ──────────── */}
{ copyYaml('hero', `theme:\n heroStyle: ${h}`); }} /> {copiedYaml === 'hero' && }
{/* ── Grid Layout (info only) ─────────── */}
{ copyYaml('layout', `grid:\n layout: ${l}`); }} /> {copiedYaml === 'layout' && }
{/* ── Export YAML ──────────────────────── */}
)} ); } /* ── Sub-components ───────────────────────────────── */ function Section({ title, children }: { title: string; children: React.ReactNode }) { return (
{title}
{children}
); } const pillStyle: React.CSSProperties = { padding: '6px 12px', 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 gallery.yaml ); }