diff --git a/src/components/builder/AdvancedBuilderApp.tsx b/src/components/builder/AdvancedBuilderApp.tsx
index d17759d..8aed013 100644
--- a/src/components/builder/AdvancedBuilderApp.tsx
+++ b/src/components/builder/AdvancedBuilderApp.tsx
@@ -1,956 +1,6 @@
-// src/components/builder/AdvancedBuilderApp.tsx
-// Advanced Builder — vertical panel nav on the left, panel content on the right.
-// No horizontal tab bars inside panels.
-
-import React, { useState, useEffect, useRef, useCallback, memo } from 'react';
-import clsx from 'clsx';
-import type { PosterConfig, ExtensionType, ApiKeys, RatingType } from './types';
-import {
- DEFAULT_CONFIG, ALL_BADGES,
- CANVAS_WIDTH, CANVAS_HEIGHT, BASE_BADGE_W, BASE_BADGE_H,
-} from './types';
-import { parseUrlToConfig, DEFAULT_API_BASE, calculateAutoPosition, getScale } from './utils';
-import PreviewCanvas from './components/PreviewCanvas';
-import LayerPanel from './components/LayerPanel';
-import Inspector from './components/layout/Inspector';
-import MobileDock from './components/layout/MobileDock';
-import KeyboardShortcutsModal from './components/KeyboardShortcutsModal';
-import ResetDialog from './components/ResetDialogue';
-import ImportDialog from './components/ImportDialogue';
-import ExportPopover from './components/ExportPopover';
-import { EditorProvider, useEditor } from './context/EditorContext';
-import {
- Film, Layers, Monitor, Sliders, MousePointer2,
- RotateCcw, Undo2, Redo2, Maximize2, Minimize2, ZoomIn, ZoomOut,
- Grid3x3, ShieldCheck, Eye, EyeOff, CheckSquare, MousePointer2Off,
- Download, Contrast, Keyboard, ChevronDown, Search, PanelRight,
-} from 'lucide-react';
-import { usePosterHistory } from './hooks/usePosterHistory';
-import ContextMenu, { type ContextMenuState, type LayerTargetId } from './components/ContextMenu';
-import CommandPalette, { type PaletteCommand } from './components/CommandPalette';
-
-// ── Constants ─────────────────────────────────────────────────────────────────
-const STORAGE_KEY = 'posterium_config_v2'; // shared with main builder
-const COOKIE_KEY = 'posterium_apikeys_v1';
-const MAX_QUERY_CONFIG_LENGTH = 12000;
-
-const saveKeysToCookie = (keys: ApiKeys) => {
- try {
- const val = encodeURIComponent(JSON.stringify(keys));
- const exp = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toUTCString();
- document.cookie = `${COOKIE_KEY}=${val}; expires=${exp}; path=/; SameSite=Strict`;
- } catch {}
-};
-const loadKeysFromCookie = (): ApiKeys => {
- try {
- const match = document.cookie.match(new RegExp(`(?:^|; )${COOKIE_KEY}=([^;]*)`));
- if (!match) return {};
- return JSON.parse(decodeURIComponent(match[1])) || {};
- } catch {
- return {};
- }
-};
-
-// ── Panel definitions ─────────────────────────────────────────────────────────
-const ADV_PANELS = [
- { id: 'source' as const, label: 'Source', Icon: Film, desc: 'Media & poster source' },
- { id: 'layers' as const, label: 'Layers', Icon: Layers, desc: 'Badge & logo layers' },
- { id: 'poster' as const, label: 'Canvas', Icon: Monitor, desc: 'Overlays & effects' },
- { id: 'badges' as const, label: 'Badges', Icon: Sliders, desc: 'Global badge style' },
- { id: 'selection' as const, label: 'Selection', Icon: MousePointer2, desc: 'Selected layer config' },
-] as const;
-
-// ── ToolbarBtn ────────────────────────────────────────────────────────────────
-const ToolbarBtn = memo<{
- onClick?: () => void;
- disabled?: boolean;
- label: string;
- danger?: boolean;
- href?: string;
- active?: boolean;
- children: React.ReactNode;
- hideOnMobile?: boolean;
-}>(({ onClick, disabled, label, danger, href, active, children, hideOnMobile = false }) => {
- const cls = `relative group w-8 h-8 flex items-center justify-center rounded-lg transition-all duration-150 select-none outline-none focus-visible:ring-2 focus-visible:ring-[#C47C2E] ${hideOnMobile ? 'hidden lg:flex' : ''} ${disabled ? 'cursor-not-allowed pointer-events-none' : 'active:scale-95 cursor-pointer'}`;
- const activeStyle = active
- ? { color: 'var(--film-amber)', background: 'rgba(196,124,46,0.1)', border: '1px solid rgba(196,124,46,0.2)' }
- : disabled
- ? { color: 'rgba(255,255,255,0.15)', border: '1px solid transparent', opacity: 0.5 }
- : { color: 'var(--film-text-dim)', border: '1px solid transparent' };
- const tooltip = !disabled && (
-
- {label}
-
- );
- const hoverEvents = !disabled && !active ? {
- onMouseEnter: (e: React.MouseEvent) => {
- const el = e.currentTarget as HTMLElement;
- if (danger) { el.style.color = 'rgba(248,113,113,0.8)'; el.style.background = 'rgba(248,113,113,0.08)'; }
- else { el.style.color = 'var(--film-text-label)'; el.style.background = 'rgba(196,124,46,0.07)'; }
- },
- onMouseLeave: (e: React.MouseEvent) => {
- const el = e.currentTarget as HTMLElement;
- el.style.color = 'var(--film-text-dim)'; el.style.background = 'transparent';
- },
- } : {};
- if (href) return {children}{tooltip} ;
- return {children}{tooltip} ;
-});
-ToolbarBtn.displayName = 'ToolbarBtn';
-
-// ── AdvancedNavSidebar ────────────────────────────────────────────────────────
-const AdvancedNavSidebar = memo<{
- config: PosterConfig;
- selectedCount: number;
-}>(({ config, selectedCount }) => {
- const { activeTab, setActiveTab } = useEditor();
-
- const activePanel = (() => {
- if (activeTab === 'logo') return 'selection';
- if (['source', 'layers', 'poster', 'badges', 'selection'].includes(activeTab)) return activeTab;
- return 'source';
- })();
-
- return (
-
- {/* Strip header */}
-
-
- Panels
-
-
-
- {/* Nav list */}
-
- {ADV_PANELS.map(({ id, label, Icon, desc }) => {
- const isActive = activePanel === id;
- const badge = id === 'selection' && selectedCount > 0 ? selectedCount : null;
- return (
- setActiveTab(id as any)}
- aria-current={isActive ? 'true' : undefined}
- style={{
- width: '100%',
- display: 'flex',
- alignItems: 'center',
- gap: 10,
- padding: '10px 14px',
- background: isActive ? 'rgba(196,124,46,0.07)' : 'transparent',
- border: 'none',
- borderLeft: `3px solid ${isActive ? 'var(--film-amber)' : 'transparent'}`,
- cursor: 'pointer',
- color: isActive ? 'var(--film-cream)' : 'var(--film-text-dim)',
- textAlign: 'left',
- transition: 'all 0.12s ease',
- }}
- onMouseEnter={e => {
- if (!isActive) {
- e.currentTarget.style.background = 'rgba(196,124,46,0.04)';
- e.currentTarget.style.color = 'var(--film-text-label)';
- }
- }}
- onMouseLeave={e => {
- if (!isActive) {
- e.currentTarget.style.background = 'transparent';
- e.currentTarget.style.color = 'var(--film-text-dim)';
- }
- }}
- >
- {/* Icon box */}
-
-
-
-
- {/* Label + desc */}
-
-
-
- {label}
-
- {badge !== null && (
-
- {badge}
-
- )}
-
-
- {desc}
-
-
-
- {/* Active indicator dot */}
- {isActive && (
-
- )}
-
- );
- })}
-
-
- {/* Footer info */}
-
-
- {config.ratings.length} badge{config.ratings.length !== 1 ? 's' : ''} · advanced
-
-
-
- );
-});
-AdvancedNavSidebar.displayName = 'AdvancedNavSidebar';
-
-// ── AdvancedRightPanel ────────────────────────────────────────────────────────
-const AdvancedRightPanel = memo<{
- config: PosterConfig;
- setConfig: React.Dispatch>;
- selectedIds: Set;
- onSelect: (id: RatingType, multi: boolean) => void;
-}>(({ config, setConfig, selectedIds, onSelect }) => {
- const { activeTab } = useEditor();
- const isLayerPanel = ['source', 'layers', 'poster'].includes(activeTab);
-
- const panelMeta = ADV_PANELS.find(p =>
- p.id === activeTab || (activeTab === 'logo' && p.id === 'selection')
- ) ?? ADV_PANELS[0];
- const PanelIcon = panelMeta.Icon;
-
- return (
-
- {/* Panel label strip */}
-
-
-
- {panelMeta.label}
-
-
- {panelMeta.desc}
-
-
-
- {/* Panel content — no tab bar */}
-
- {isLayerPanel ? (
-
- ) : (
-
- )}
-
-
- );
-});
-AdvancedRightPanel.displayName = 'AdvancedRightPanel';
-
-// ── AdvancedStudioLayout ──────────────────────────────────────────────────────
-const AdvancedStudioLayout: React.FC<{
- config: PosterConfig;
- setConfig: React.Dispatch>;
- handleReset: () => void;
- baseUrl: string;
- handleLoadConfig: (url: string) => void;
- undo: () => void;
- redo: () => void;
- canUndo: boolean;
- canRedo: boolean;
-}> = ({ config, setConfig, handleReset, baseUrl, handleLoadConfig, undo, redo, canUndo, canRedo }) => {
- const {
- activeTab, setActiveTab,
- mobileSheetMode, setMobileSheetMode,
- selectedIds, selectedLogo, selectedMinimalElements,
- handleSelection, handleLogoSelection,
- clearSelection, setBatchSelection,
- viewOptions, toggleViewOption,
- } = useEditor();
-
- // ── UI state ────────────────────────────────────────────────────────────
- const [isResetOpen, setIsResetOpen] = useState(false);
- const [isImportOpen, setIsImportOpen] = useState(false);
- const [isFullscreen, setIsFullscreen] = useState(false);
- const [shortcutsOpen, setShortcutsOpen] = useState(false);
- const [exportOpen, setExportOpen] = useState(false);
- const [paletteOpen, setPaletteOpen] = useState(false);
- const [navVisible, setNavVisible] = useState(true);
- const [rightVisible, setRightVisible] = useState(true);
- const [rightW, setRightW] = useState(300);
- const [isDesktop, setIsDesktop] = useState(() => typeof window !== 'undefined' && window.innerWidth >= 1024);
-
- const importBtnRef = useRef(null);
- const exportBtnRef = useRef(null);
- const toggleFullscreen = useCallback(() => setIsFullscreen(v => !v), []);
-
- // Stable refs for keyboard shortcuts
- const selectedIdsRef = useRef(selectedIds);
- const selectedLogoRef = useRef(selectedLogo);
- const selectedMinimalElementsRef = useRef(selectedMinimalElements);
- const configRatingsRef = useRef(config.ratings);
- useEffect(() => { selectedIdsRef.current = selectedIds; });
- useEffect(() => { selectedLogoRef.current = selectedLogo; });
- useEffect(() => { selectedMinimalElementsRef.current = selectedMinimalElements; });
- useEffect(() => { configRatingsRef.current = config.ratings; });
-
- useEffect(() => {
- const mq = window.matchMedia('(min-width: 1024px)');
- const h = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
- mq.addEventListener('change', h);
- return () => mq.removeEventListener('change', h);
- }, []);
-
- // ── Context menu ────────────────────────────────────────────────────────
- const [ctxMenu, setCtxMenu] = useState({ visible: false, x: 0, y: 0, badgeId: null });
- const openCtxMenu = useCallback((badgeId: LayerTargetId, e: React.MouseEvent) => {
- e.preventDefault();
- setCtxMenu({ visible: true, x: e.clientX, y: e.clientY, badgeId });
- }, []);
- const closeCtxMenu = useCallback(() => setCtxMenu(s => ({ ...s, visible: false })), []);
-
- // ── Layer helpers (same logic as main builder) ──────────────────────────
- const handleSelectionOverride = useCallback((id: RatingType, multi: boolean) => {
- handleSelection(id, multi);
- }, [handleSelection]);
-
- const moveLayer = useCallback((id: RatingType, dir: 'front' | 'forward' | 'back' | 'toback') => {
- setConfig(prev => {
- const arr = [...prev.ratings];
- const idx = arr.indexOf(id);
- if (idx === -1) return prev;
- arr.splice(idx, 1);
- if (dir === 'front') arr.push(id);
- else if (dir === 'forward') arr.splice(Math.min(idx + 1, arr.length), 0, id);
- else if (dir === 'back') arr.splice(Math.max(idx - 1, 0), 0, id);
- else arr.unshift(id);
- return { ...prev, ratings: arr };
- });
- }, [setConfig]);
-
- const hideBadge = useCallback((id: RatingType) => {
- setConfig(prev => ({ ...prev, ratings: prev.ratings.filter(r => r !== id) }));
- clearSelection();
- }, [setConfig, clearSelection]);
-
- const showAllBadges = useCallback(() => {
- setConfig(prev => ({
- ...prev,
- ratings: ALL_BADGES.map(b => b.id).filter(id => prev.ratings.includes(id) || !prev.ratings.includes(id)),
- }));
- }, [setConfig]);
-
- const resetBadge = useCallback((id: RatingType) => {
- setConfig(prev => { const ni = { ...prev.items }; delete ni[id]; return { ...prev, items: ni }; });
- }, [setConfig]);
-
- const deleteBadge = useCallback((id: RatingType) => {
- setConfig(prev => ({ ...prev, ratings: prev.ratings.filter(r => r !== id) }));
- clearSelection();
- }, [setConfig, clearSelection]);
-
- const moveLogoLayer = useCallback((dir: 'front' | 'forward' | 'back' | 'toback') => {
- setConfig(prev => {
- const c = prev.logoZ ?? 90;
- if (dir === 'front') return { ...prev, logoZ: 220 };
- if (dir === 'toback') return { ...prev, logoZ: 1 };
- if (dir === 'forward') return { ...prev, logoZ: Math.min(220, c + 1) };
- return { ...prev, logoZ: Math.max(1, c - 1) };
- });
- }, [setConfig]);
-
- const hideLayer = useCallback((id: LayerTargetId) => {
- if (id === 'logo') { setConfig(prev => ({ ...prev, logo: false })); clearSelection(); return; }
- hideBadge(id);
- }, [setConfig, clearSelection, hideBadge]);
-
- const resetLayer = useCallback((id: LayerTargetId) => {
- if (id === 'logo') {
- setConfig(prev => ({
- ...prev,
- logoX: DEFAULT_CONFIG.logoX, logoY: DEFAULT_CONFIG.logoY,
- logoW: DEFAULT_CONFIG.logoW, logoH: DEFAULT_CONFIG.logoH,
- logoOpacity: DEFAULT_CONFIG.logoOpacity, logoZ: DEFAULT_CONFIG.logoZ,
- logoShadow: DEFAULT_CONFIG.logoShadow, logoBgEnabled: DEFAULT_CONFIG.logoBgEnabled,
- logoBgColor: DEFAULT_CONFIG.logoBgColor, logoBgOpacity: DEFAULT_CONFIG.logoBgOpacity,
- logoBgRadius: DEFAULT_CONFIG.logoBgRadius, logoBgPadding: DEFAULT_CONFIG.logoBgPadding,
- logoBgBorderW: DEFAULT_CONFIG.logoBgBorderW, logoBgBorderC: DEFAULT_CONFIG.logoBgBorderC,
- logoBgShadow: DEFAULT_CONFIG.logoBgShadow,
- }));
- return;
- }
- resetBadge(id);
- }, [setConfig, resetBadge]);
-
- const deleteLayer = useCallback((id: LayerTargetId) => {
- if (id === 'logo') { setConfig(prev => ({ ...prev, logo: false })); clearSelection(); return; }
- deleteBadge(id);
- }, [setConfig, clearSelection, deleteBadge]);
-
- // ── Nudge ────────────────────────────────────────────────────────────────
- const nudgeSelection = useCallback((dx: number, dy: number) => {
- const activeBadges = Array.from(selectedIdsRef.current);
- const hasLogo = selectedLogoRef.current;
- if (activeBadges.length === 0 && !hasLogo) return;
- setConfig(prev => {
- const next: PosterConfig = { ...prev, items: { ...prev.items } };
- if (activeBadges.length > 0) {
- activeBadges.forEach(id => {
- const base = next.items[id] ?? {};
- const idx = next.ratings.indexOf(id);
- const auto = calculateAutoPosition(id, Math.max(0, idx), next.ratings.length, next);
- const currX = base.x ?? auto.x, currY = base.y ?? auto.y;
- const scale = getScale(next.size) * (base.scale ?? next.scale ?? 1.0);
- const w = BASE_BADGE_W * scale, h = BASE_BADGE_H * scale;
- next.items[id] = {
- ...base,
- x: Math.max(1 - w, Math.min(currX + dx, CANVAS_WIDTH - 1)),
- y: Math.max(1 - h, Math.min(currY + dy, CANVAS_HEIGHT - 1)),
- };
- });
- next.layout = 'custom'; next.preset = 'custom';
- }
- if (hasLogo) {
- const cx = next.logoX !== null && next.logoX !== undefined
- ? next.logoX : Math.round((CANVAS_WIDTH - next.logoW) / 2);
- next.logoX = Math.max(1 - next.logoW, Math.min(cx + dx, CANVAS_WIDTH - 1));
- next.logoY = Math.max(1 - next.logoH, Math.min(next.logoY + dy, CANVAS_HEIGHT - 1));
- }
- return next;
- });
- }, [setConfig]);
-
- // ── Zoom helpers ────────────────────────────────────────────────────────
- const dispatchZoom = useCallback((delta: number) => window.dispatchEvent(new CustomEvent('canvas-zoom', { detail: delta })), []);
- const dispatchResetView = useCallback(() => window.dispatchEvent(new CustomEvent('reset-canvas-view')), []);
-
- // ── Right panel resize ──────────────────────────────────────────────────
- const startResizeRight = useCallback((e: React.MouseEvent) => {
- e.preventDefault();
- const sx = e.clientX, sw = rightW;
- const move = (m: MouseEvent) => setRightW(Math.max(260, Math.min(sw - (m.clientX - sx), 540)));
- const up = () => {
- document.removeEventListener('mousemove', move);
- document.removeEventListener('mouseup', up);
- document.body.style.cursor = '';
- };
- document.addEventListener('mousemove', move);
- document.addEventListener('mouseup', up);
- document.body.style.cursor = 'col-resize';
- }, [rightW]);
-
- const handleExtensionChange = useCallback((ext: ExtensionType) => {
- setConfig(prev => ({ ...prev, extension: ext }));
- }, [setConfig]);
-
- // ── Keyboard shortcuts ──────────────────────────────────────────────────
- useEffect(() => {
- const onKey = (e: KeyboardEvent) => {
- const t = e.target as HTMLElement;
- const inInput = t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable;
- const mod = e.ctrlKey || e.metaKey;
-
- if (e.key === 'Escape') {
- if (shortcutsOpen) { setShortcutsOpen(false); return; }
- if (paletteOpen) { setPaletteOpen(false); return; }
- if (exportOpen) { setExportOpen(false); return; }
- if (isFullscreen) { setIsFullscreen(false); return; }
- if (selectedIdsRef.current.size > 0 || selectedLogoRef.current) { clearSelection(); return; }
- return;
- }
- if (mod && (e.key.toLowerCase() === 'k' || e.key.toLowerCase() === 'p')) {
- e.preventDefault(); setPaletteOpen(v => !v); return;
- }
- if (mod && (e.key === '/' || e.key === '?')) {
- e.preventDefault(); setShortcutsOpen(v => !v); return;
- }
- if (inInput) return;
- if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key) &&
- (selectedIdsRef.current.size > 0 || selectedLogoRef.current || selectedMinimalElementsRef.current.size > 0)) {
- e.preventDefault();
- const step = e.shiftKey ? 10 : 1;
- if (e.key === 'ArrowUp') nudgeSelection(0, -step);
- else if (e.key === 'ArrowDown') nudgeSelection(0, step);
- else if (e.key === 'ArrowLeft') nudgeSelection(-step, 0);
- else nudgeSelection(step, 0);
- return;
- }
- if (mod && e.key.toLowerCase() === 'a') { e.preventDefault(); setBatchSelection(configRatingsRef.current); return; }
- if (mod && e.key.toLowerCase() === 'd') { e.preventDefault(); clearSelection(); return; }
- if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
- if (mod && (e.key.toLowerCase() === 'y' || (e.key.toLowerCase() === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
- if ((e.key === 'Delete' || e.key === 'Backspace') && (selectedIdsRef.current.size > 0 || selectedLogoRef.current)) {
- e.preventDefault();
- const rm = new Set(selectedIdsRef.current);
- if (rm.size > 0) setConfig(p => ({ ...p, ratings: p.ratings.filter(r => !rm.has(r)) }));
- clearSelection(); return;
- }
- if (selectedIdsRef.current.size > 0) {
- const sel = Array.from(selectedIdsRef.current);
- if (mod && e.shiftKey && e.key === ']') { e.preventDefault(); sel.forEach(id => moveLayer(id as RatingType, 'front')); return; }
- if (mod && e.shiftKey && e.key === '[') { e.preventDefault(); sel.forEach(id => moveLayer(id as RatingType, 'toback')); return; }
- if (mod && e.key === ']') { e.preventDefault(); sel.forEach(id => moveLayer(id as RatingType, 'forward')); return; }
- if (mod && e.key === '[') { e.preventDefault(); sel.forEach(id => moveLayer(id as RatingType, 'back')); return; }
- if (e.key.toLowerCase() === 'h' && !mod) { e.preventDefault(); sel.forEach(id => hideBadge(id as RatingType)); return; }
- }
- if (e.key.toLowerCase() === 'f' && !mod && isDesktop) { e.preventDefault(); setIsFullscreen(v => !v); return; }
- if (e.key.toLowerCase() === 'g' && !mod) { e.preventDefault(); toggleViewOption('showGrid'); return; }
- if (e.key === "'" && !mod) { e.preventDefault(); toggleViewOption('showSafeArea'); return; }
- if (mod && e.key === '1') { e.preventDefault(); dispatchResetView(); return; }
- if (mod && (e.key === '+' || e.key === '=')) { e.preventDefault(); dispatchZoom(0.25); return; }
- if (mod && e.key === '-') { e.preventDefault(); dispatchZoom(-0.25); return; }
- if (e.key === ']' && !mod && !e.shiftKey) { e.preventDefault(); setRightVisible(v => !v); return; }
- if (e.key === 'Tab' && !mod) {
- const ratings = configRatingsRef.current;
- if (!ratings.length) return;
- e.preventDefault();
- const selArr = Array.from(selectedIdsRef.current);
- const lastSel = selArr[selArr.length - 1];
- const idx = lastSel ? ratings.indexOf(lastSel) : -1;
- const next = ratings[(idx + (e.shiftKey ? -1 + ratings.length : 1)) % ratings.length];
- setBatchSelection([next]);
- return;
- }
- };
- window.addEventListener('keydown', onKey);
- return () => window.removeEventListener('keydown', onKey);
- }, [
- undo, redo, setConfig, clearSelection, setBatchSelection,
- moveLayer, hideBadge, toggleViewOption, dispatchZoom, dispatchResetView, nudgeSelection,
- isFullscreen, paletteOpen, shortcutsOpen, exportOpen,
- selectedIds, selectedLogo, selectedMinimalElements, isDesktop,
- ]);
-
- // ── Command palette commands ────────────────────────────────────────────
- const paletteCommands: PaletteCommand[] = [
- // Panel switching
- { id: 'panel-source', label: 'Open Source Panel', category: 'Panels', icon: , action: () => setActiveTab('source') },
- { id: 'panel-layers', label: 'Open Layers Panel', category: 'Panels', icon: , action: () => setActiveTab('layers') },
- { id: 'panel-canvas', label: 'Open Canvas Panel', category: 'Panels', icon: , action: () => setActiveTab('poster') },
- { id: 'panel-badges', label: 'Open Badges Panel', category: 'Panels', icon: , action: () => setActiveTab('badges') },
- { id: 'panel-selection', label: 'Open Selection Panel', category: 'Panels', icon: , action: () => setActiveTab('selection') },
- // View
- { id: 'zoom-fit', label: 'Zoom to Fit', category: 'View & Canvas', icon: , shortcut: '⌘1', action: dispatchResetView },
- { id: 'zoom-in', label: 'Zoom In', category: 'View & Canvas', icon: , shortcut: '⌘+', action: () => dispatchZoom(0.25) },
- { id: 'zoom-out', label: 'Zoom Out', category: 'View & Canvas', icon: , shortcut: '⌘-', action: () => dispatchZoom(-0.25) },
- { id: 'fullscreen', label: isFullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen', category: 'View & Canvas', icon: isFullscreen ? : , shortcut: 'F', action: toggleFullscreen },
- { id: 'grid', label: `${viewOptions.showGrid ? 'Hide' : 'Show'} Grid`, category: 'View & Canvas', icon: , shortcut: 'G', action: () => toggleViewOption('showGrid') },
- { id: 'safe-area', label: `${viewOptions.showSafeArea ? 'Hide' : 'Show'} Safe Area`, category: 'View & Canvas', icon: , action: () => toggleViewOption('showSafeArea') },
- { id: 'right-panel', label: `${rightVisible ? 'Hide' : 'Show'} Right Panel`, category: 'View & Canvas', icon: , shortcut: ']', action: () => setRightVisible(v => !v) },
- // Selection
- { id: 'select-all', label: 'Select All Badges', category: 'Layers & Selection', icon: , shortcut: '⌘A', action: () => setBatchSelection(config.ratings) },
- { id: 'deselect-all', label: 'Deselect All', category: 'Layers & Selection', icon: , shortcut: '⌘D', action: clearSelection },
- { id: 'show-all', label: 'Show All Badges', category: 'Layers & Selection', icon: , action: showAllBadges },
- { id: 'hide-sel', label: 'Hide Selected', category: 'Layers & Selection', icon: , shortcut: 'H', action: () => Array.from(selectedIds).forEach(id => hideBadge(id as RatingType)) },
- // Canvas
- { id: 'grayscale', label: `${config.grayscale ? 'Remove' : 'Apply'} Grayscale`, category: 'Canvas Properties', icon: , action: () => setConfig(p => ({ ...p, grayscale: !p.grayscale })) },
- // Export
- { id: 'export-svg', label: 'Export as SVG', category: 'Export', icon: , action: () => { setConfig(p => ({ ...p, extension: 'svg' })); setExportOpen(true); } },
- { id: 'export-png', label: 'Export as PNG', category: 'Export', icon: , action: () => { setConfig(p => ({ ...p, extension: 'png' })); setExportOpen(true); } },
- { id: 'export-jpg', label: 'Export as JPG', category: 'Export', icon: , action: () => { setConfig(p => ({ ...p, extension: 'jpg' })); setExportOpen(true); } },
- // History
- { id: 'undo', label: 'Undo', category: 'File', icon: , shortcut: '⌘Z', action: undo },
- { id: 'redo', label: 'Redo', category: 'File', icon: , shortcut: '⌘Y', action: redo },
- { id: 'reset', label: 'Reset All Settings', category: 'File', icon: , action: () => setIsResetOpen(true) },
- // App
- { id: 'shortcuts', label: 'Keyboard Shortcuts', category: 'File', icon: , shortcut: '⌘/', action: () => setShortcutsOpen(true) },
- ];
-
- const ctxBadgeSelected = ctxMenu.badgeId
- ? ctxMenu.badgeId === 'logo' ? selectedLogo : selectedIds.has(ctxMenu.badgeId)
- : false;
-
- const selectedCount = selectedIds.size + (selectedLogo ? 1 : 0);
-
- // ── Render ────────────────────────────────────────────────────────────────
- return (
- <>
-
-
-
-
- {/* ── Modals & overlays ── */}
-
setIsResetOpen(false)} onConfirm={handleReset} />
- setIsImportOpen(false)} onLoad={handleLoadConfig} anchorRef={importBtnRef} />
- setShortcutsOpen(false)} />
- id === 'logo' ? moveLogoLayer('front') : moveLayer(id, 'front')}
- onBringForward={id => id === 'logo' ? moveLogoLayer('forward') : moveLayer(id, 'forward')}
- onSendBackward={id => id === 'logo' ? moveLogoLayer('back') : moveLayer(id, 'back')}
- onSendToBack={id => id === 'logo' ? moveLogoLayer('toback') : moveLayer(id, 'toback')}
- onHide={hideLayer} onShowAll={showAllBadges}
- onSelect={id => id === 'logo' ? handleLogoSelection(false) : handleSelectionOverride(id, false)}
- onDeselect={() => clearSelection()}
- onSelectAll={() => setBatchSelection(config.ratings)}
- onDeselectAll={clearSelection}
- onResetBadge={resetLayer}
- onDelete={deleteLayer}
- />
- setPaletteOpen(false)} commands={paletteCommands} />
- setExportOpen(false)}
- anchorRef={exportBtnRef}
- />
-
- {/* ── Header ────────────────────────────────────────────────────── */}
- {!isFullscreen && (
-
-
-
- {/* Left: logo + badges */}
-
-
-
- POSTERIUM
-
-
- P
-
-
- {/* "Advanced" badge */}
-
- Advanced
-
- {/* Switch to standard */}
-
- Standard
-
-
setShortcutsOpen(v => !v)} label="Keyboard Shortcuts (⌘/)" active={shortcutsOpen} hideOnMobile>
-
-
-
-
- {/* Centre: command palette trigger */}
-
- setPaletteOpen(true)}
- className="hidden min-[751px]:flex items-center gap-2 px-3 h-8 w-full max-w-[420px] rounded-md transition-colors pointer-events-auto"
- style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)', color: 'var(--film-text-dim)' }}
- onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = 'rgba(196,124,46,0.3)'; }}
- onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = 'rgba(255,255,255,0.08)'; }}>
-
- Search commands…
- ⌘K
-
-
-
- {/* Right: actions */}
-
-
-
-
-
-
setIsImportOpen(true)}
- className="hidden sm:flex items-center gap-1.5 h-8 px-2.5 rounded-md transition-colors syne-font"
- style={{ color: 'var(--film-text-dim)' }}
- onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = 'rgba(255,255,255,0.05)'; (e.currentTarget as HTMLElement).style.color = 'var(--film-cream)'; }}
- onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = 'transparent'; (e.currentTarget as HTMLElement).style.color = 'var(--film-text-dim)'; }}>
-
- Import
-
-
- {/* Export CTA */}
-
setExportOpen(v => !v)}
- className="flex items-center gap-1.5 h-8 px-2 sm:px-3 rounded-lg ml-1 syne-font transition-all active:scale-95"
- style={{
- background: exportOpen ? 'rgba(196,124,46,0.9)' : 'var(--film-amber)',
- color: '#070706', fontSize: 11, fontWeight: 700, letterSpacing: '0.08em',
- textTransform: 'uppercase', border: 'none', cursor: 'pointer',
- boxShadow: exportOpen ? 'none' : '0 0 16px rgba(196,124,46,0.2)',
- }}>
-
- Export
-
-
-
-
-
-
setIsResetOpen(true)}
- className="flex items-center gap-1.5 h-8 px-2 sm:px-2.5 rounded-md transition-colors syne-font text-red-400/80 hover:text-red-300 hover:bg-red-500/10">
-
- Reset
-
-
-
- )}
-
- {/* ── Body ──────────────────────────────────────────────────────── */}
-
-
- {/* Left: Advanced nav sidebar (desktop only) */}
- {!isFullscreen && (
-
- )}
-
- {/* Canvas */}
-
{
- if (e.target === e.currentTarget) clearSelection();
- if (mobileSheetMode !== 'hidden') setMobileSheetMode('hidden');
- }}>
-
-
- openCtxMenu('logo', e)}
- />
-
- {/* Film corner accents */}
- {(['tl', 'tr', 'bl', 'br'] as const).map(c => (
-
- ))}
-
- {/* Zoom + fullscreen overlay */}
-
- {[
- { icon:
, label: 'Zoom In', action: () => dispatchZoom(0.25) },
- { icon:
, label: 'Zoom Out', action: () => dispatchZoom(-0.25) },
- { icon:
, label: 'Reset View', action: dispatchResetView },
- ].map(({ icon, label, action }) => (
-
{ (e.currentTarget as HTMLElement).style.color = 'var(--film-amber)'; (e.currentTarget as HTMLElement).style.background = 'rgba(196,124,46,0.1)'; }}
- onMouseLeave={e => { (e.currentTarget as HTMLElement).style.color = 'var(--film-text-dim)'; (e.currentTarget as HTMLElement).style.background = 'transparent'; }}>
- {icon}
-
- ))}
- {isDesktop && (
- <>
-
-
{ (e.currentTarget as HTMLElement).style.color = 'var(--film-amber)'; (e.currentTarget as HTMLElement).style.background = 'rgba(196,124,46,0.1)'; }}
- onMouseLeave={e => { (e.currentTarget as HTMLElement).style.color = isFullscreen ? 'rgba(196,124,46,0.7)' : 'var(--film-text-dim)'; (e.currentTarget as HTMLElement).style.background = 'transparent'; }}>
- {isFullscreen ? : }
-
- >
- )}
-
-
-
- {/* Right: panel content (desktop) */}
- {!isFullscreen && (
-
- {/* Resize handle */}
-
-
-
- )}
-
- {/* Mobile panel sheet */}
- {!isFullscreen && (
-
- {/* Swipe handle */}
-
setMobileSheetMode('hidden')}>
-
-
-
- {(activeTab === 'source' || activeTab === 'layers' || activeTab === 'poster') && (
-
- )}
- {(activeTab === 'badges' || activeTab === 'selection' || activeTab === 'logo') && (
-
- )}
-
-
- )}
-
-
- {/* Mobile dock */}
- 0}
- hasLogo={config.logo}
- isMinimalPreset={(config.uiPreset ?? 'b') === 'm'}
- selectedCount={selectedCount}
- />
-
- >
- );
-};
-
-// ── AdvancedBuilderApp ────────────────────────────────────────────────────────
-const AdvancedBuilderApp: React.FC = () => {
- const { state: config, setState: setConfig, undo, redo, canUndo, canRedo } = usePosterHistory(() => {
- try {
- const saved = localStorage.getItem(STORAGE_KEY);
- const cfg = saved ? (JSON.parse(saved) as PosterConfig) : DEFAULT_CONFIG;
- const cookieKeys = loadKeysFromCookie();
- if (cookieKeys && Object.keys(cookieKeys).some(k => cookieKeys[k as keyof ApiKeys])) {
- return { ...cfg, keys: { ...cookieKeys, ...cfg.keys } };
- }
- return cfg;
- } catch {
- return DEFAULT_CONFIG;
- }
- });
-
- const [baseUrl, setBaseUrl] = useState(DEFAULT_API_BASE);
-
- useEffect(() => {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
- }, [config]);
-
- useEffect(() => {
- if (config.keys) {
- const hasAnyKey = Object.values(config.keys).some(v => v && v.trim());
- if (hasAnyKey) saveKeysToCookie(config.keys);
- }
- }, [config.keys]);
-
- const handleLoadConfig = useCallback((url: string) => {
- setConfig(parseUrlToConfig(url));
- try { setBaseUrl(new URL(url).origin); } catch {}
- }, [setConfig]);
-
- const handleReset = useCallback(() => {
- setConfig(current => ({
- ...DEFAULT_CONFIG,
- mediaType: current.mediaType, tmdbId: current.tmdbId, imdbId: current.imdbId,
- source: current.source, ptype: current.ptype, textless: current.textless, keys: current.keys,
- }));
- window.dispatchEvent(new CustomEvent('reset-canvas-view'));
- }, [setConfig]);
-
- useEffect(() => {
- const params = new URLSearchParams(window.location.search);
- const urlParam = params.get('url');
- if (urlParam) { handleLoadConfig(urlParam); return; }
- const configParam = params.get('config');
- if (!configParam || configParam.length > MAX_QUERY_CONFIG_LENGTH) return;
- try {
- const decoded = atob(decodeURIComponent(configParam));
- const parsed = JSON.parse(decoded) as Partial;
- if (!parsed || !Array.isArray(parsed.ratings)) return;
- setConfig({ ...DEFAULT_CONFIG, ...parsed, items: parsed.items ?? {} } as PosterConfig);
- } catch {}
- }, [handleLoadConfig, setConfig]);
-
- return (
-
-
-
- );
-};
-
-export default AdvancedBuilderApp;
\ No newline at end of file
+import React from 'react';
+import BuilderApp from './index';
+
+const AdvancedBuilderApp: React.FC = () => ;
+
+export default AdvancedBuilderApp;
diff --git a/src/components/builder/components/CommandPalette.tsx b/src/components/builder/components/CommandPalette.tsx
index ed4d357..218d558 100644
--- a/src/components/builder/components/CommandPalette.tsx
+++ b/src/components/builder/components/CommandPalette.tsx
@@ -1,32 +1,6 @@
// src/components/builder/components/CommandPalette.tsx
import React, { useState, useEffect, useRef, useCallback, memo } from 'react';
-import {
- Search,
- ZoomIn,
- ZoomOut,
- Maximize2,
- Minimize2,
- Grid3x3,
- ShieldCheck,
- RotateCcw,
- Eye,
- EyeOff,
- Layers,
- CheckSquare,
- MousePointer2Off,
- Download,
- Image,
- ScanLine,
- Droplet,
- Contrast,
- Layout,
- PanelLeft,
- PanelRight,
- ArrowUpToLine,
- ArrowDownToLine,
- Command,
- X,
-} from 'lucide-react';
+import { Command, Search } from 'lucide-react';
export interface PaletteCommand {
id: string;
diff --git a/src/components/builder/components/LayerPanel.tsx b/src/components/builder/components/LayerPanel.tsx
index a47dc3c..f3a1ae1 100644
--- a/src/components/builder/components/LayerPanel.tsx
+++ b/src/components/builder/components/LayerPanel.tsx
@@ -1,14 +1,6 @@
// src/components/builder/components/LayerPanel.tsx
-import React, { useState, useEffect, Fragment, memo, useCallback, useRef } from 'react';
-import {
- Combobox,
- Listbox,
- ListboxButton,
- ListboxOptions,
- ListboxOption,
- Switch,
- Transition,
-} from '@headlessui/react';
+import React, { useState, useEffect, useCallback, useRef } from 'react';
+import { Combobox, Switch } from '@headlessui/react';
import {
Check,
Search,
@@ -21,10 +13,8 @@ import {
Clapperboard,
Eye,
EyeOff,
- ChevronDown,
ImagePlay,
KeyRound,
- ChevronRight,
Monitor,
ShieldCheck,
Grid3x3,
@@ -39,6 +29,8 @@ import { BADGE_ICONS } from '../constants';
import { DEFAULT_API_BASE } from '../utils';
import { useEditor } from '../context/EditorContext';
import SidebarLayout from './SidebarLayout';
+import PanelTabs from './navigation/PanelTabs';
+import { Section, SegmentedRow, SelectBox, SliderRow, ToggleRow } from './ui';
type BadgeIconKey = keyof typeof BADGE_ICONS;
@@ -47,6 +39,9 @@ interface Props {
setConfig: React.Dispatch>;
selectedIds: Set;
onSelect: (id: RatingType, multi: boolean) => void;
+ panelMode?: 'source' | 'layers' | 'poster';
+ hideTabs?: boolean;
+ detailLevel?: 'simple' | 'advanced';
}
interface SearchResult {
@@ -62,375 +57,18 @@ interface SearchResult {
const BADGES_PREF_STORAGE_KEY = 'posterium_badges_toggle_pref_v1';
const TEXTLESS_PREF_STORAGE_KEY = 'posterium_textless_toggle_pref_v1';
-const readBadgesPreference = (fallback: boolean): boolean => {
- try {
- const raw = localStorage.getItem(BADGES_PREF_STORAGE_KEY);
- if (raw === '1') return true;
- if (raw === '0') return false;
- } catch {}
- return fallback;
-};
-
const writeBadgesPreference = (enabled: boolean) => {
try {
localStorage.setItem(BADGES_PREF_STORAGE_KEY, enabled ? '1' : '0');
} catch {}
};
-const readTextlessPreference = (fallback: boolean): boolean => {
- try {
- const raw = localStorage.getItem(TEXTLESS_PREF_STORAGE_KEY);
- if (raw === '1') return true;
- if (raw === '0') return false;
- } catch {}
- return fallback;
-};
-
const writeTextlessPreference = (enabled: boolean) => {
try {
localStorage.setItem(TEXTLESS_PREF_STORAGE_KEY, enabled ? '1' : '0');
} catch {}
};
-// ── SelectBox ────────────────────────────────────────────────────────────────
-const SelectBox = memo(
- ({
- value,
- onChange,
- options,
- }: {
- value: string;
- onChange: (v: string) => void;
- options: { id: string; label: string }[];
- }) => (
-
-
- {
- (e.currentTarget as HTMLElement).style.borderColor = 'rgba(196,124,46,0.4)';
- }}
- onMouseLeave={(e) => {
- (e.currentTarget as HTMLElement).style.borderColor = 'rgba(255,255,255,0.1)';
- }}
- >
- {options.find((o) => o.id === value)?.label ?? value}
-
-
-
- {options.map((opt) => (
-
- clsx(
- 'flex items-center gap-2 px-3 py-2.5 cursor-pointer transition-colors syne-font',
- active && 'bg-[rgba(196,124,46,0.1)]',
- !active && selected && 'text-[var(--film-pale)]',
- !active && !selected && 'text-[var(--film-text-label)]'
- )
- }
- >
- {({ selected }) => (
- <>
- {opt.label}
- {selected && (
-
- )}
- >
- )}
-
- ))}
-
-
-
- )
-);
-SelectBox.displayName = 'SelectBox';
-
-// ── SliderRow ────────────────────────────────────────────────────────────────
-// Synced from PropertyPanel for visual/functional parity
-const SliderRow: React.FC<{
- label: string;
- value: number;
- onChange: (v: number) => void;
- min: number;
- max: number;
- step?: number;
- unit?: string;
- formatValue?: (v: number) => string;
-}> = ({ label, value, onChange, min, max, step = 1, unit = '', formatValue }) => {
- const [localValue, setLocalValue] = useState(value);
- const [inputText, setInputText] = useState(() => (formatValue ? formatValue(value) : `${value}`));
- const lastUpdate = useRef(Date.now());
- const timeoutRef = useRef(null);
- const inputRef = useRef(null);
- const isFocused = useRef(false);
-
- useEffect(() => {
- setLocalValue(value);
- if (!isFocused.current) {
- setInputText(formatValue ? formatValue(value) : `${value}`);
- }
- }, [value, formatValue]);
-
- const commitInput = useCallback(
- (text: string) => {
- const raw = text.replace(unit, '').replace(/[^0-9.\-]/g, '');
- const n = parseFloat(raw);
- if (!isNaN(n)) {
- const clamped = Math.max(min, Math.min(max, n));
- setLocalValue(clamped);
- setInputText(formatValue ? formatValue(clamped) : `${clamped}`);
- onChange(clamped);
- } else {
- setInputText(formatValue ? formatValue(localValue) : `${localValue}`);
- }
- },
- [min, max, onChange, unit, formatValue, localValue]
- );
-
- const handleRangeChange = useCallback(
- (e: React.ChangeEvent) => {
- const val = parseFloat(e.target.value);
- setLocalValue(val);
- if (!isFocused.current) {
- setInputText(formatValue ? formatValue(val) : `${val}`);
- }
- const now = Date.now();
- if (now - lastUpdate.current > 33) {
- onChange(val);
- lastUpdate.current = now;
- } else {
- if (timeoutRef.current) clearTimeout(timeoutRef.current);
- timeoutRef.current = setTimeout(() => {
- onChange(val);
- lastUpdate.current = Date.now();
- }, 33);
- }
- },
- [onChange, formatValue]
- );
-
- const handleInputKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === 'Enter') {
- commitInput(inputText);
- inputRef.current?.blur();
- } else if (e.key === 'Escape') {
- setInputText(formatValue ? formatValue(localValue) : `${localValue}`);
- inputRef.current?.blur();
- } else if (e.key === 'ArrowUp') {
- e.preventDefault();
- const newVal = Math.max(min, Math.min(max, localValue + step));
- setLocalValue(newVal);
- setInputText(formatValue ? formatValue(newVal) : `${newVal}`);
- onChange(newVal);
- } else if (e.key === 'ArrowDown') {
- e.preventDefault();
- const newVal = Math.max(min, Math.min(max, localValue - step));
- setLocalValue(newVal);
- setInputText(formatValue ? formatValue(newVal) : `${newVal}`);
- onChange(newVal);
- }
- };
-
- return (
-
-
- {label}
-
-
- setInputText(e.target.value)}
- onFocus={() => {
- isFocused.current = true;
- }}
- onBlur={() => {
- isFocused.current = false;
- commitInput(inputText);
- }}
- onKeyDown={handleInputKeyDown}
- className="mono-font tabular-nums focus:outline-none shrink-0"
- style={{
- width: 48,
- height: 22,
- paddingInline: 5,
- borderRadius: 4,
- background: 'rgba(255,255,255,0.04)',
- border: '1px solid rgba(255,255,255,0.1)',
- fontSize: 10,
- color: 'var(--film-pale)',
- textAlign: 'center',
- transition: 'border-color 0.15s',
- }}
- onMouseEnter={(e) => {
- (e.currentTarget as HTMLInputElement).style.borderColor = 'rgba(196,124,46,0.4)';
- }}
- onMouseLeave={(e) => {
- if (!isFocused.current)
- (e.currentTarget as HTMLInputElement).style.borderColor = 'rgba(255,255,255,0.1)';
- }}
- />
-
-
-
- );
-};
-
-// ── ToggleRow ────────────────────────────────────────────────────────────────
-const ToggleRow: React.FC<{
- label: string;
- sub?: string;
- checked: boolean;
- onChange: (v: boolean) => void;
- small?: boolean;
- disabled?: boolean;
-}> = ({ label, sub, checked, onChange, small, disabled }) => (
-
-
-
- {label}
-
- {sub && (
-
- {sub}
-
- )}
-
-
-
-
-
-);
-
-const SegmentedRow: React.FC<{
- label: string;
- options: { id: string; label: string }[];
- value: string;
- onChange: (v: string) => void;
-}> = ({ label, options, value, onChange }) => (
-
-
- {label}
-
-
- {options.map((opt) => (
- onChange(opt.id)}
- className={clsx(
- 'h-8 rounded-md text-[10px] font-medium transition-all border syne-font',
- value === opt.id
- ? 'bg-[rgba(196,124,46,0.15)] text-[var(--film-pale)] border-[rgba(196,124,46,0.3)]'
- : 'bg-[rgba(255,255,255,0.03)] text-[var(--film-text-label)] border-[rgba(255,255,255,0.05)] hover:bg-[rgba(255,255,255,0.07)] hover:border-[rgba(196,124,46,0.24)] hover:text-[var(--film-cream)]'
- )}
- >
- {opt.label}
-
- ))}
-
-
-);
-
-// ── Section — flat collapsible matching PropertyPanel ─────────────────────────
-const Section: React.FC<{
- title: string;
- icon?: React.ReactNode;
- children: React.ReactNode;
- defaultOpen?: boolean;
-}> = ({ title, icon, children, defaultOpen = false }) => {
- const [open, setOpen] = useState(defaultOpen);
- return (
-
-
setOpen((v) => !v)}
- className="w-full flex items-center justify-between px-1 mb-3 focus:outline-none group"
- >
-
- {icon && (
- {icon}
- )}
- {title}
-
-
- {open ? : }
-
-
- {open &&
{children}
}
-
-
- );
-};
-
// ── API Keys panel ────────────────────────────────────────────────────────────
const ApiKeysPanel: React.FC<{
config: PosterConfig;
@@ -529,7 +167,15 @@ const ApiKeysPanel: React.FC<{
};
// ── Main LayerPanel component ─────────────────────────────────────────────────
-const LayerPanel: React.FC = ({ config, setConfig, selectedIds, onSelect }) => {
+const LayerPanel: React.FC = ({
+ config,
+ setConfig,
+ selectedIds,
+ onSelect,
+ panelMode,
+ hideTabs = false,
+ detailLevel = 'advanced',
+}) => {
const {
setBatchSelection,
activeTab,
@@ -545,13 +191,19 @@ const LayerPanel: React.FC = ({ config, setConfig, selectedIds, onSelect
toggleViewOption,
} = useEditor();
- const [localMode, setLocalMode] = useState<'source' | 'layers' | 'poster'>('source');
+ const isAdvanced = detailLevel === 'advanced';
+
+ const [localMode, setLocalMode] = useState<'source' | 'layers' | 'poster'>(panelMode ?? 'source');
const [inactiveOrder, setInactiveOrder] = useState([]);
useEffect(() => {
+ if (panelMode) {
+ setLocalMode(panelMode);
+ return;
+ }
if (activeTab === 'source' || activeTab === 'poster' || activeTab === 'layers')
setLocalMode(activeTab);
- }, [activeTab]);
+ }, [activeTab, panelMode]);
const [searchQuery, setSearchQuery] = useState('');
const [results, setResults] = useState([]);
@@ -732,8 +384,11 @@ const LayerPanel: React.FC = ({ config, setConfig, selectedIds, onSelect
if (prev.ratings.includes(id)) return prev;
if (id !== 'title' && id !== 'year') return { ...prev, ratings: [id, ...prev.ratings] };
const nextItems = { ...prev.items, [id]: { ...(prev.items[id] ?? {}) } };
- delete nextItems[id].x;
- delete nextItems[id].y;
+ const titleItem = nextItems[id];
+ if (titleItem) {
+ delete titleItem.x;
+ delete titleItem.y;
+ }
return { ...prev, ratings: [id, ...prev.ratings], items: nextItems };
});
setInactiveOrder((prev) => prev.filter((x) => x !== id));
@@ -819,10 +474,7 @@ const LayerPanel: React.FC = ({ config, setConfig, selectedIds, onSelect
setConfig((prev) => ({
...prev,
ratings: [...badgeTopToBottom].reverse(),
- logoZ:
- logoIndex === -1
- ? prev.logoZ
- : 100 + (ordered.length - logoIndex - 1),
+ logoZ: logoIndex === -1 ? prev.logoZ : 100 + (ordered.length - logoIndex - 1),
}));
} else if (
result.source.droppableId === 'inactive' &&
@@ -1045,83 +697,83 @@ const LayerPanel: React.FC = ({ config, setConfig, selectedIds, onSelect
setActiveTab('selection');
};
return (
- {
- if (isActive) handleLogoSelection(e.shiftKey || e.ctrlKey || e.metaKey);
- else enableLogoAndFocus();
- }}
- className={clsx(
- 'flex items-center gap-2 px-2 py-2 rounded-lg transition-all select-none',
- selectedLogo && isActive
- ? 'bg-[rgba(196,124,46,0.08)] ring-1 ring-[rgba(196,124,46,0.2)]'
+
{
+ if (isActive) handleLogoSelection(e.shiftKey || e.ctrlKey || e.metaKey);
+ else enableLogoAndFocus();
+ }}
+ className={clsx(
+ 'flex items-center gap-2 px-2 py-2 rounded-lg transition-all select-none',
+ selectedLogo && isActive
+ ? 'bg-[rgba(196,124,46,0.08)] ring-1 ring-[rgba(196,124,46,0.2)]'
: isActive
? 'hover:bg-[rgba(196,124,46,0.06)] cursor-pointer'
: 'opacity-50',
- isDraggingItem && 'shadow-2xl rotate-[0.5deg]'
- )}
- style={
- isDraggingItem
- ? { background: 'var(--film-mid)', ...(provided?.draggableProps.style ?? {}) }
- : (provided?.draggableProps.style ?? {})
- }
- >
- {isActive ? (
+ isDraggingItem && 'shadow-2xl rotate-[0.5deg]'
+ )}
+ style={
+ isDraggingItem
+ ? { background: 'var(--film-mid)', ...(provided?.draggableProps.style ?? {}) }
+ : (provided?.draggableProps.style ?? {})
+ }
+ >
+ {isActive ? (
+
e.stopPropagation()}
+ className="p-0.5 outline-none transition-colors shrink-0"
+ style={{ color: 'var(--film-text-dim)', cursor: 'grab' }}
+ >
+
+
+ ) : (
+
+ )}
e.stopPropagation()}
- className="p-0.5 outline-none transition-colors shrink-0"
- style={{ color: 'var(--film-text-dim)', cursor: 'grab' }}
+ className="shrink-0"
+ onClick={(e) => {
+ e.stopPropagation();
+ if (!isActive) enableLogoAndFocus();
+ else handleLogoSelection(false);
+ }}
>
-
+
+ {selectedLogo && isActive &&
}
+
- ) : (
-
- )}
-
{
- e.stopPropagation();
- if (!isActive) enableLogoAndFocus();
- else handleLogoSelection(false);
- }}
- >
- {selectedLogo && isActive &&
}
+
+
+
+
+ Logo
+
+
+
e.stopPropagation()} className="shrink-0">
+ (config.logo ? updateConfig('logo', false) : enableLogoAndFocus())}
+ className="w-7 h-7 rounded-md flex items-center justify-center transition-colors"
+ style={{ color: config.logo ? 'var(--film-text-dim)' : 'rgba(110,110,120,0.7)' }}
+ title={config.logo ? 'Hide layer' : 'Show layer'}
+ >
+ {config.logo ? : }
+
-
-
-
-
-
- Logo
-
-
-
e.stopPropagation()} className="shrink-0">
- (config.logo ? updateConfig('logo', false) : enableLogoAndFocus())}
- className="w-7 h-7 rounded-md flex items-center justify-center transition-colors"
- style={{ color: config.logo ? 'var(--film-text-dim)' : 'rgba(110,110,120,0.7)' }}
- title={config.logo ? 'Hide layer' : 'Show layer'}
- >
- {config.logo ? : }
-
-
-
);
};
@@ -1130,37 +782,18 @@ const LayerPanel: React.FC
= ({ config, setConfig, selectedIds, onSelect
side="left"
bodyClassName="px-2 pt-2 pb-8"
header={
-
- {([
- { id: 'source', label: 'Source', icon: },
- { id: 'layers', label: 'Layers', icon: },
- { id: 'poster', label: 'Poster', icon: },
- ] as const).map((tab) => (
- setActiveTab(tab.id)}
- className={clsx(
- 'flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all duration-150 outline-none select-none capitalize syne-font',
- localMode !== tab.id &&
- 'hover:bg-[rgba(196,124,46,0.08)] hover:text-[var(--film-text-label)]'
- )}
- style={{
- background: localMode === tab.id ? 'var(--film-mid)' : 'transparent',
- color: localMode === tab.id ? 'var(--film-cream)' : 'var(--film-text-dim)',
- boxShadow: localMode === tab.id ? '0 1px 4px rgba(0,0,0,0.3)' : 'none',
- }}
- >
- {tab.icon}
- {tab.label}
-
- ))}
-
+ hideTabs ? null : (
+ setActiveTab(id)}
+ tabs={[
+ { id: 'source', label: 'Source', icon: },
+ { id: 'layers', label: 'Layers', icon: },
+ { id: 'poster', label: 'Poster', icon: },
+ ]}
+ />
+ )
}
>
{/* ── Source Tab ──────────────────────────────────────────────────────── */}
@@ -1501,7 +1134,10 @@ const LayerPanel: React.FC = ({ config, setConfig, selectedIds, onSelect
>
Badges
-
+
Show/hide all layers with badge behavior
@@ -1574,7 +1210,12 @@ const LayerPanel: React.FC = ({ config, setConfig, selectedIds, onSelect
updateConfig('logoSource', v === 'auto' ? null : v)}
+ onChange={(v) =>
+ updateConfig(
+ 'logoSource',
+ v === 'auto' ? null : (v as PosterConfig['logoSource'])
+ )
+ }
options={logoSourceOptions}
/>
@@ -1585,10 +1226,16 @@ const LayerPanel: React.FC = ({ config, setConfig, selectedIds, onSelect
/>
- {/* API Keys — Section */}
- }>
-
-
+ {isAdvanced && (
+ }
+ defaultOpen={false}
+ >
+
+
+ )}
)}
@@ -1610,7 +1257,7 @@ const LayerPanel: React.FC = ({ config, setConfig, selectedIds, onSelect
- } defaultOpen>
+ } defaultOpen>
= ({ config, setConfig, selectedIds, onSelect
/>
- } defaultOpen>
-
- toggleViewOption('showSafeArea')}
- className={clsx(
- 'h-8 rounded-lg text-[11px] font-medium flex items-center justify-center gap-1.5 transition-all active:scale-95 syne-font',
- viewOptions.showSafeArea
- ? 'bg-[rgba(196,124,46,0.1)] text-[var(--film-pale)] border border-[rgba(196,124,46,0.22)]'
- : 'bg-[rgba(255,255,255,0.02)] text-[var(--film-text-dim)] border border-[rgba(255,255,255,0.05)]'
- )}
- >
-
- Safe Area
-
- toggleViewOption('showGrid')}
- className={clsx(
- 'h-8 rounded-lg text-[11px] font-medium flex items-center justify-center gap-1.5 transition-all active:scale-95 syne-font',
- viewOptions.showGrid
- ? 'bg-[rgba(196,124,46,0.1)] text-[var(--film-pale)] border border-[rgba(196,124,46,0.22)]'
- : 'bg-[rgba(255,255,255,0.02)] text-[var(--film-text-dim)] border border-[rgba(255,255,255,0.05)]'
- )}
- >
-
- Grid
-
- toggleViewOption('snapToGrid')}
- className={clsx(
- 'h-8 rounded-lg text-[11px] font-medium col-span-2 flex items-center justify-center gap-1.5 transition-all active:scale-95 syne-font',
- viewOptions.snapToGrid
- ? 'bg-[rgba(196,124,46,0.1)] text-[var(--film-pale)] border border-[rgba(196,124,46,0.22)]'
- : 'bg-[rgba(255,255,255,0.02)] text-[var(--film-text-dim)] border border-[rgba(255,255,255,0.05)]'
- )}
- >
-
- Snap to Grid
-
-
-
-
+ {isAdvanced && (
+ }
+ defaultOpen
+ >
+
+ toggleViewOption('showSafeArea')}
+ className={clsx(
+ 'h-8 rounded-lg text-[11px] font-medium flex items-center justify-center gap-1.5 transition-all active:scale-95 syne-font',
+ viewOptions.showSafeArea
+ ? 'bg-[rgba(196,124,46,0.1)] text-[var(--film-pale)] border border-[rgba(196,124,46,0.22)]'
+ : 'bg-[rgba(255,255,255,0.02)] text-[var(--film-text-dim)] border border-[rgba(255,255,255,0.05)]'
+ )}
+ >
+
+ Safe Area
+
+ toggleViewOption('showGrid')}
+ className={clsx(
+ 'h-8 rounded-lg text-[11px] font-medium flex items-center justify-center gap-1.5 transition-all active:scale-95 syne-font',
+ viewOptions.showGrid
+ ? 'bg-[rgba(196,124,46,0.1)] text-[var(--film-pale)] border border-[rgba(196,124,46,0.22)]'
+ : 'bg-[rgba(255,255,255,0.02)] text-[var(--film-text-dim)] border border-[rgba(255,255,255,0.05)]'
+ )}
+ >
+
+ Grid
+
+ toggleViewOption('snapToGrid')}
+ className={clsx(
+ 'h-8 rounded-lg text-[11px] font-medium col-span-2 flex items-center justify-center gap-1.5 transition-all active:scale-95 syne-font',
+ viewOptions.snapToGrid
+ ? 'bg-[rgba(196,124,46,0.1)] text-[var(--film-pale)] border border-[rgba(196,124,46,0.22)]'
+ : 'bg-[rgba(255,255,255,0.02)] text-[var(--film-text-dim)] border border-[rgba(255,255,255,0.05)]'
+ )}
+ >
+
+ Snap to Grid
+
+
+
+ )}
)}
@@ -1678,183 +1331,177 @@ const LayerPanel: React.FC = ({ config, setConfig, selectedIds, onSelect
{localMode === 'layers' && (
<>
-
-
+
+ Badges
+
+
+
{
+ (e.currentTarget as HTMLElement).style.color = 'var(--film-text-label)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLElement).style.color = 'var(--film-text-dim)';
+ }}
>
- Badges
-
-
-
{
- (e.currentTarget as HTMLElement).style.color = 'var(--film-text-label)';
- }}
- onMouseLeave={(e) => {
- (e.currentTarget as HTMLElement).style.color = 'var(--film-text-dim)';
- }}
- >
- {allVisible ? : }
- {allVisible ? 'Hide all' : 'Show all'}
-
-
-
handleSelectAll(!allVisibleSelected)}
- className="flex items-center gap-1.5 transition-colors body-font"
- style={{ fontSize: 10, color: 'var(--film-text-dim)' }}
- onMouseEnter={(e) => {
- (e.currentTarget as HTMLElement).style.color = 'var(--film-text-label)';
- }}
- onMouseLeave={(e) => {
- (e.currentTarget as HTMLElement).style.color = 'var(--film-text-dim)';
+ {allVisible ? : }
+ {allVisible ? 'Hide all' : 'Show all'}
+
+
+
handleSelectAll(!allVisibleSelected)}
+ className="flex items-center gap-1.5 transition-colors body-font"
+ style={{ fontSize: 10, color: 'var(--film-text-dim)' }}
+ onMouseEnter={(e) => {
+ (e.currentTarget as HTMLElement).style.color = 'var(--film-text-label)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLElement).style.color = 'var(--film-text-dim)';
+ }}
+ >
+
+ {allVisibleSelected && }
+
+ Select all
+
+
+
+
+
+ {activeLayers.length > 0 ? (
+
+ {(provided) => (
- {allVisibleSelected && }
+ {activeLayers.map((layer, idx) => (
+
+ {(prov, snap) =>
+ layer.kind === 'logo'
+ ? renderLogoLayerRow(true, prov, snap.isDragging)
+ : renderBadgeRow(
+ { id: layer.id as RatingType, label: layer.label },
+ true,
+ prov,
+ snap.isDragging
+ )
+ }
+
+ ))}
+ {provided.placeholder}
- Select all
-
+ )}
+
+ ) : (
+
+
+
+ No active badges
+
+
+ Enable some from the list below
+
-
+ )}
-
- {activeLayers.length > 0 ? (
-
- {(provided) => (
-
- {activeLayers.map((layer, idx) => (
-
- {(prov, snap) =>
- layer.kind === 'logo'
- ? renderLogoLayerRow(true, prov, snap.isDragging)
- : renderBadgeRow(
- { id: layer.id as RatingType, label: layer.label },
- true,
- prov,
- snap.isDragging
- )
- }
-
- ))}
- {provided.placeholder}
-
- )}
-
- ) : (
-
-
-
0 && (
+ <>
+
+
- No active badges
-
-
- Enable some from the list below
-
-
- )}
-
- {inactiveBadges.length > 0 && (
- <>
-
+ Available
+
+ {/* Fallback toggle */}
+
- Available
+ Fallback
- {/* Fallback toggle */}
-
+ {
+ setFallbackEnabled(v);
+ setConfig((prev) => ({
+ ...prev,
+ fallbackEnabled: v,
+ fallbackPool: v ? inactiveBadges.map((b) => b.id) : [],
+ }));
+ }}
+ className={clsx(
+ 'relative inline-flex h-4 w-7 items-center rounded-full transition-colors focus:outline-none',
+ fallbackEnabled ? 'bg-[#C47C2E]' : 'bg-zinc-700/80'
+ )}
+ >
- Fallback
-
- {
- setFallbackEnabled(v);
- setConfig((prev) => ({
- ...prev,
- fallbackEnabled: v,
- fallbackPool: v ? inactiveBadges.map((b) => b.id) : [],
- }));
- }}
className={clsx(
- 'relative inline-flex h-4 w-7 items-center rounded-full transition-colors focus:outline-none',
- fallbackEnabled ? 'bg-[#C47C2E]' : 'bg-zinc-700/80'
+ 'inline-block w-2.5 h-2.5 rounded-full bg-white shadow-sm transition-transform',
+ fallbackEnabled ? 'translate-x-[13px]' : 'translate-x-[2px]'
)}
- >
-
-
-
+ />
+
-
- {fallbackEnabled ? (
-
- {(provided) => (
-
- {inactiveBadges.map((badge, idx) => (
-
- {(prov, snap) =>
- renderBadgeRow(badge, false, prov, snap.isDragging)
- }
-
- ))}
- {provided.placeholder}
-
- )}
-
- ) : (
-
- {inactiveBadges.map((badge) => (
-
- {renderBadgeRow(badge, false)}
-
- ))}
-
- )}
- >
- )}
- {!config.logo && (
-
0 ? 'mt-2' : 'mt-5')}>
- {renderLogoLayerRow(false)}
- )}
-
+ {fallbackEnabled ? (
+
+ {(provided) => (
+
+ {inactiveBadges.map((badge, idx) => (
+
+ {(prov, snap) => renderBadgeRow(badge, false, prov, snap.isDragging)}
+
+ ))}
+ {provided.placeholder}
+
+ )}
+
+ ) : (
+
+ {inactiveBadges.map((badge) => (
+
+ {renderBadgeRow(badge, false)}
+
+ ))}
+
+ )}
+ >
+ )}
+ {!config.logo && (
+
0 ? 'mt-2' : 'mt-5')}>
+ {renderLogoLayerRow(false)}
+
+ )}
+
>
)}
diff --git a/src/components/builder/components/PropertyPanel.tsx b/src/components/builder/components/PropertyPanel.tsx
index 4ddd85e..5b8db7f 100644
--- a/src/components/builder/components/PropertyPanel.tsx
+++ b/src/components/builder/components/PropertyPanel.tsx
@@ -1,14 +1,11 @@
// src/components/builder/components/PropertyPanel.tsx
-import React, { memo, useState, useRef, useEffect, useCallback } from 'react';
-import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Switch } from '@headlessui/react';
+import React, { memo, useRef, useEffect } from 'react';
import type { PosterConfig, RatingType, PresetType, BadgeConfig } from '../types';
-import { ALL_BADGES, CANVAS_WIDTH, CANVAS_HEIGHT, DEFAULT_CONFIG } from '../types';
+import { CANVAS_WIDTH, CANVAS_HEIGHT, DEFAULT_CONFIG } from '../types';
import {
Layers,
Layout,
Palette,
- ChevronDown,
- ChevronRight,
Eye,
EyeOff,
RotateCcw,
@@ -18,11 +15,10 @@ import {
Hash,
Sliders,
ImagePlay,
- Check,
} from 'lucide-react';
-import ColorPicker from './ColorPicker';
import clsx from 'clsx';
import SidebarLayout from './SidebarLayout';
+import { ColorRow, Section, SegmentedRow, SliderRow, TextInputRow, ToggleRow } from './ui';
interface Props {
config: PosterConfig;
@@ -32,9 +28,9 @@ interface Props {
selectedMinimalElements?: Set
;
viewMode?: 'global' | 'selection';
mode?: 'badges' | 'logo' | 'selection';
+ detailLevel?: 'simple' | 'advanced';
}
-const SECTION_STORAGE_KEY = 'posterium_section_states_v2';
const INACTIVE_OPTION_HOVER_CLASSES =
'bg-[rgba(255,255,255,0.03)] text-[var(--film-text-label)] border-[rgba(255,255,255,0.05)] hover:bg-[rgba(255,255,255,0.07)] hover:border-[rgba(196,124,46,0.24)] hover:text-[var(--film-cream)]';
const BADGE_DISPLAY_NAMES: Record = {
@@ -52,484 +48,6 @@ const BADGE_DISPLAY_NAMES: Record = {
title: 'Title',
};
-const readSectionStates = (): Record => {
- try {
- return JSON.parse(localStorage.getItem(SECTION_STORAGE_KEY) || '{}');
- } catch {
- return {};
- }
-};
-
-const writeSectionState = (id: string, open: boolean) => {
- try {
- const s = readSectionStates();
- localStorage.setItem(SECTION_STORAGE_KEY, JSON.stringify({ ...s, [id]: open }));
- } catch {}
-};
-
-// ── Section ──────────────────────────────────────────────────────────────────
-const Section: React.FC<{
- title: string;
- icon?: React.ReactNode;
- children: React.ReactNode;
- defaultOpen?: boolean;
- sectionId?: string;
-}> = ({ title, icon, children, defaultOpen = true, sectionId }) => {
- const [open, setOpen] = useState(() => {
- if (!sectionId) return defaultOpen;
- const states = readSectionStates();
- return sectionId in states ? states[sectionId] : defaultOpen;
- });
-
- const toggle = useCallback(() => {
- setOpen((v) => {
- const next = !v;
- if (sectionId) writeSectionState(sectionId, next);
- return next;
- });
- }, [sectionId]);
-
- return (
-
-
-
- {icon && (
- {icon}
- )}
- {title}
-
-
- {open ? : }
-
-
- {open &&
{children}
}
-
-
- );
-};
-
-// ── SliderRow ─────────────────────────────────────────────────────────────────
-const SliderRow: React.FC<{
- label: string;
- value: number;
- onChange: (v: number) => void;
- onReset?: () => void;
- min: number;
- max: number;
- step?: number;
- unit?: string;
- formatValue?: (v: number) => string;
-}> = ({ label, value, onChange, onReset, min, max, step = 1, unit = '', formatValue }) => {
- const [localValue, setLocalValue] = useState(value);
- const [inputText, setInputText] = useState(() => (formatValue ? formatValue(value) : `${value}`));
- const lastUpdate = useRef(Date.now());
- const timeoutRef = useRef(null);
- const inputRef = useRef(null);
- const isFocused = useRef(false);
-
- useEffect(() => {
- setLocalValue(value);
- if (!isFocused.current) {
- setInputText(formatValue ? formatValue(value) : `${value}`);
- }
- }, [value, formatValue]);
-
- const commitInput = useCallback(
- (text: string) => {
- const raw = text.replace(unit, '').replace(/[^0-9.\-]/g, '');
- const n = parseFloat(raw);
- if (!isNaN(n)) {
- const clamped = Math.max(min, Math.min(max, n));
- setLocalValue(clamped);
- setInputText(formatValue ? formatValue(clamped) : `${clamped}`);
- onChange(clamped);
- } else {
- setInputText(formatValue ? formatValue(localValue) : `${localValue}`);
- }
- },
- [min, max, onChange, unit, formatValue, localValue]
- );
-
- const handleRangeChange = useCallback(
- (e: React.ChangeEvent) => {
- const val = parseFloat(e.target.value);
- setLocalValue(val);
- if (!isFocused.current) {
- setInputText(formatValue ? formatValue(val) : `${val}`);
- }
- const now = Date.now();
- if (now - lastUpdate.current > 33) {
- onChange(val);
- lastUpdate.current = now;
- } else {
- if (timeoutRef.current) clearTimeout(timeoutRef.current);
- timeoutRef.current = setTimeout(() => {
- onChange(val);
- lastUpdate.current = Date.now();
- }, 33);
- }
- },
- [onChange, formatValue]
- );
-
- const handleInputKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === 'Enter') {
- commitInput(inputText);
- inputRef.current?.blur();
- } else if (e.key === 'Escape') {
- setInputText(formatValue ? formatValue(localValue) : `${localValue}`);
- inputRef.current?.blur();
- } else if (e.key === 'ArrowUp') {
- e.preventDefault();
- const newVal = Math.max(min, Math.min(max, localValue + step));
- setLocalValue(newVal);
- setInputText(formatValue ? formatValue(newVal) : `${newVal}`);
- onChange(newVal);
- } else if (e.key === 'ArrowDown') {
- e.preventDefault();
- const newVal = Math.max(min, Math.min(max, localValue - step));
- setLocalValue(newVal);
- setInputText(formatValue ? formatValue(newVal) : `${newVal}`);
- onChange(newVal);
- }
- };
-
- return (
-
-
-
- {label}
-
- {onReset && (
- {
- (e.currentTarget as HTMLElement).style.color = 'var(--film-text-label)';
- }}
- onMouseLeave={(e) => {
- (e.currentTarget as HTMLElement).style.color = 'var(--film-text-dim)';
- }}
- >
-
- Reset
-
- )}
-
-
- setInputText(e.target.value)}
- onFocus={() => {
- isFocused.current = true;
- }}
- onBlur={() => {
- isFocused.current = false;
- commitInput(inputText);
- }}
- onKeyDown={handleInputKeyDown}
- className="mono-font tabular-nums focus:outline-none shrink-0"
- style={{
- width: 48,
- height: 22,
- paddingInline: 5,
- borderRadius: 4,
- background: 'rgba(255,255,255,0.04)',
- border: '1px solid rgba(255,255,255,0.1)',
- fontSize: 10,
- color: 'var(--film-pale)',
- textAlign: 'center',
- transition: 'border-color 0.15s',
- }}
- onMouseEnter={(e) => {
- (e.currentTarget as HTMLInputElement).style.borderColor = 'rgba(196,124,46,0.4)';
- }}
- onMouseLeave={(e) => {
- if (!isFocused.current)
- (e.currentTarget as HTMLInputElement).style.borderColor = 'rgba(255,255,255,0.1)';
- }}
- />
-
-
-
- );
-};
-
-// ── ToggleRow ─────────────────────────────────────────────────────────────────
-const ToggleRow: React.FC<{
- label: string;
- sub?: string;
- checked: boolean;
- onChange: (v: boolean) => void;
- small?: boolean;
-}> = ({ label, sub, checked, onChange, small }) => (
-
-
-
- {label}
-
- {sub && (
-
- {sub}
-
- )}
-
-
-
-
-
-);
-
-// ── SegmentedRow — compact button group ───────────────────────────────────────
-const SegmentedRow: React.FC<{
- label: string;
- options: { id: string; label: string }[];
- value: string | null;
- onChange: (v: string) => void;
-}> = ({ label, options, value, onChange }) => (
-
-
- {label}
-
-
- {options.map((opt) => (
- onChange(opt.id)}
- className={clsx(
- 'h-7 rounded-md text-[10px] font-medium transition-all border syne-font',
- value === opt.id
- ? 'bg-[rgba(196,124,46,0.15)] text-[var(--film-pale)] border-[rgba(196,124,46,0.3)]'
- : INACTIVE_OPTION_HOVER_CLASSES
- )}
- >
- {opt.label}
-
- ))}
-
-
-);
-
-// ── TextInputRow ──────────────────────────────────────────────────────────────
-const TextInputRow: React.FC<{
- label: string;
- value: string;
- placeholder?: string;
- onChange: (v: string) => void;
- onClear?: () => void;
-}> = ({ label, value, placeholder, onChange, onClear }) => {
- const inputRef = useRef(null);
- const [focused, setFocused] = useState(false);
-
- return (
-
-
-
- {label}
-
- {onClear && value && (
- {
- (e.currentTarget as HTMLElement).style.color = 'var(--film-text-label)';
- }}
- onMouseLeave={(e) => {
- (e.currentTarget as HTMLElement).style.color = 'var(--film-text-dim)';
- }}
- >
- Clear
-
- )}
-
-
onChange(e.target.value)}
- onFocus={() => setFocused(true)}
- onBlur={() => setFocused(false)}
- className="w-full focus:outline-none body-font hover:border-[rgba(196,124,46,0.28)]"
- style={{
- height: 28,
- paddingInline: 8,
- borderRadius: 6,
- background: 'rgba(255,255,255,0.04)',
- border: `1px solid ${focused ? 'rgba(196,124,46,0.4)' : 'rgba(255,255,255,0.1)'}`,
- fontSize: 11,
- color: 'var(--film-pale)',
- transition: 'border-color 0.15s',
- }}
- />
-
- );
-};
-
-const SelectBox = memo(
- ({
- value,
- onChange,
- options,
- }: {
- value: string;
- onChange: (v: string) => void;
- options: { id: string; label: string }[];
- }) => (
-
-
-
- {options.find((o) => o.id === value)?.label ?? value}
-
-
-
- {options.map((opt) => (
-
- clsx(
- 'flex items-center gap-2 px-3 py-2.5 cursor-pointer transition-colors syne-font',
- active && 'bg-[rgba(196,124,46,0.1)]',
- !active && selected && 'text-[var(--film-pale)]',
- !active && !selected && 'text-[var(--film-text-label)]'
- )
- }
- >
- {({ selected }) => (
- <>
- {opt.label}
- {selected && (
-
- )}
- >
- )}
-
- ))}
-
-
-
- )
-);
-SelectBox.displayName = 'SelectBox';
-
-// ── ColorRow — label + optional reset button above a ColorPicker ──────────────
-const ColorRow: React.FC<{
- label: string;
- value: string;
- onChange: (v: string) => void;
- onReset?: () => void;
- showOpacity?: boolean;
- opacity?: number;
- onOpacityChange?: (v: number) => void;
-}> = ({ label, value, onChange, onReset, showOpacity, opacity, onOpacityChange }) => (
-
-
-
- {label}
-
- {onReset && (
- {
- (e.currentTarget as HTMLElement).style.color = 'var(--film-text-label)';
- }}
- onMouseLeave={(e) => {
- (e.currentTarget as HTMLElement).style.color = 'var(--film-text-dim)';
- }}
- >
- Reset
-
- )}
-
-
-
-);
-
// ── Alignment grid ─────────────────────────────────────────────────────────────
const GRID_POSITIONS: { id: PresetType; label: string }[] = [
{ id: 'tl', label: 'Top left' },
@@ -643,6 +161,7 @@ const PropertyPanel: React.FC = ({
selectedMinimalElements = new Set(),
viewMode,
mode,
+ detailLevel = 'advanced',
}) => {
const badgesEnabled = config.ratings.length > 0;
const logoSettingsRef = useRef(null);
@@ -740,6 +259,7 @@ const PropertyPanel: React.FC = ({
return vals.length > 0 && vals.every((v) => v === vals[0]) ? vals[0] : null;
};
+ const isAdvanced = detailLevel === 'advanced';
const panelMode = mode ?? (viewMode === 'selection' ? 'selection' : 'badges');
const showGlobal = panelMode !== 'selection';
const showBadgeSettings = panelMode === 'badges';
@@ -892,38 +412,46 @@ const PropertyPanel: React.FC = ({
: undefined
}
/>
- updateConfig('shadowX', Math.round(v))}
- />
- updateConfig('shadowY', Math.round(v))}
- />
- updateConfig('shadowColor', v)}
- />
- `${Math.round(v * 100)}%`}
- onChange={(v) => updateConfig('shadowOpacity', Number(v.toFixed(2)))}
- />
+ {isAdvanced && (
+ updateConfig('shadowX', Math.round(v))}
+ />
+ )}
+ {isAdvanced && (
+ updateConfig('shadowY', Math.round(v))}
+ />
+ )}
+ {isAdvanced && (
+ updateConfig('shadowColor', v)}
+ />
+ )}
+ {isAdvanced && (
+ `${Math.round(v * 100)}%`}
+ onChange={(v) => updateConfig('shadowOpacity', Number(v.toFixed(2)))}
+ />
+ )}
@@ -959,112 +487,118 @@ const PropertyPanel: React.FC = ({
)}
- }
- defaultOpen={false}
- sectionId="global-score"
- >
- updateConfig('normalize', v)}
- />
- 0}
- onChange={(v) => updateConfig('outOf', v ? 10 : undefined)}
- />
-
+ {isAdvanced && (
+ }
+ defaultOpen={false}
+ sectionId="global-score"
+ >
+ updateConfig('normalize', v)}
+ />
+ 0}
+ onChange={(v) => updateConfig('outOf', v ? 10 : undefined)}
+ />
+
+ )}
- }
- defaultOpen={false}
- sectionId="global-labels"
- >
- {/* Show/Hide Labels toggle */}
- updateConfig('labelPos', config.labelPos ? undefined : 'below')}
- className="w-full h-8 rounded-lg text-[11px] font-medium flex items-center justify-center gap-1.5 transition-all active:scale-95 syne-font mb-1"
- style={{
- background: config.labelPos ? 'rgba(196,124,46,0.1)' : 'rgba(255,255,255,0.02)',
- color: config.labelPos ? 'var(--film-pale)' : 'var(--film-text-dim)',
- border: config.labelPos
- ? '1px solid rgba(196,124,46,0.22)'
- : '1px solid rgba(255,255,255,0.05)',
- }}
- onMouseEnter={(e) => {
- (e.currentTarget as HTMLElement).style.borderColor = 'rgba(196,124,46,0.35)';
- (e.currentTarget as HTMLElement).style.background = config.labelPos
- ? 'rgba(196,124,46,0.15)'
- : 'rgba(255,255,255,0.05)';
- }}
- onMouseLeave={(e) => {
- (e.currentTarget as HTMLElement).style.borderColor = config.labelPos
- ? 'rgba(196,124,46,0.22)'
- : 'rgba(255,255,255,0.05)';
- (e.currentTarget as HTMLElement).style.background = config.labelPos
- ? 'rgba(196,124,46,0.1)'
- : 'rgba(255,255,255,0.02)';
- }}
+ {isAdvanced && (
+ }
+ defaultOpen={false}
+ sectionId="global-labels"
>
- {config.labelPos ? (
-
- ) : (
-
- )}
- {config.labelPos ? 'Labels Visible' : 'Labels Hidden'}
-
-
- updateConfig(
- 'labelPos',
- (v === 'none' ? undefined : (v as PosterConfig['labelPos'])) as PosterConfig['labelPos']
- )
- }
- />
- updateConfig('labelText', v || undefined)}
- onClear={() => updateConfig('labelText', undefined)}
- />
- updateConfig('labelSize', v)}
- />
- updateConfig('labelColor', v)}
- onReset={
- config.labelColor ? () => updateConfig('labelColor', undefined) : undefined
- }
- />
-
+ {/* Show/Hide Labels toggle */}
+ updateConfig('labelPos', config.labelPos ? undefined : 'below')}
+ className="w-full h-8 rounded-lg text-[11px] font-medium flex items-center justify-center gap-1.5 transition-all active:scale-95 syne-font mb-1"
+ style={{
+ background: config.labelPos ? 'rgba(196,124,46,0.1)' : 'rgba(255,255,255,0.02)',
+ color: config.labelPos ? 'var(--film-pale)' : 'var(--film-text-dim)',
+ border: config.labelPos
+ ? '1px solid rgba(196,124,46,0.22)'
+ : '1px solid rgba(255,255,255,0.05)',
+ }}
+ onMouseEnter={(e) => {
+ (e.currentTarget as HTMLElement).style.borderColor = 'rgba(196,124,46,0.35)';
+ (e.currentTarget as HTMLElement).style.background = config.labelPos
+ ? 'rgba(196,124,46,0.15)'
+ : 'rgba(255,255,255,0.05)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLElement).style.borderColor = config.labelPos
+ ? 'rgba(196,124,46,0.22)'
+ : 'rgba(255,255,255,0.05)';
+ (e.currentTarget as HTMLElement).style.background = config.labelPos
+ ? 'rgba(196,124,46,0.1)'
+ : 'rgba(255,255,255,0.02)';
+ }}
+ >
+ {config.labelPos ? (
+
+ ) : (
+
+ )}
+ {config.labelPos ? 'Labels Visible' : 'Labels Hidden'}
+
+
+ updateConfig(
+ 'labelPos',
+ (v === 'none'
+ ? undefined
+ : (v as PosterConfig['labelPos'])) as PosterConfig['labelPos']
+ )
+ }
+ />
+ updateConfig('labelText', v || undefined)}
+ onClear={() => updateConfig('labelText', undefined)}
+ />
+ updateConfig('labelSize', v)}
+ />
+ updateConfig('labelColor', v)}
+ onReset={
+ config.labelColor ? () => updateConfig('labelColor', undefined) : undefined
+ }
+ />
+
+ )}
>
)}
- {showTitleDefaults && (
+ {isAdvanced && showTitleDefaults && (
} sectionId="global-title-layer">
= ({
icon={ }
sectionId="global-logo-overlay"
>
- {
- const w = Math.round(newW);
- const h = Math.round(w / LOGO_ASPECT);
- setConfig((prev) => ({ ...prev, logoW: w, logoH: h }));
- }}
- />
- `${Math.round(v * 100)}%`}
- onChange={(v) => updateConfig('logoOpacity', v)}
- />
- updateConfig('logoShadow', v)}
- />
- updateConfig('logoBgEnabled', v)}
- />
- updateConfig('logoBgBorderW', v)}
- />
- {config.logoBgBorderW > 0 && (
- updateConfig('logoBgBorderC', v)}
+ {
+ const w = Math.round(newW);
+ const h = Math.round(w / LOGO_ASPECT);
+ setConfig((prev) => ({ ...prev, logoW: w, logoH: h }));
+ }}
/>
- )}
- {config.logoBgEnabled && (
- <>
- updateConfig('logoBgColor', v)}
- showOpacity
- opacity={config.logoBgOpacity}
- onOpacityChange={(v) => updateConfig('logoBgOpacity', v)}
- />
- updateConfig('logoBgPadding', v)}
- />
- updateConfig('logoBgShadow', v)}
- />
-
- setConfig((prev) => ({
- ...prev,
- logoBgEnabled: true,
- logoBgColor: prev.bg ?? '#000000',
- logoBgOpacity: prev.alpha,
- logoBgRadius: prev.radius,
- logoBgPadding: 10,
- logoBgBorderW: prev.borderW ?? 0,
- logoBgBorderC: prev.borderC ?? '#ffffff',
- logoBgShadow: resolveShadow(prev.shadow, 6),
- }))
- }
- className="w-full h-8 rounded-lg text-[11px] font-medium transition-all active:scale-[0.98] flex items-center justify-center gap-2 cursor-pointer syne-font"
- style={{
- border: '1px solid rgba(196,124,46,0.16)',
- background: 'rgba(196,124,46,0.08)',
- color: 'var(--film-pale)',
- letterSpacing: '0.04em',
- }}
- >
- Apply Badge Style to Logo Background
-
- >
- )}
- updateConfig('logoBgRadius', v)}
- />
-
- Drag the logo on the canvas to reposition it.
-
+ `${Math.round(v * 100)}%`}
+ onChange={(v) => updateConfig('logoOpacity', v)}
+ />
+ updateConfig('logoShadow', v)}
+ />
+ updateConfig('logoBgEnabled', v)}
+ />
+ updateConfig('logoBgBorderW', v)}
+ />
+ {config.logoBgBorderW > 0 && (
+ updateConfig('logoBgBorderC', v)}
+ />
+ )}
+ {config.logoBgEnabled && (
+ <>
+ updateConfig('logoBgColor', v)}
+ showOpacity
+ opacity={config.logoBgOpacity}
+ onOpacityChange={(v) => updateConfig('logoBgOpacity', v)}
+ />
+ updateConfig('logoBgPadding', v)}
+ />
+ updateConfig('logoBgShadow', v)}
+ />
+
+ setConfig((prev) => ({
+ ...prev,
+ logoBgEnabled: true,
+ logoBgColor: prev.bg ?? '#000000',
+ logoBgOpacity: prev.alpha,
+ logoBgRadius: prev.radius,
+ logoBgPadding: 10,
+ logoBgBorderW: prev.borderW ?? 0,
+ logoBgBorderC: prev.borderC ?? '#ffffff',
+ logoBgShadow: resolveShadow(prev.shadow, 6),
+ }))
+ }
+ className="w-full h-8 rounded-lg text-[11px] font-medium transition-all active:scale-[0.98] flex items-center justify-center gap-2 cursor-pointer syne-font"
+ style={{
+ border: '1px solid rgba(196,124,46,0.16)',
+ background: 'rgba(196,124,46,0.08)',
+ color: 'var(--film-pale)',
+ letterSpacing: '0.04em',
+ }}
+ >
+ Apply Badge Style to Logo Background
+
+ >
+ )}
+ updateConfig('logoBgRadius', v)}
+ />
+
+ Drag the logo on the canvas to reposition it.
+
)}
@@ -1299,7 +833,8 @@ const PropertyPanel: React.FC = ({
const isTitleOrYearOnlySelection = isOnlyTitleSelected || isOnlyYearSelected;
const multi = selectionCount > 1;
const selectedBadgeLabel = (() => {
- if (selectedLogo && selectedIds.size === 0 && selectedMinimalElements.size === 0) return 'Logo Overlay';
+ if (selectedLogo && selectedIds.size === 0 && selectedMinimalElements.size === 0)
+ return 'Logo Overlay';
if (selectedMinimalElements.size > 0 && selectedIds.size === 0 && !selectedLogo) {
if (selectedMinimalElements.size > 1) return 'Minimal Selection';
const only = Array.from(selectedMinimalElements)[0];
@@ -1307,7 +842,8 @@ const PropertyPanel: React.FC = ({
if (only === 'minimal-year') return 'Year';
if (only === 'minimal-duration') return 'Duration';
if (only === 'minimal-logo') return 'Logo Overlay';
- if (only.startsWith('minimal-rating-')) return `Rating Slot ${Number(only.split('-').at(-1) ?? 0) + 1}`;
+ if (only.startsWith('minimal-rating-'))
+ return `Rating Slot ${Number(only.split('-').pop() ?? 0) + 1}`;
}
const first = Array.from(selectedIds)[0];
if (!first) return 'Selection';
@@ -1322,8 +858,12 @@ const PropertyPanel: React.FC = ({
(getCommonValue('shadow', 6) as number | boolean | null) ?? 6,
6
);
- const commonShadowX = (getCommonValue('shadowX', config.shadowX ?? 0) ?? config.shadowX ?? 0) as number;
- const commonShadowY = (getCommonValue('shadowY', config.shadowY ?? 2) ?? config.shadowY ?? 2) as number;
+ const commonShadowX = (getCommonValue('shadowX', config.shadowX ?? 0) ??
+ config.shadowX ??
+ 0) as number;
+ const commonShadowY = (getCommonValue('shadowY', config.shadowY ?? 2) ??
+ config.shadowY ??
+ 2) as number;
const commonShadowOpacity = (getCommonValue('shadowOpacity', config.shadowOpacity ?? 0.35) ??
config.shadowOpacity ??
0.35) as number;
@@ -1399,7 +939,8 @@ const PropertyPanel: React.FC = ({
const commonTextShadowX = (getCommonValue('textShadowX', 0) ?? 0) as number;
const commonTextShadowY = (getCommonValue('textShadowY', 2) ?? 2) as number;
const commonTextShadowBlur = (getCommonValue('textShadowBlur', 8) ?? 8) as number;
- const commonTextShadowColor = (getCommonValue('textShadowColor', '#000000') ?? '#000000') as string;
+ const commonTextShadowColor = (getCommonValue('textShadowColor', '#000000') ??
+ '#000000') as string;
return (
@@ -1440,7 +981,9 @@ const PropertyPanel: React.FC = ({
onChange={(v) => updateConfig('minimalTitleColor', v)}
showOpacity
opacity={config.minimalTitleOpacity ?? 1}
- onOpacityChange={(v) => updateConfig('minimalTitleOpacity', Number(v.toFixed(2)))}
+ onOpacityChange={(v) =>
+ updateConfig('minimalTitleOpacity', Number(v.toFixed(2)))
+ }
/>
>
)}
@@ -1461,7 +1004,9 @@ const PropertyPanel: React.FC = ({
onChange={(v) => updateConfig('minimalMetaColor', v)}
showOpacity
opacity={config.minimalMetaOpacity ?? 0.92}
- onOpacityChange={(v) => updateConfig('minimalMetaOpacity', Number(v.toFixed(2)))}
+ onOpacityChange={(v) =>
+ updateConfig('minimalMetaOpacity', Number(v.toFixed(2)))
+ }
/>
>
)}
@@ -1472,372 +1017,393 @@ const PropertyPanel: React.FC = ({
{selectedIds.size > 0 && (
<>
- {isTitleOrYearOnlySelection && (
- } sectionId="badge-typography">
- {isOnlyTitleSelected && (
+ {isTitleOrYearOnlySelection && (
+ } sectionId="badge-typography">
+ {isOnlyTitleSelected && (
+ updateSelectedBadges({ textSize: Math.round(v) })}
+ onReset={() => updateSelectedBadges({ textSize: 36 })}
+ />
+ )}
+ {isOnlyTitleSelected && (
+ updateSelectedBadges({ textCharWidth: Math.round(v) })}
+ onReset={() =>
+ updateSelectedBadges({
+ textCharWidth: DEFAULT_CONFIG.items.title?.textCharWidth ?? 24,
+ })
+ }
+ />
+ )}
+ {isOnlyTitleSelected && (
+ updateSelectedBadges({ textCharHeight: Math.round(v) })}
+ onReset={() =>
+ updateSelectedBadges({
+ textCharHeight: DEFAULT_CONFIG.items.title?.textCharHeight ?? 1,
+ })
+ }
+ />
+ )}
+ updateSelectedBadges({ textWeight: Math.round(v) })}
+ onReset={() => updateSelectedBadges({ textWeight: 700 })}
+ />
+ updateSelectedBadges({ textLineHeight: Number(v.toFixed(2)) })}
+ onReset={() => updateSelectedBadges({ textLineHeight: 1.1 })}
+ />
+ updateSelectedBadges({ textLetterSpacing: Number(v.toFixed(1)) })}
+ onReset={() =>
+ updateSelectedBadges({ textLetterSpacing: isOnlyTitleSelected ? 0.2 : 0 })
+ }
+ />
+ updateSelectedBadges({ textAlign: v as BadgeConfig['textAlign'] })}
+ />
+ {isOnlyTitleSelected && (
+ (v <= 0 ? 'Full' : `${Math.round(v)} ch`)}
+ onChange={(v) => updateSelectedBadges({ textMaxChars: Math.round(v) })}
+ onReset={() => updateSelectedBadges({ textMaxChars: 0 })}
+ />
+ )}
+ {isOnlyTitleSelected && (
+ updateSelectedBadges({ textWrapEnabled: v })}
+ />
+ )}
+ updateSelectedBadges({ textShadowEnabled: v })}
+ />
+ {commonTextShadowEnabled && (
+ <>
+ updateSelectedBadges({ textShadowX: Math.round(v) })}
+ />
+ updateSelectedBadges({ textShadowY: Math.round(v) })}
+ />
+ updateSelectedBadges({ textShadowBlur: Math.round(v) })}
+ />
+ updateSelectedBadges({ textShadowColor: v })}
+ />
+ >
+ )}
+
+ )}
+ {/* Transform ── scale */}
+ {!isOnlyTitleSelected && (
+
+ `${v.toFixed(2)}×`}
+ onChange={(v) => updateSelectedBadges({ scale: v })}
+ onReset={
+ commonScale !== 1.0 ? () => updateSelectedBadges({ scale: 1.0 }) : undefined
+ }
+ />
+
+ )}
+
+ {/* Shape ── blur, radius, shadow, border */}
+
updateSelectedBadges({ textSize: Math.round(v) })}
- onReset={() => updateSelectedBadges({ textSize: 36 })}
+ onChange={(v) => updateSelectedBadges({ blur: v })}
+ onReset={commonBlur !== 0 ? () => updateSelectedBadges({ blur: 0 }) : undefined}
/>
- )}
- {isOnlyTitleSelected && (
updateSelectedBadges({ radius: v })}
+ />
+ updateSelectedBadges({ shadow: v })}
+ onReset={commonShadow !== 6 ? () => updateSelectedBadges({ shadow: 6 }) : undefined}
+ />
+ updateSelectedBadges({ textCharWidth: Math.round(v) })}
- onReset={() =>
- updateSelectedBadges({
- textCharWidth: DEFAULT_CONFIG.items.title?.textCharWidth ?? 24,
- })
- }
+ unit="px"
+ onChange={(v) => updateSelectedBadges({ shadowX: Math.round(v) })}
/>
- )}
- {isOnlyTitleSelected && (
updateSelectedBadges({ textCharHeight: Math.round(v) })}
- onReset={() =>
- updateSelectedBadges({
- textCharHeight: DEFAULT_CONFIG.items.title?.textCharHeight ?? 1,
- })
- }
+ unit="px"
+ onChange={(v) => updateSelectedBadges({ shadowY: Math.round(v) })}
+ />
+ updateSelectedBadges({ shadowColor: v })}
/>
- )}
- updateSelectedBadges({ textWeight: Math.round(v) })}
- onReset={() => updateSelectedBadges({ textWeight: 700 })}
- />
- updateSelectedBadges({ textLineHeight: Number(v.toFixed(2)) })}
- onReset={() => updateSelectedBadges({ textLineHeight: 1.1 })}
- />
- updateSelectedBadges({ textLetterSpacing: Number(v.toFixed(1)) })}
- onReset={() => updateSelectedBadges({ textLetterSpacing: isOnlyTitleSelected ? 0.2 : 0 })}
- />
- updateSelectedBadges({ textAlign: v as BadgeConfig['textAlign'] })}
- />
- {isOnlyTitleSelected && (
(v <= 0 ? 'Full' : `${Math.round(v)} ch`)}
- onChange={(v) => updateSelectedBadges({ textMaxChars: Math.round(v) })}
- onReset={() => updateSelectedBadges({ textMaxChars: 0 })}
+ max={1}
+ step={0.01}
+ formatValue={(v) => `${Math.round(v * 100)}%`}
+ onChange={(v) => updateSelectedBadges({ shadowOpacity: Number(v.toFixed(2)) })}
/>
- )}
- {isOnlyTitleSelected && (
- updateSelectedBadges({ textWrapEnabled: v })}
+ updateSelectedBadges({ borderW: v })}
/>
+ {commonBorderW > 0 && (
+ updateSelectedBadges({ borderC: v })}
+ onReset={() => clearSelectedBadgeProp('borderC')}
+ />
+ )}
+
+
+ {/* Colors ── fill + text */}
+
+ updateSelectedBadges({ bg: v })}
+ onReset={() => clearSelectedBadgeProp('bg')}
+ showOpacity
+ opacity={commonAlpha}
+ onOpacityChange={(v) => updateSelectedBadges({ alpha: v })}
+ />
+ updateSelectedBadges({ txt: v })}
+ onReset={() => clearSelectedBadgeProp('txt')}
+ />
+
+
+ {/* Visibility ── icons, text, icon variant */}
+ {!isTitleOrYearOnlySelection && (
+ } sectionId="badge-visibility">
+ {!isAgeSelected && (
+ updateSelectedBadges({ icon: v })}
+ />
+ )}
+ updateSelectedBadges({ showText: v })}
+ />
+
+ updateSelectedBadges({ iconType: Math.max(1, Math.min(3, Number(v) || 1)) })
+ }
+ />
+
)}
- updateSelectedBadges({ textShadowEnabled: v })}
- />
- {commonTextShadowEnabled && (
- <>
- updateSelectedBadges({ textShadowX: Math.round(v) })}
+
+ {/* Score ── normalize + denominator */}
+ {!isTitleOrYearOnlySelection && isAdvanced && (
+ }
+ defaultOpen={false}
+ sectionId="badge-score"
+ >
+ updateSelectedBadges({ normalize: v })}
/>
- updateSelectedBadges({ textShadowY: Math.round(v) })}
+ 0}
+ onChange={(v) => updateSelectedBadges({ outOf: v ? 10 : undefined })}
+ />
+
+ )}
+
+ {/* Labels ── position, custom text, size, color */}
+ {!isTitleOrYearOnlySelection && isAdvanced && (
+ }
+ defaultOpen={false}
+ sectionId="badge-labels"
+ >
+
+ updateSelectedBadges({
+ labelPos: (v === 'none' ? undefined : (v as BadgeConfig['labelPos'])) as
+ | BadgeConfig['labelPos']
+ | undefined,
+ })
+ }
+ />
+ updateSelectedBadges({ labelText: v || undefined })}
+ onClear={() => clearSelectedBadgeProp('labelText')}
/>
updateSelectedBadges({ textShadowBlur: Math.round(v) })}
+ onChange={(v) => updateSelectedBadges({ labelSize: v })}
/>
updateSelectedBadges({ textShadowColor: v })}
+ label="Label Color"
+ value={commonLabelColor}
+ onChange={(v) => updateSelectedBadges({ labelColor: v })}
+ onReset={() => clearSelectedBadgeProp('labelColor')}
/>
- >
+
)}
-
- )}
- {/* Transform ── scale */}
- {!isOnlyTitleSelected && (
-
- `${v.toFixed(2)}×`}
- onChange={(v) => updateSelectedBadges({ scale: v })}
- onReset={commonScale !== 1.0 ? () => updateSelectedBadges({ scale: 1.0 }) : undefined}
- />
-
- )}
-
- {/* Shape ── blur, radius, shadow, border */}
-
- updateSelectedBadges({ blur: v })}
- onReset={commonBlur !== 0 ? () => updateSelectedBadges({ blur: 0 }) : undefined}
- />
- updateSelectedBadges({ radius: v })}
- />
- updateSelectedBadges({ shadow: v })}
- onReset={commonShadow !== 6 ? () => updateSelectedBadges({ shadow: 6 }) : undefined}
- />
- updateSelectedBadges({ shadowX: Math.round(v) })}
- />
- updateSelectedBadges({ shadowY: Math.round(v) })}
- />
- updateSelectedBadges({ shadowColor: v })}
- />
- `${Math.round(v * 100)}%`}
- onChange={(v) => updateSelectedBadges({ shadowOpacity: Number(v.toFixed(2)) })}
- />
- updateSelectedBadges({ borderW: v })}
- />
- {commonBorderW > 0 && (
- updateSelectedBadges({ borderC: v })}
- onReset={() => clearSelectedBadgeProp('borderC')}
- />
- )}
-
-
- {/* Colors ── fill + text */}
-
- updateSelectedBadges({ bg: v })}
- onReset={() => clearSelectedBadgeProp('bg')}
- showOpacity
- opacity={commonAlpha}
- onOpacityChange={(v) => updateSelectedBadges({ alpha: v })}
- />
- updateSelectedBadges({ txt: v })}
- onReset={() => clearSelectedBadgeProp('txt')}
- />
-
-
- {/* Visibility ── icons, text, icon variant */}
- {!isTitleOrYearOnlySelection && (
- } sectionId="badge-visibility">
- {!isAgeSelected && (
- updateSelectedBadges({ icon: v })}
- />
- )}
- updateSelectedBadges({ showText: v })}
- />
- updateSelectedBadges({ iconType: Math.max(1, Math.min(3, Number(v) || 1)) })}
- />
-
- )}
-
- {/* Score ── normalize + denominator */}
- {!isTitleOrYearOnlySelection && (
- } defaultOpen={false} sectionId="badge-score">
- updateSelectedBadges({ normalize: v })}
- />
- 0}
- onChange={(v) => updateSelectedBadges({ outOf: v ? 10 : undefined })}
- />
-
- )}
-
- {/* Labels ── position, custom text, size, color */}
- {!isTitleOrYearOnlySelection && (
- }
- defaultOpen={false}
- sectionId="badge-labels"
- >
-
- updateSelectedBadges({
- labelPos: (v === 'none' ? undefined : (v as BadgeConfig['labelPos'])) as
- | BadgeConfig['labelPos']
- | undefined,
- })
- }
- />
- updateSelectedBadges({ labelText: v || undefined })}
- onClear={() => clearSelectedBadgeProp('labelText')}
- />
- updateSelectedBadges({ labelSize: v })}
- />
- updateSelectedBadges({ labelColor: v })}
- onReset={() => clearSelectedBadgeProp('labelColor')}
- />
-
- )}
- >
+ >
)}
{selectedLogo && config.logo && (
- } sectionId="selection-logo-overlay">
+ }
+ sectionId="selection-logo-overlay"
+ >
= ({
{/* Reset */}
{(selectedIds.size > 0 || selectedLogo) && (
-
-
- setConfig((prev) => {
- const ni = { ...prev.items };
- selectedIds.forEach((id) => delete ni[id]);
- if (!selectedLogo) return { ...prev, items: ni };
- return {
- ...prev,
- items: ni,
- logoX: DEFAULT_CONFIG.logoX,
- logoY: DEFAULT_CONFIG.logoY,
- logoW: DEFAULT_CONFIG.logoW,
- logoH: DEFAULT_CONFIG.logoH,
- logoOpacity: DEFAULT_CONFIG.logoOpacity,
- logoZ: DEFAULT_CONFIG.logoZ,
- logoShadow: DEFAULT_CONFIG.logoShadow,
- logoBgEnabled: DEFAULT_CONFIG.logoBgEnabled,
- logoBgColor: DEFAULT_CONFIG.logoBgColor,
- logoBgOpacity: DEFAULT_CONFIG.logoBgOpacity,
- logoBgRadius: DEFAULT_CONFIG.logoBgRadius,
- logoBgPadding: DEFAULT_CONFIG.logoBgPadding,
- logoBgBorderW: DEFAULT_CONFIG.logoBgBorderW,
- logoBgBorderC: DEFAULT_CONFIG.logoBgBorderC,
- logoBgShadow: DEFAULT_CONFIG.logoBgShadow,
- };
- })
- }
- className="w-full h-8 rounded-lg text-[11px] font-medium transition-all active:scale-[0.98] flex items-center justify-center gap-2 cursor-pointer syne-font"
- style={{
- border: '1px solid rgba(248,113,113,0.12)',
- background: 'rgba(248,113,113,0.04)',
- color: 'rgba(248,113,113,0.6)',
- letterSpacing: '0.04em',
- }}
- onMouseEnter={(e) => {
- (e.currentTarget as HTMLElement).style.background = 'rgba(248,113,113,0.08)';
- (e.currentTarget as HTMLElement).style.color = 'rgba(248,113,113,0.85)';
- }}
- onMouseLeave={(e) => {
- (e.currentTarget as HTMLElement).style.background = 'rgba(248,113,113,0.04)';
- (e.currentTarget as HTMLElement).style.color = 'rgba(248,113,113,0.6)';
- }}
- >
- Reset selected to defaults
-
-
+
+
+ setConfig((prev) => {
+ const ni = { ...prev.items };
+ selectedIds.forEach((id) => delete ni[id]);
+ if (!selectedLogo) return { ...prev, items: ni };
+ return {
+ ...prev,
+ items: ni,
+ logoX: DEFAULT_CONFIG.logoX,
+ logoY: DEFAULT_CONFIG.logoY,
+ logoW: DEFAULT_CONFIG.logoW,
+ logoH: DEFAULT_CONFIG.logoH,
+ logoOpacity: DEFAULT_CONFIG.logoOpacity,
+ logoZ: DEFAULT_CONFIG.logoZ,
+ logoShadow: DEFAULT_CONFIG.logoShadow,
+ logoBgEnabled: DEFAULT_CONFIG.logoBgEnabled,
+ logoBgColor: DEFAULT_CONFIG.logoBgColor,
+ logoBgOpacity: DEFAULT_CONFIG.logoBgOpacity,
+ logoBgRadius: DEFAULT_CONFIG.logoBgRadius,
+ logoBgPadding: DEFAULT_CONFIG.logoBgPadding,
+ logoBgBorderW: DEFAULT_CONFIG.logoBgBorderW,
+ logoBgBorderC: DEFAULT_CONFIG.logoBgBorderC,
+ logoBgShadow: DEFAULT_CONFIG.logoBgShadow,
+ };
+ })
+ }
+ className="w-full h-8 rounded-lg text-[11px] font-medium transition-all active:scale-[0.98] flex items-center justify-center gap-2 cursor-pointer syne-font"
+ style={{
+ border: '1px solid rgba(248,113,113,0.12)',
+ background: 'rgba(248,113,113,0.04)',
+ color: 'rgba(248,113,113,0.6)',
+ letterSpacing: '0.04em',
+ }}
+ onMouseEnter={(e) => {
+ (e.currentTarget as HTMLElement).style.background = 'rgba(248,113,113,0.08)';
+ (e.currentTarget as HTMLElement).style.color = 'rgba(248,113,113,0.85)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLElement).style.background = 'rgba(248,113,113,0.04)';
+ (e.currentTarget as HTMLElement).style.color = 'rgba(248,113,113,0.6)';
+ }}
+ >
+ Reset selected to defaults
+
+
)}
);
diff --git a/src/components/builder/components/ResetDialogue.tsx b/src/components/builder/components/ResetDialogue.tsx
index 5c86a24..69454f4 100644
--- a/src/components/builder/components/ResetDialogue.tsx
+++ b/src/components/builder/components/ResetDialogue.tsx
@@ -1,7 +1,7 @@
// src/components/builder/components/ResetDialog.tsx
-import React, { Fragment, memo } from 'react';
+import { Fragment, memo } from 'react';
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
-import { AlertTriangle } from 'lucide-react';
+import { AlertTriangle, RotateCcw, X } from 'lucide-react';
interface Props {
isOpen: boolean;
@@ -21,59 +21,105 @@ const ResetDialog = memo(({ isOpen, onClose, onConfirm }) => (
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
-
+
-
-
+
+
+
+
+
+ Reset Configuration
+
+
+ Keep poster · reset layout
+
+
+
+
+
+
+
+
+
+
+ Badge styling, layer positions, effects, and layout settings will be restored to
+ defaults. Media source, poster choice, and API keys are preserved.
+
+
-
-
- Reset Configuration
-
-
+
+ This cannot be undone unless you use history immediately after closing.
+
+
+
+
+
- Badge and layout settings will be restored to defaults. Your current poster will be
- kept. This action cannot be undone.
-
-
{
- (e.currentTarget as HTMLElement).style.borderColor = 'rgba(196,124,46,0.25)';
- (e.currentTarget as HTMLElement).style.color = 'var(--film-pale)';
- }}
- onMouseLeave={(e) => {
- (e.currentTarget as HTMLElement).style.borderColor = 'rgba(255,255,255,0.08)';
- (e.currentTarget as HTMLElement).style.color = 'var(--film-text-dim)';
- }}
>
Cancel
@@ -82,8 +128,15 @@ const ResetDialog = memo
(({ isOpen, onClose, onConfirm }) => (
onConfirm();
onClose();
}}
- className="flex-1 h-9 rounded-lg bg-red-600/90 border border-red-500/30 text-xs font-semibold text-white hover:bg-red-500 transition-all active:scale-[0.97] cursor-pointer tracking-wide uppercase select-none syne-font"
+ className="flex-1 h-9 rounded-lg text-xs font-semibold transition-all active:scale-[0.97] tracking-wide uppercase select-none syne-font inline-flex items-center justify-center gap-2"
+ style={{
+ background: 'rgba(220,38,38,0.92)',
+ border: '1px solid rgba(248,113,113,0.34)',
+ color: '#fff',
+ boxShadow: '0 0 20px rgba(220,38,38,0.22)',
+ }}
>
+
Reset All
diff --git a/src/components/builder/components/canvas/ZoomOverlay.tsx b/src/components/builder/components/canvas/ZoomOverlay.tsx
new file mode 100644
index 0000000..809da63
--- /dev/null
+++ b/src/components/builder/components/canvas/ZoomOverlay.tsx
@@ -0,0 +1,219 @@
+import { memo, useState } from 'react';
+import type React from 'react';
+import {
+ Grid3x3,
+ Magnet,
+ Maximize2,
+ Minimize2,
+ RotateCcw,
+ Settings2,
+ ShieldCheck,
+ ZoomIn,
+ ZoomOut,
+} from 'lucide-react';
+
+interface ViewOptions {
+ showSafeArea: boolean;
+ showGrid: boolean;
+ snapToGrid: boolean;
+}
+
+const OverlayButton = ({
+ title,
+ active,
+ onClick,
+ children,
+}: {
+ title: string;
+ active?: boolean;
+ onClick: () => void;
+ children: React.ReactNode;
+}) => (
+
{
+ (e.currentTarget as HTMLElement).style.color = 'var(--film-amber)';
+ (e.currentTarget as HTMLElement).style.background = 'rgba(196,124,46,0.1)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLElement).style.color = active
+ ? 'var(--film-amber)'
+ : 'var(--film-text-dim)';
+ (e.currentTarget as HTMLElement).style.background = active
+ ? 'rgba(196,124,46,0.1)'
+ : 'transparent';
+ }}
+ >
+ {children}
+
+);
+
+const ZoomOverlay = memo<{
+ isFullscreen: boolean;
+ rightSidebarWidth: number;
+ onToggleFullscreen: () => void;
+ onZoomIn: () => void;
+ onZoomOut: () => void;
+ onResetView: () => void;
+ isMobile: boolean;
+ viewOptions: ViewOptions;
+ onToggleViewOption: (key: keyof ViewOptions) => void;
+}>(
+ ({
+ isFullscreen,
+ rightSidebarWidth,
+ onToggleFullscreen,
+ onZoomIn,
+ onZoomOut,
+ onResetView,
+ isMobile,
+ viewOptions,
+ onToggleViewOption,
+ }) => {
+ const [settingsOpen, setSettingsOpen] = useState(false);
+
+ const placement = isMobile
+ ? { bottom: 76, right: 12 }
+ : {
+ top: '50%',
+ transform: 'translateY(-50%)',
+ right: isFullscreen ? 20 : rightSidebarWidth + 20,
+ transition: 'right 0.3s cubic-bezier(0.16,1,0.3,1)',
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
setSettingsOpen((v) => !v)}
+ >
+
+
+
+ {!isMobile && (
+
+ )}
+ {!isMobile && (
+
+ {isFullscreen ? : }
+
+ )}
+
+
+ {settingsOpen && (
+
+
+
+ Builder Settings
+
+
+ Canvas guides and movement behavior
+
+
+
+ {[
+ { key: 'showGrid' as const, label: 'Show grid lines', Icon: Grid3x3 },
+ { key: 'showSafeArea' as const, label: 'Show safe zones', Icon: ShieldCheck },
+ { key: 'snapToGrid' as const, label: 'Snap to grid', Icon: Magnet },
+ ].map(({ key, label, Icon }) => (
+ onToggleViewOption(key)}
+ className="w-full h-9 px-2.5 rounded-lg flex items-center justify-between gap-2 transition-all syne-font"
+ style={{
+ color: viewOptions[key] ? 'var(--film-cream)' : 'var(--film-text-dim)',
+ background: viewOptions[key]
+ ? 'rgba(196,124,46,0.1)'
+ : 'rgba(255,255,255,0.025)',
+ border: viewOptions[key]
+ ? '1px solid rgba(196,124,46,0.22)'
+ : '1px solid rgba(255,255,255,0.05)',
+ fontSize: 11,
+ }}
+ >
+
+
+ {label}
+
+
+ {viewOptions[key] ? 'ON' : 'OFF'}
+
+
+ ))}
+
+
+ )}
+
+ );
+ }
+);
+ZoomOverlay.displayName = 'ZoomOverlay';
+
+export default ZoomOverlay;
diff --git a/src/components/builder/components/layout/Inspector.tsx b/src/components/builder/components/layout/Inspector.tsx
index 8065763..f345057 100644
--- a/src/components/builder/components/layout/Inspector.tsx
+++ b/src/components/builder/components/layout/Inspector.tsx
@@ -1,23 +1,24 @@
import React, { memo } from 'react';
import { useEditor } from '../../context/EditorContext';
-import PropertyPanel from '../PropertyPanel';
+import { BadgesPanel, SelectionPanel } from '../panels';
import type { PosterConfig } from '../../types';
import { Badge, MousePointer2 } from 'lucide-react';
-import clsx from 'clsx';
import SidebarLayout from '../SidebarLayout';
+import PanelTabs from '../navigation/PanelTabs';
interface Props {
config: PosterConfig;
setConfig: React.Dispatch
>;
+ detailLevel?: 'simple' | 'advanced';
}
type InspectorTab = 'badges' | 'selection';
-const INACTIVE_TAB_HOVER_CLASSES = 'hover:bg-white/[0.05] hover:text-[var(--film-text-dim)]';
const isInspectorTab = (value: string): value is InspectorTab =>
value === 'badges' || value === 'selection';
-const Inspector: React.FC = memo(({ config, setConfig }) => {
- const { activeTab, setActiveTab, selectedIds, selectedLogo, selectedMinimalElements } = useEditor();
+const Inspector: React.FC = memo(({ config, setConfig, detailLevel = 'advanced' }) => {
+ const { activeTab, setActiveTab, selectedIds, selectedLogo, selectedMinimalElements } =
+ useEditor();
const selectedCount = selectedIds.size + (selectedLogo ? 1 : 0) + selectedMinimalElements.size;
const isMinimalPreset = (config.uiPreset ?? 'b') === 'm';
const hasBadges = config.ratings.length > 0;
@@ -33,7 +34,12 @@ const Inspector: React.FC = memo(({ config, setConfig }) => {
: 'Badges';
const tabs: { id: InspectorTab; label: string; Icon: React.ElementType; visible: boolean }[] = [
- { id: 'badges', label: primaryTabLabel, Icon: Badge, visible: hasBadges || hasLogo || isMinimalPreset },
+ {
+ id: 'badges',
+ label: primaryTabLabel,
+ Icon: Badge,
+ visible: hasBadges || hasLogo || isMinimalPreset,
+ },
{
id: 'selection',
label: selectedCount > 0 ? `${selectedCount} selected` : 'Selection',
@@ -53,43 +59,37 @@ const Inspector: React.FC = memo(({ config, setConfig }) => {
return (
- {visibleTabs.map(({ id, label, Icon }) => (
- setActiveTab(id)}
- aria-pressed={currentTab === id}
- className={clsx(
- 'flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all duration-150 outline-none select-none syne-font',
- currentTab !== id && INACTIVE_TAB_HOVER_CLASSES
- )}
- style={{
- background: currentTab === id ? 'var(--film-mid)' : 'transparent',
- color: currentTab === id ? 'var(--film-cream)' : 'var(--film-text-dim)',
- boxShadow: currentTab === id ? '0 1px 4px rgba(0,0,0,0.3)' : 'none',
- }}
- >
-
- {label}
-
- ))}
-
+ setActiveTab(id)}
+ tabs={visibleTabs.map(({ id, label, Icon }) => ({
+ id,
+ label,
+ icon: ,
+ }))}
+ />
}
>
-
+ {currentTab === 'badges' ? (
+
+ ) : (
+
+ )}
);
});
diff --git a/src/components/builder/components/navigation/AdvancedPanelNav.tsx b/src/components/builder/components/navigation/AdvancedPanelNav.tsx
new file mode 100644
index 0000000..c7d7373
--- /dev/null
+++ b/src/components/builder/components/navigation/AdvancedPanelNav.tsx
@@ -0,0 +1,65 @@
+import React, { memo } from 'react';
+import { Badge, Film, Layers, Monitor, MousePointer2 } from 'lucide-react';
+import clsx from 'clsx';
+
+export type BuilderPanelId = 'source' | 'layers' | 'poster' | 'badges' | 'selection';
+
+export const BUILDER_PANELS: Array<{
+ id: BuilderPanelId;
+ label: string;
+ description: string;
+ Icon: React.ElementType;
+}> = [
+ { id: 'source', label: 'Source', description: 'Media, IDs, source', Icon: Film },
+ { id: 'layers', label: 'Layers', description: 'Order and visibility', Icon: Layers },
+ { id: 'poster', label: 'Poster', description: 'Canvas and effects', Icon: Monitor },
+ { id: 'badges', label: 'Badges', description: 'Global badge style', Icon: Badge },
+ { id: 'selection', label: 'Selection', description: 'Selected layer edits', Icon: MousePointer2 },
+];
+
+interface Props {
+ activePanel: BuilderPanelId;
+ onChange: (panel: BuilderPanelId) => void;
+}
+
+const AdvancedPanelNav: React.FC = memo(({ activePanel, onChange }) => (
+
+
+ {BUILDER_PANELS.map(({ id, label, description, Icon }) => {
+ const active = id === activePanel;
+ return (
+ onChange(id)}
+ className={clsx(
+ 'w-full flex items-center gap-2 rounded-xl px-2.5 py-2.5 text-left transition-all outline-none focus-visible:ring-2 focus-visible:ring-[#C47C2E]',
+ !active && 'hover:bg-[rgba(196,124,46,0.07)]'
+ )}
+ style={{
+ background: active ? 'rgba(196,124,46,0.12)' : 'transparent',
+ border: active ? '1px solid rgba(196,124,46,0.22)' : '1px solid transparent',
+ color: active ? 'var(--film-cream)' : 'var(--film-text-dim)',
+ }}
+ >
+
+
+
+
+ {label}
+
+ {description}
+
+
+
+ );
+ })}
+
+
+));
+
+AdvancedPanelNav.displayName = 'AdvancedPanelNav';
+export default AdvancedPanelNav;
diff --git a/src/components/builder/components/navigation/ModeToggle.tsx b/src/components/builder/components/navigation/ModeToggle.tsx
new file mode 100644
index 0000000..2deeba8
--- /dev/null
+++ b/src/components/builder/components/navigation/ModeToggle.tsx
@@ -0,0 +1,38 @@
+import React, { memo } from 'react';
+
+export type BuilderMode = 'simple' | 'advanced';
+
+interface Props {
+ mode: BuilderMode;
+ onChange: (mode: BuilderMode) => void;
+}
+
+const ModeToggle: React.FC = memo(({ mode, onChange }) => (
+
+ {(['simple', 'advanced'] as const).map((item) => {
+ const active = mode === item;
+ return (
+ onChange(item)}
+ aria-pressed={active}
+ className="h-7 px-2.5 rounded-md syne-font text-[10px] font-bold uppercase tracking-wider transition-all"
+ style={{
+ color: active ? '#070706' : 'var(--film-text-dim)',
+ background: active ? 'var(--film-amber)' : 'transparent',
+ }}
+ >
+ {item}
+
+ );
+ })}
+
+));
+
+ModeToggle.displayName = 'ModeToggle';
+export default ModeToggle;
diff --git a/src/components/builder/components/navigation/PanelTabs.tsx b/src/components/builder/components/navigation/PanelTabs.tsx
new file mode 100644
index 0000000..d69dd4d
--- /dev/null
+++ b/src/components/builder/components/navigation/PanelTabs.tsx
@@ -0,0 +1,63 @@
+import React, { memo } from 'react';
+import clsx from 'clsx';
+
+export interface PanelTab {
+ id: T;
+ label: string;
+ icon?: React.ReactNode;
+ visible?: boolean;
+}
+
+interface PanelTabsProps {
+ tabs: readonly PanelTab[];
+ activeId: T;
+ onChange: (id: T) => void;
+ ariaLabel?: string;
+}
+
+function PanelTabsComponent({
+ tabs,
+ activeId,
+ onChange,
+ ariaLabel,
+}: PanelTabsProps) {
+ const visibleTabs = tabs.filter((tab) => tab.visible !== false);
+
+ return (
+
+ {visibleTabs.map((tab) => {
+ const isActive = activeId === tab.id;
+ return (
+ onChange(tab.id)}
+ aria-pressed={isActive}
+ className={clsx(
+ 'flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all duration-150 outline-none select-none capitalize syne-font',
+ !isActive && 'hover:bg-[rgba(196,124,46,0.08)] hover:text-[var(--film-text-label)]'
+ )}
+ style={{
+ background: isActive ? 'var(--film-mid)' : 'transparent',
+ color: isActive ? 'var(--film-cream)' : 'var(--film-text-dim)',
+ boxShadow: isActive ? '0 1px 4px rgba(0,0,0,0.3)' : 'none',
+ }}
+ >
+ {tab.icon}
+ {tab.label}
+
+ );
+ })}
+
+ );
+}
+
+const PanelTabs = memo(PanelTabsComponent) as typeof PanelTabsComponent;
+export default PanelTabs;
diff --git a/src/components/builder/components/panels/BadgesPanel.tsx b/src/components/builder/components/panels/BadgesPanel.tsx
new file mode 100644
index 0000000..ad4aa1a
--- /dev/null
+++ b/src/components/builder/components/panels/BadgesPanel.tsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import type { PosterConfig, RatingType } from '../../types';
+import PropertyPanel from '../PropertyPanel';
+
+interface Props {
+ config: PosterConfig;
+ setConfig: React.Dispatch>;
+ selectedIds: Set;
+ selectedLogo?: boolean;
+ selectedMinimalElements?: Set;
+ detailLevel?: 'simple' | 'advanced';
+}
+
+const BadgesPanel: React.FC = (props) => ;
+export default BadgesPanel;
diff --git a/src/components/builder/components/panels/LayersPanel.tsx b/src/components/builder/components/panels/LayersPanel.tsx
new file mode 100644
index 0000000..bb47a08
--- /dev/null
+++ b/src/components/builder/components/panels/LayersPanel.tsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import type { PosterConfig, RatingType } from '../../types';
+import LayerPanel from '../LayerPanel';
+
+interface Props {
+ config: PosterConfig;
+ setConfig: React.Dispatch>;
+ selectedIds: Set;
+ onSelect: (id: RatingType, multi: boolean) => void;
+ chrome?: boolean;
+ detailLevel?: 'simple' | 'advanced';
+}
+
+const LayersPanel: React.FC = ({ chrome = true, ...props }) => (
+
+);
+
+export default LayersPanel;
diff --git a/src/components/builder/components/panels/PosterPanel.tsx b/src/components/builder/components/panels/PosterPanel.tsx
new file mode 100644
index 0000000..3846539
--- /dev/null
+++ b/src/components/builder/components/panels/PosterPanel.tsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import type { PosterConfig, RatingType } from '../../types';
+import LayerPanel from '../LayerPanel';
+
+interface Props {
+ config: PosterConfig;
+ setConfig: React.Dispatch>;
+ selectedIds: Set;
+ onSelect: (id: RatingType, multi: boolean) => void;
+ chrome?: boolean;
+ detailLevel?: 'simple' | 'advanced';
+}
+
+const PosterPanel: React.FC = ({ chrome = true, ...props }) => (
+
+);
+
+export default PosterPanel;
diff --git a/src/components/builder/components/panels/SelectionPanel.tsx b/src/components/builder/components/panels/SelectionPanel.tsx
new file mode 100644
index 0000000..92b1ab7
--- /dev/null
+++ b/src/components/builder/components/panels/SelectionPanel.tsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import type { PosterConfig, RatingType } from '../../types';
+import PropertyPanel from '../PropertyPanel';
+
+interface Props {
+ config: PosterConfig;
+ setConfig: React.Dispatch>;
+ selectedIds: Set;
+ selectedLogo?: boolean;
+ selectedMinimalElements?: Set;
+ detailLevel?: 'simple' | 'advanced';
+}
+
+const SelectionPanel: React.FC = (props) => ;
+export default SelectionPanel;
diff --git a/src/components/builder/components/panels/SourcePanel.tsx b/src/components/builder/components/panels/SourcePanel.tsx
new file mode 100644
index 0000000..ce70474
--- /dev/null
+++ b/src/components/builder/components/panels/SourcePanel.tsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import type { PosterConfig, RatingType } from '../../types';
+import LayerPanel from '../LayerPanel';
+
+interface Props {
+ config: PosterConfig;
+ setConfig: React.Dispatch>;
+ selectedIds: Set;
+ onSelect: (id: RatingType, multi: boolean) => void;
+ chrome?: boolean;
+ detailLevel?: 'simple' | 'advanced';
+}
+
+const SourcePanel: React.FC = ({ chrome = true, ...props }) => (
+
+);
+
+export default SourcePanel;
diff --git a/src/components/builder/components/panels/index.ts b/src/components/builder/components/panels/index.ts
new file mode 100644
index 0000000..9c94990
--- /dev/null
+++ b/src/components/builder/components/panels/index.ts
@@ -0,0 +1,5 @@
+export { default as SourcePanel } from './SourcePanel';
+export { default as LayersPanel } from './LayersPanel';
+export { default as PosterPanel } from './PosterPanel';
+export { default as BadgesPanel } from './BadgesPanel';
+export { default as SelectionPanel } from './SelectionPanel';
diff --git a/src/components/builder/components/toolbar/ToolbarButton.tsx b/src/components/builder/components/toolbar/ToolbarButton.tsx
new file mode 100644
index 0000000..b81f9ec
--- /dev/null
+++ b/src/components/builder/components/toolbar/ToolbarButton.tsx
@@ -0,0 +1,106 @@
+import React, { memo } from 'react';
+
+interface ToolbarButtonProps {
+ onClick?: () => void;
+ disabled?: boolean;
+ label: string;
+ danger?: boolean;
+ href?: string;
+ active?: boolean;
+ children: React.ReactNode;
+ hideOnMobile?: boolean;
+}
+
+const ToolbarButton = memo(
+ ({ onClick, disabled, label, danger, href, active, children, hideOnMobile = false }) => {
+ const base = `relative group w-8 h-8 flex items-center justify-center rounded-lg transition-all duration-150 select-none outline-none focus-visible:ring-2 focus-visible:ring-[#C47C2E] ${hideOnMobile ? 'hidden lg:flex' : ''}`;
+ const cls = `${base} ${
+ disabled
+ ? 'cursor-not-allowed pointer-events-none'
+ : active
+ ? 'cursor-pointer'
+ : 'active:scale-95 cursor-pointer'
+ }`;
+
+ const activeStyle = active
+ ? {
+ color: 'var(--film-amber)',
+ background: 'rgba(196,124,46,0.1)',
+ border: '1px solid rgba(196,124,46,0.2)',
+ }
+ : disabled
+ ? { color: 'rgba(255,255,255,0.15)', border: '1px solid transparent', opacity: 0.5 }
+ : danger
+ ? { color: 'var(--film-text-dim)', border: '1px solid transparent' }
+ : { color: 'var(--film-text-dim)', border: '1px solid transparent' };
+
+ const tooltip = !disabled && (
+
+ {label}
+
+ );
+
+ const hoverEvents =
+ !disabled && !active
+ ? {
+ onMouseEnter: (e: React.MouseEvent) => {
+ const el = e.currentTarget as HTMLElement;
+ if (danger) {
+ el.style.color = 'rgba(248,113,113,0.8)';
+ el.style.background = 'rgba(248,113,113,0.08)';
+ } else {
+ el.style.color = 'var(--film-text-label)';
+ el.style.background = 'rgba(196,124,46,0.07)';
+ }
+ },
+ onMouseLeave: (e: React.MouseEvent) => {
+ const el = e.currentTarget as HTMLElement;
+ el.style.color = 'var(--film-text-dim)';
+ el.style.background = 'transparent';
+ },
+ }
+ : {};
+
+ if (href) {
+ return (
+
+ {children}
+ {tooltip}
+
+ );
+ }
+
+ return (
+
+ {children}
+ {tooltip}
+
+ );
+ }
+);
+ToolbarButton.displayName = 'ToolbarButton';
+
+export default ToolbarButton;
diff --git a/src/components/builder/components/ui/ColorRow.tsx b/src/components/builder/components/ui/ColorRow.tsx
new file mode 100644
index 0000000..edcb034
--- /dev/null
+++ b/src/components/builder/components/ui/ColorRow.tsx
@@ -0,0 +1,58 @@
+import React from 'react';
+import ColorPicker from '../ColorPicker';
+
+interface ColorRowProps {
+ label: string;
+ value: string;
+ onChange: (v: string) => void;
+ onReset?: () => void;
+ showOpacity?: boolean;
+ opacity?: number;
+ onOpacityChange?: (v: number) => void;
+}
+
+const ColorRow: React.FC = ({
+ label,
+ value,
+ onChange,
+ onReset,
+ showOpacity,
+ opacity,
+ onOpacityChange,
+}) => (
+
+
+
+ {label}
+
+ {onReset && (
+ {
+ (e.currentTarget as HTMLElement).style.color = 'var(--film-text-label)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLElement).style.color = 'var(--film-text-dim)';
+ }}
+ >
+ Reset
+
+ )}
+
+
+
+);
+
+export default ColorRow;
diff --git a/src/components/builder/components/ui/Section.tsx b/src/components/builder/components/ui/Section.tsx
new file mode 100644
index 0000000..de2f679
--- /dev/null
+++ b/src/components/builder/components/ui/Section.tsx
@@ -0,0 +1,73 @@
+import React, { useCallback, useState } from 'react';
+import { ChevronDown, ChevronRight } from 'lucide-react';
+import { readSectionState, writeSectionState } from './sectionStorage';
+
+interface SectionProps {
+ title: string;
+ icon?: React.ReactNode;
+ children: React.ReactNode;
+ defaultOpen?: boolean;
+ sectionId?: string;
+ inset?: 'compact' | 'normal';
+}
+
+const Section: React.FC = ({
+ title,
+ icon,
+ children,
+ defaultOpen = true,
+ sectionId,
+ inset = 'normal',
+}) => {
+ const [open, setOpen] = useState(() =>
+ sectionId ? readSectionState(sectionId, defaultOpen) : defaultOpen
+ );
+ const x = inset === 'compact' ? 'px-1' : 'px-3';
+ const mx = inset === 'compact' ? 'mx-1' : 'mx-3';
+
+ const toggle = useCallback(() => {
+ setOpen((v) => {
+ const next = !v;
+ if (sectionId) writeSectionState(sectionId, next);
+ return next;
+ });
+ }, [sectionId]);
+
+ return (
+
+
+
+ {icon && (
+ {icon}
+ )}
+ {title}
+
+
+ {open ? : }
+
+
+ {open &&
{children}
}
+
+
+ );
+};
+
+export default Section;
diff --git a/src/components/builder/components/ui/SegmentedRow.tsx b/src/components/builder/components/ui/SegmentedRow.tsx
new file mode 100644
index 0000000..0a8fb99
--- /dev/null
+++ b/src/components/builder/components/ui/SegmentedRow.tsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import clsx from 'clsx';
+
+const INACTIVE_OPTION_HOVER_CLASSES =
+ 'bg-[rgba(255,255,255,0.03)] text-[var(--film-text-label)] border-[rgba(255,255,255,0.05)] hover:bg-[rgba(255,255,255,0.07)] hover:border-[rgba(196,124,46,0.24)] hover:text-[var(--film-cream)]';
+
+interface SegmentedRowProps {
+ label: string;
+ options: { id: string; label: string }[];
+ value: string | null;
+ onChange: (v: string) => void;
+ uppercaseLabel?: boolean;
+ size?: 'sm' | 'md';
+}
+
+const SegmentedRow: React.FC = ({
+ label,
+ options,
+ value,
+ onChange,
+ uppercaseLabel = false,
+ size = 'sm',
+}) => (
+
+ {uppercaseLabel ? (
+
+ {label}
+
+ ) : (
+
+ {label}
+
+ )}
+
+ {options.map((opt) => (
+ onChange(opt.id)}
+ className={clsx(
+ `${size === 'md' ? 'h-8' : 'h-7'} rounded-md text-[10px] font-medium transition-all border syne-font`,
+ value === opt.id
+ ? 'bg-[rgba(196,124,46,0.15)] text-[var(--film-pale)] border-[rgba(196,124,46,0.3)]'
+ : INACTIVE_OPTION_HOVER_CLASSES
+ )}
+ >
+ {opt.label}
+
+ ))}
+
+
+);
+
+export default SegmentedRow;
diff --git a/src/components/builder/components/ui/SelectBox.tsx b/src/components/builder/components/ui/SelectBox.tsx
new file mode 100644
index 0000000..3dc28e0
--- /dev/null
+++ b/src/components/builder/components/ui/SelectBox.tsx
@@ -0,0 +1,69 @@
+import { memo } from 'react';
+import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react';
+import { Check, ChevronDown } from 'lucide-react';
+import clsx from 'clsx';
+
+interface SelectBoxProps {
+ value: string;
+ onChange: (v: string) => void;
+ options: { id: string; label: string }[];
+}
+
+const SelectBox = memo(({ value, onChange, options }) => (
+
+
+ {
+ (e.currentTarget as HTMLElement).style.borderColor = 'rgba(196,124,46,0.4)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLElement).style.borderColor = 'rgba(255,255,255,0.1)';
+ }}
+ >
+ {options.find((o) => o.id === value)?.label ?? value}
+
+
+
+ {options.map((opt) => (
+
+ clsx(
+ 'flex items-center gap-2 px-3 py-2.5 cursor-pointer transition-colors syne-font',
+ active && 'bg-[rgba(196,124,46,0.1)]',
+ !active && selected && 'text-[var(--film-pale)]',
+ !active && !selected && 'text-[var(--film-text-label)]'
+ )
+ }
+ >
+ {({ selected }) => (
+ <>
+ {opt.label}
+ {selected && (
+
+ )}
+ >
+ )}
+
+ ))}
+
+
+
+));
+SelectBox.displayName = 'SelectBox';
+
+export default SelectBox;
diff --git a/src/components/builder/components/ui/SliderRow.tsx b/src/components/builder/components/ui/SliderRow.tsx
new file mode 100644
index 0000000..ea57ac9
--- /dev/null
+++ b/src/components/builder/components/ui/SliderRow.tsx
@@ -0,0 +1,169 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { RotateCcw } from 'lucide-react';
+
+interface SliderRowProps {
+ label: string;
+ value: number;
+ onChange: (v: number) => void;
+ onReset?: () => void;
+ min: number;
+ max: number;
+ step?: number;
+ unit?: string;
+ formatValue?: (v: number) => string;
+}
+
+const SliderRow: React.FC = ({
+ label,
+ value,
+ onChange,
+ onReset,
+ min,
+ max,
+ step = 1,
+ unit = '',
+ formatValue,
+}) => {
+ const [localValue, setLocalValue] = useState(value);
+ const [inputText, setInputText] = useState(() => (formatValue ? formatValue(value) : `${value}`));
+ const lastUpdate = useRef(Date.now());
+ const timeoutRef = useRef(null);
+ const inputRef = useRef(null);
+ const isFocused = useRef(false);
+
+ useEffect(() => {
+ setLocalValue(value);
+ if (!isFocused.current) setInputText(formatValue ? formatValue(value) : `${value}`);
+ }, [value, formatValue]);
+
+ const commitInput = useCallback(
+ (text: string) => {
+ const raw = text.replace(unit, '').replace(/[^0-9.\-]/g, '');
+ const n = parseFloat(raw);
+ if (!isNaN(n)) {
+ const clamped = Math.max(min, Math.min(max, n));
+ setLocalValue(clamped);
+ setInputText(formatValue ? formatValue(clamped) : `${clamped}`);
+ onChange(clamped);
+ } else {
+ setInputText(formatValue ? formatValue(localValue) : `${localValue}`);
+ }
+ },
+ [min, max, onChange, unit, formatValue, localValue]
+ );
+
+ const handleRangeChange = useCallback(
+ (e: React.ChangeEvent) => {
+ const val = parseFloat(e.target.value);
+ setLocalValue(val);
+ if (!isFocused.current) setInputText(formatValue ? formatValue(val) : `${val}`);
+ const now = Date.now();
+ if (now - lastUpdate.current > 33) {
+ onChange(val);
+ lastUpdate.current = now;
+ } else {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ timeoutRef.current = setTimeout(() => {
+ onChange(val);
+ lastUpdate.current = Date.now();
+ }, 33);
+ }
+ },
+ [onChange, formatValue]
+ );
+
+ const handleInputKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ commitInput(inputText);
+ inputRef.current?.blur();
+ } else if (e.key === 'Escape') {
+ setInputText(formatValue ? formatValue(localValue) : `${localValue}`);
+ inputRef.current?.blur();
+ } else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
+ e.preventDefault();
+ const delta = e.key === 'ArrowUp' ? step : -step;
+ const newVal = Math.max(min, Math.min(max, localValue + delta));
+ setLocalValue(newVal);
+ setInputText(formatValue ? formatValue(newVal) : `${newVal}`);
+ onChange(newVal);
+ }
+ };
+
+ return (
+
+
+
+ {label}
+
+ {onReset && (
+ {
+ (e.currentTarget as HTMLElement).style.color = 'var(--film-text-label)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLElement).style.color = 'var(--film-text-dim)';
+ }}
+ >
+
+ Reset
+
+ )}
+
+
+ setInputText(e.target.value)}
+ onFocus={() => {
+ isFocused.current = true;
+ }}
+ onBlur={() => {
+ isFocused.current = false;
+ commitInput(inputText);
+ }}
+ onKeyDown={handleInputKeyDown}
+ className="mono-font tabular-nums focus:outline-none shrink-0"
+ style={{
+ width: 48,
+ height: 22,
+ paddingInline: 5,
+ borderRadius: 4,
+ background: 'rgba(255,255,255,0.04)',
+ border: '1px solid rgba(255,255,255,0.1)',
+ fontSize: 10,
+ color: 'var(--film-pale)',
+ textAlign: 'center',
+ transition: 'border-color 0.15s',
+ }}
+ onMouseEnter={(e) => {
+ (e.currentTarget as HTMLInputElement).style.borderColor = 'rgba(196,124,46,0.4)';
+ }}
+ onMouseLeave={(e) => {
+ if (!isFocused.current)
+ (e.currentTarget as HTMLInputElement).style.borderColor = 'rgba(255,255,255,0.1)';
+ }}
+ />
+
+
+
+ );
+};
+
+export default SliderRow;
diff --git a/src/components/builder/components/ui/TextInputRow.tsx b/src/components/builder/components/ui/TextInputRow.tsx
new file mode 100644
index 0000000..aa2856c
--- /dev/null
+++ b/src/components/builder/components/ui/TextInputRow.tsx
@@ -0,0 +1,71 @@
+import React, { useRef, useState } from 'react';
+
+interface TextInputRowProps {
+ label: string;
+ value: string;
+ placeholder?: string;
+ onChange: (v: string) => void;
+ onClear?: () => void;
+}
+
+const TextInputRow: React.FC = ({
+ label,
+ value,
+ placeholder,
+ onChange,
+ onClear,
+}) => {
+ const inputRef = useRef(null);
+ const [focused, setFocused] = useState(false);
+
+ return (
+
+
+
+ {label}
+
+ {onClear && value && (
+ {
+ (e.currentTarget as HTMLElement).style.color = 'var(--film-text-label)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLElement).style.color = 'var(--film-text-dim)';
+ }}
+ >
+ Clear
+
+ )}
+
+
onChange(e.target.value)}
+ onFocus={() => setFocused(true)}
+ onBlur={() => setFocused(false)}
+ className="w-full focus:outline-none body-font hover:border-[rgba(196,124,46,0.28)]"
+ style={{
+ height: 28,
+ paddingInline: 8,
+ borderRadius: 6,
+ background: 'rgba(255,255,255,0.04)',
+ border: `1px solid ${focused ? 'rgba(196,124,46,0.4)' : 'rgba(255,255,255,0.1)'}`,
+ fontSize: 11,
+ color: 'var(--film-pale)',
+ transition: 'border-color 0.15s',
+ }}
+ />
+
+ );
+};
+
+export default TextInputRow;
diff --git a/src/components/builder/components/ui/ToggleRow.tsx b/src/components/builder/components/ui/ToggleRow.tsx
new file mode 100644
index 0000000..f54dbe7
--- /dev/null
+++ b/src/components/builder/components/ui/ToggleRow.tsx
@@ -0,0 +1,59 @@
+import React from 'react';
+import { Switch } from '@headlessui/react';
+import clsx from 'clsx';
+
+interface ToggleRowProps {
+ label: string;
+ sub?: string;
+ checked: boolean;
+ onChange: (v: boolean) => void;
+ small?: boolean;
+ disabled?: boolean;
+}
+
+const ToggleRow: React.FC = ({
+ label,
+ sub,
+ checked,
+ onChange,
+ small,
+ disabled,
+}) => (
+
+
+
+ {label}
+
+ {sub && (
+
+ {sub}
+
+ )}
+
+
+
+
+
+);
+
+export default ToggleRow;
diff --git a/src/components/builder/components/ui/index.ts b/src/components/builder/components/ui/index.ts
new file mode 100644
index 0000000..36d1e2d
--- /dev/null
+++ b/src/components/builder/components/ui/index.ts
@@ -0,0 +1,7 @@
+export { default as Section } from './Section';
+export { default as SliderRow } from './SliderRow';
+export { default as ToggleRow } from './ToggleRow';
+export { default as SegmentedRow } from './SegmentedRow';
+export { default as SelectBox } from './SelectBox';
+export { default as TextInputRow } from './TextInputRow';
+export { default as ColorRow } from './ColorRow';
diff --git a/src/components/builder/components/ui/sectionStorage.ts b/src/components/builder/components/ui/sectionStorage.ts
new file mode 100644
index 0000000..295e24c
--- /dev/null
+++ b/src/components/builder/components/ui/sectionStorage.ts
@@ -0,0 +1,21 @@
+const SECTION_STORAGE_KEY = 'posterium_section_states_v2';
+
+const readSectionStates = (): Record => {
+ try {
+ return JSON.parse(localStorage.getItem(SECTION_STORAGE_KEY) || '{}');
+ } catch {
+ return {};
+ }
+};
+
+export const writeSectionState = (id: string, open: boolean) => {
+ try {
+ const s = readSectionStates();
+ localStorage.setItem(SECTION_STORAGE_KEY, JSON.stringify({ ...s, [id]: open }));
+ } catch {}
+};
+
+export const readSectionState = (id: string, fallback: boolean): boolean => {
+ const states = readSectionStates();
+ return id in states ? states[id] : fallback;
+};
diff --git a/src/components/builder/index.tsx b/src/components/builder/index.tsx
index 3a479d8..62e3f05 100644
--- a/src/components/builder/index.tsx
+++ b/src/components/builder/index.tsx
@@ -1,13 +1,37 @@
// src/components/builder/index.tsx
-import React, { useState, useEffect, useRef, useCallback, memo } from 'react';
+import React, { useState, useEffect, useRef, useCallback } from 'react';
import clsx from 'clsx';
import type { PosterConfig, ExtensionType, ApiKeys, RatingType } from './types';
-import { DEFAULT_CONFIG, ALL_BADGES, CANVAS_WIDTH, CANVAS_HEIGHT, BASE_BADGE_W, BASE_BADGE_H } from './types';
+import {
+ DEFAULT_CONFIG,
+ ALL_BADGES,
+ CANVAS_WIDTH,
+ CANVAS_HEIGHT,
+ BASE_BADGE_W,
+ BASE_BADGE_H,
+} from './types';
import { parseUrlToConfig, DEFAULT_API_BASE, calculateAutoPosition, getScale } from './utils';
+import {
+ BUILDER_STORAGE_KEY,
+ MAX_QUERY_CONFIG_LENGTH,
+ loadKeysFromCookie,
+ saveKeysToCookie,
+} from './systems/storage/builderStorage';
import PreviewCanvas from './components/PreviewCanvas';
import LayerPanel from './components/LayerPanel';
import Inspector from './components/layout/Inspector';
+import AdvancedPanelNav, { type BuilderPanelId } from './components/navigation/AdvancedPanelNav';
+import ModeToggle, { type BuilderMode } from './components/navigation/ModeToggle';
+import {
+ SourcePanel,
+ LayersPanel,
+ PosterPanel,
+ BadgesPanel,
+ SelectionPanel,
+} from './components/panels';
import MobileDock from './components/layout/MobileDock';
+import ToolbarBtn from './components/toolbar/ToolbarButton';
+import ZoomOverlay from './components/canvas/ZoomOverlay';
import KeyboardShortcutsModal from './components/KeyboardShortcutsModal';
import ResetDialog from './components/ResetDialogue';
import ImportDialog from './components/ImportDialogue';
@@ -39,239 +63,11 @@ import {
Type,
ChevronDown,
Search,
- Coffee,
} from 'lucide-react';
import { usePosterHistory } from './hooks/usePosterHistory';
import ContextMenu, { type ContextMenuState, type LayerTargetId } from './components/ContextMenu';
import CommandPalette, { type PaletteCommand } from './components/CommandPalette';
-const STORAGE_KEY = 'posterium_config_v2';
-const MAX_QUERY_CONFIG_LENGTH = 12000; // Guard against oversized URL payloads/memory abuse in base64 config loading.
-
-// ── Cookie helpers ────────────────────────────────────────────────────────────
-const COOKIE_KEY = 'posterium_apikeys_v1';
-const saveKeysToCookie = (keys: ApiKeys) => {
- try {
- const val = encodeURIComponent(JSON.stringify(keys));
- const exp = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toUTCString();
- document.cookie = `${COOKIE_KEY}=${val}; expires=${exp}; path=/; SameSite=Strict`;
- } catch {
- /* ignore */
- }
-};
-const loadKeysFromCookie = (): ApiKeys => {
- try {
- const match = document.cookie.match(new RegExp(`(?:^|; )${COOKIE_KEY}=([^;]*)`));
- if (!match) return {};
- return JSON.parse(decodeURIComponent(match[1])) || {};
- } catch {
- return {};
- }
-};
-
-// ── Toolbar button ──────────────────────────────────────────────────────────
-interface ToolbarBtnProps {
- onClick?: () => void;
- disabled?: boolean;
- label: string;
- danger?: boolean;
- href?: string;
- active?: boolean;
- children: React.ReactNode;
- hideOnMobile?: boolean;
-}
-const ToolbarBtn = memo(
- ({ onClick, disabled, label, danger, href, active, children, hideOnMobile = false }) => {
- const base = `relative group w-8 h-8 flex items-center justify-center rounded-lg transition-all duration-150 select-none outline-none focus-visible:ring-2 focus-visible:ring-[#C47C2E] ${hideOnMobile ? 'hidden lg:flex' : ''}`;
- const cls = `${base} ${
- disabled
- ? 'cursor-not-allowed pointer-events-none'
- : active
- ? 'cursor-pointer'
- : 'active:scale-95 cursor-pointer'
- }`;
-
- const activeStyle = active
- ? {
- color: 'var(--film-amber)',
- background: 'rgba(196,124,46,0.1)',
- border: '1px solid rgba(196,124,46,0.2)',
- }
- : disabled
- ? { color: 'rgba(255,255,255,0.15)', border: '1px solid transparent', opacity: 0.5 }
- : danger
- ? { color: 'var(--film-text-dim)', border: '1px solid transparent' }
- : { color: 'var(--film-text-dim)', border: '1px solid transparent' };
-
- const tooltip = !disabled && (
-
- {label}
-
- );
-
- const hoverEvents =
- !disabled && !active
- ? {
- onMouseEnter: (e: React.MouseEvent) => {
- const el = e.currentTarget as HTMLElement;
- if (danger) {
- el.style.color = 'rgba(248,113,113,0.8)';
- el.style.background = 'rgba(248,113,113,0.08)';
- } else {
- el.style.color = 'var(--film-text-label)';
- el.style.background = 'rgba(196,124,46,0.07)';
- }
- },
- onMouseLeave: (e: React.MouseEvent) => {
- const el = e.currentTarget as HTMLElement;
- el.style.color = 'var(--film-text-dim)';
- el.style.background = 'transparent';
- },
- }
- : {};
-
- if (href)
- return (
-
- {children}
- {tooltip}
-
- );
- return (
-
- {children}
- {tooltip}
-
- );
- }
-);
-ToolbarBtn.displayName = 'ToolbarBtn';
-
-// ── Zoom/Fullscreen Overlay ───────────────────────────────────────────────────
-const ZoomOverlay = memo<{
- isFullscreen: boolean;
- rightSidebarWidth: number;
- onToggleFullscreen: () => void;
- onZoomIn: () => void;
- onZoomOut: () => void;
- onResetView: () => void;
- isMobile: boolean;
-}>(
- ({
- isFullscreen,
- rightSidebarWidth,
- onToggleFullscreen,
- onZoomIn,
- onZoomOut,
- onResetView,
- isMobile,
- }) => (
-
- {[
- { icon:
, label: 'Zoom In', action: onZoomIn },
- { icon:
, label: 'Zoom Out', action: onZoomOut },
- { icon:
, label: 'Reset View', action: onResetView },
- ].map(({ icon, label, action }) => (
-
{
- (e.currentTarget as HTMLElement).style.color = 'var(--film-amber)';
- (e.currentTarget as HTMLElement).style.background = 'rgba(196,124,46,0.1)';
- }}
- onMouseLeave={(e) => {
- (e.currentTarget as HTMLElement).style.color = 'var(--film-text-dim)';
- (e.currentTarget as HTMLElement).style.background = 'transparent';
- }}
- >
- {icon}
-
- ))}
- {/* Divider — hidden on mobile */}
- {!isMobile && (
-
- )}
- {/* Fullscreen toggle — desktop only */}
- {!isMobile && (
-
{
- (e.currentTarget as HTMLElement).style.color = 'var(--film-amber)';
- (e.currentTarget as HTMLElement).style.background = 'rgba(196,124,46,0.1)';
- }}
- onMouseLeave={(e) => {
- (e.currentTarget as HTMLElement).style.color = isFullscreen
- ? 'rgba(196,124,46,0.7)'
- : 'var(--film-text-dim)';
- (e.currentTarget as HTMLElement).style.background = 'transparent';
- }}
- >
- {isFullscreen ? : }
-
- )}
-
- )
-);
-ZoomOverlay.displayName = 'ZoomOverlay';
-
// ── Studio layout ─────────────────────────────────────────────────────────────
const StudioLayout: React.FC<{
config: PosterConfig;
@@ -283,6 +79,7 @@ const StudioLayout: React.FC<{
redo: () => void;
canUndo: boolean;
canRedo: boolean;
+ initialMode?: BuilderMode;
}> = ({
config,
setConfig,
@@ -293,9 +90,11 @@ const StudioLayout: React.FC<{
redo,
canUndo,
canRedo,
+ initialMode = 'simple',
}) => {
const {
activeTab,
+ setActiveTab,
mobileSheetMode,
setMobileSheetMode,
selectedIds,
@@ -309,6 +108,8 @@ const StudioLayout: React.FC<{
toggleViewOption,
} = useEditor();
+ const [builderMode, setBuilderMode] = useState(initialMode);
+ const [advancedPanel, setAdvancedPanel] = useState('source');
const [isResetOpen, setIsResetOpen] = useState(false);
const [isImportOpen, setIsImportOpen] = useState(false);
const [leftVisible, setLeftVisible] = useState(true);
@@ -320,6 +121,20 @@ const StudioLayout: React.FC<{
const exportBtnRef = useRef(null);
const toggleFullscreen = useCallback(() => setIsFullscreen((v) => !v), []);
+ useEffect(() => {
+ if (['source', 'layers', 'poster', 'badges', 'selection'].includes(activeTab)) {
+ setAdvancedPanel(activeTab as BuilderPanelId);
+ }
+ }, [activeTab]);
+
+ const switchAdvancedPanel = useCallback(
+ (panel: BuilderPanelId) => {
+ setAdvancedPanel(panel);
+ setActiveTab(panel);
+ },
+ [setActiveTab]
+ );
+
const [isDesktop, setIsDesktop] = useState(
() => typeof window !== 'undefined' && window.innerWidth >= 1024
);
@@ -397,7 +212,9 @@ const StudioLayout: React.FC<{
}
if (hasLogo) {
const currentX =
- next.logoX !== null && next.logoX !== undefined ? next.logoX : Math.round((CANVAS_WIDTH - next.logoW) / 2);
+ next.logoX !== null && next.logoX !== undefined
+ ? next.logoX
+ : Math.round((CANVAS_WIDTH - next.logoW) / 2);
next.logoX = Math.max(1 - next.logoW, Math.min(currentX + dx, CANVAS_WIDTH - 1));
next.logoY = Math.max(1 - next.logoH, Math.min(next.logoY + dy, CANVAS_HEIGHT - 1));
}
@@ -412,8 +229,14 @@ const StudioLayout: React.FC<{
: Math.max(0, Math.min(CANVAS_HEIGHT - boxH, next.minimalTextY + dy));
}
if (activeMinimal.includes('minimal-year')) {
- next.minimalMetaX = Math.max(0, Math.min(CANVAS_WIDTH - 120, (next.minimalMetaX ?? 26) + dx));
- next.minimalMetaY = Math.max(0, Math.min(CANVAS_HEIGHT - 40, (next.minimalMetaY ?? 672) + dy));
+ next.minimalMetaX = Math.max(
+ 0,
+ Math.min(CANVAS_WIDTH - 120, (next.minimalMetaX ?? 26) + dx)
+ );
+ next.minimalMetaY = Math.max(
+ 0,
+ Math.min(CANVAS_HEIGHT - 40, (next.minimalMetaY ?? 672) + dy)
+ );
}
if (activeMinimal.includes('minimal-duration')) {
next.minimalDurationX = Math.max(
@@ -430,7 +253,7 @@ const StudioLayout: React.FC<{
activeMinimal
.filter((id) => id.startsWith('minimal-rating-'))
.forEach((id) => {
- const idx = Number(id.split('-').at(-1) ?? -1);
+ const idx = Number(id.split('-').pop() ?? -1);
if (!Number.isFinite(idx) || !list[idx]) return;
list[idx] = {
...list[idx],
@@ -610,7 +433,10 @@ const StudioLayout: React.FC<{
}
if (inInput) return;
if (
- (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') &&
+ (e.key === 'ArrowUp' ||
+ e.key === 'ArrowDown' ||
+ e.key === 'ArrowLeft' ||
+ e.key === 'ArrowRight') &&
(selectedIdsRef.current.size > 0 ||
selectedLogoRef.current ||
selectedMinimalElementsRef.current.size > 0)
@@ -1052,6 +878,38 @@ const StudioLayout: React.FC<{
: selectedIds.has(ctxMenu.badgeId)
: false;
+ const sharedPanelProps = {
+ config,
+ setConfig,
+ selectedIds,
+ onSelect: handleSelectionOverride,
+ detailLevel: 'advanced' as const,
+ };
+ const sharedInspectorProps = {
+ config,
+ setConfig,
+ selectedIds,
+ selectedLogo,
+ selectedMinimalElements,
+ detailLevel: 'advanced' as const,
+ };
+ const renderAdvancedPanel = () => {
+ switch (advancedPanel) {
+ case 'source':
+ return ;
+ case 'layers':
+ return ;
+ case 'poster':
+ return ;
+ case 'badges':
+ return ;
+ case 'selection':
+ return ;
+ default:
+ return null;
+ }
+ };
+
return (
<>
(id === 'logo' ? moveLogoLayer('toback') : moveLayer(id, 'toback'))}
onHide={hideLayer}
onShowAll={showAllBadges}
- onSelect={(id) => (id === 'logo' ? handleLogoSelection(false) : handleSelectionOverride(id, false))}
+ onSelect={(id) =>
+ id === 'logo' ? handleLogoSelection(false) : handleSelectionOverride(id, false)
+ }
onDeselect={() => clearSelection()}
onSelectAll={() => setBatchSelection(config.ratings)}
onDeselectAll={clearSelection}
@@ -1180,16 +1040,7 @@ const StudioLayout: React.FC<{
P
-
-
-
- Support
-
-
+
setPaletteOpen(true)}
title="Search commands (⌘K)"
@@ -1408,12 +1259,17 @@ const StudioLayout: React.FC<{
opacity: leftVisible ? 1 : 0,
}}
>
-
+ {builderMode === 'advanced' ? (
+
+ ) : (
+
+ )}
-
+ {builderMode === 'advanced' ? (
+ renderAdvancedPanel()
+ ) : (
+
+ )}
)}
@@ -1540,10 +1400,11 @@ const StudioLayout: React.FC<{
setConfig={setConfig}
selectedIds={selectedIds}
onSelect={handleSelectionOverride}
+ detailLevel={builderMode}
/>
)}
{(activeTab === 'badges' || activeTab === 'selection') && (
-
+
)}
@@ -1567,6 +1428,8 @@ const StudioLayout: React.FC<{
onZoomOut={() => dispatchZoom(-0.25)}
onResetView={dispatchResetView}
isMobile={!isDesktop}
+ viewOptions={viewOptions}
+ onToggleViewOption={toggleViewOption}
/>
>
@@ -1574,7 +1437,7 @@ const StudioLayout: React.FC<{
};
// ── Root app ──────────────────────────────────────────────────────────────────
-const BuilderApp: React.FC = () => {
+const BuilderApp: React.FC<{ initialMode?: BuilderMode }> = ({ initialMode = 'simple' }) => {
const {
state: config,
setState: setConfig,
@@ -1584,7 +1447,7 @@ const BuilderApp: React.FC = () => {
canRedo,
} = usePosterHistory(() => {
try {
- const saved = localStorage.getItem(STORAGE_KEY);
+ const saved = localStorage.getItem(BUILDER_STORAGE_KEY);
const cfg = saved ? (JSON.parse(saved) as PosterConfig) : DEFAULT_CONFIG;
const cookieKeys = loadKeysFromCookie();
if (cookieKeys && Object.keys(cookieKeys).some((k) => cookieKeys[k as keyof ApiKeys])) {
@@ -1599,7 +1462,7 @@ const BuilderApp: React.FC = () => {
const [baseUrl, setBaseUrl] = useState(DEFAULT_API_BASE);
useEffect(() => {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
+ localStorage.setItem(BUILDER_STORAGE_KEY, JSON.stringify(config));
}, [config]);
useEffect(() => {
@@ -1674,6 +1537,7 @@ const BuilderApp: React.FC = () => {
redo={redo}
canUndo={canUndo}
canRedo={canRedo}
+ initialMode={initialMode}
/>
);
diff --git a/src/components/builder/systems/storage/builderStorage.ts b/src/components/builder/systems/storage/builderStorage.ts
new file mode 100644
index 0000000..77323fc
--- /dev/null
+++ b/src/components/builder/systems/storage/builderStorage.ts
@@ -0,0 +1,25 @@
+import type { ApiKeys } from '../../types';
+
+export const BUILDER_STORAGE_KEY = 'posterium_config_v2';
+export const MAX_QUERY_CONFIG_LENGTH = 12000;
+const COOKIE_KEY = 'posterium_apikeys_v1';
+
+export const saveKeysToCookie = (keys: ApiKeys) => {
+ try {
+ const val = encodeURIComponent(JSON.stringify(keys));
+ const exp = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toUTCString();
+ document.cookie = `${COOKIE_KEY}=${val}; expires=${exp}; path=/; SameSite=Strict`;
+ } catch {
+ /* ignore */
+ }
+};
+
+export const loadKeysFromCookie = (): ApiKeys => {
+ try {
+ const match = document.cookie.match(new RegExp(`(?:^|; )${COOKIE_KEY}=([^;]*)`));
+ if (!match) return {};
+ return JSON.parse(decodeURIComponent(match[1])) || {};
+ } catch {
+ return {};
+ }
+};
diff --git a/src/pages/abuild.astro b/src/pages/abuild.astro
index 57e13b6..63bd1b6 100644
--- a/src/pages/abuild.astro
+++ b/src/pages/abuild.astro
@@ -1,23 +1,23 @@
----
-// src/pages/abuild.astro
-import BaseLayout from '@/layouts/BaseLayout.astro';
-import PageSEO from '@/components/seo/PageSEO.astro';
-import AdvancedBuilderApp from '@/components/builder/AdvancedBuilderApp.tsx';
-import '@/styles/global.css';
----
-
-
-
-
-
-
Posterium Advanced Builder
-
Advanced visual editor with vertical panel navigation for creating custom movie and TV posters with live rating badges.
-
-
-
-
\ No newline at end of file
+---
+// src/pages/abuild.astro
+import BaseLayout from '@/layouts/BaseLayout.astro';
+import PageSEO from '@/components/seo/PageSEO.astro';
+import BuilderApp from '@/components/builder/index.tsx';
+import '@/styles/global.css';
+---
+
+
+
+
+
+
Posterium Advanced Builder
+
Advanced visual editor with vertical panel navigation for creating custom movie and TV posters with live rating badges.
+
+
+
+