diff --git a/src/components/builder/AdvancedBuilderApp.tsx b/src/components/builder/AdvancedBuilderApp.tsx
index d17759d..b38cc8f 100644
--- a/src/components/builder/AdvancedBuilderApp.tsx
+++ b/src/components/builder/AdvancedBuilderApp.tsx
@@ -1,956 +1,1586 @@
-// 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 ;
-});
-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 */}
-
-
- {/* 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 */}
-
-
-
-
- {/* Right: actions */}
-
-
-
-
-
-
-
- {/* Export CTA */}
-
-
-
-
-
-
-
- )}
-
- {/* ── 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 }) => (
-
- ))}
- {isDesktop && (
- <>
-
-
- >
- )}
-
-
-
- {/* Right: panel content (desktop) */}
- {!isFullscreen && (
-
- )}
-
- {/* 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
+// 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';
+import BuilderModeToggle from './components/layout/BuilderModeToggle';
+import AdvancedPanelList from './components/panels/left/AdvancedPanelList';
+import AdvancedPanelHost from './components/panels/right/AdvancedPanelHost';
+
+// ── 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 (
+
+ );
+});
+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 */}
+
+
+ {/* 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 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 */}
+
+
+ );
+});
+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] = 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
+
+
+
+
setShortcutsOpen((v) => !v)}
+ label="Keyboard Shortcuts (⌘/)"
+ active={shortcutsOpen}
+ hideOnMobile
+ >
+
+
+
+
+ {/* Centre: command palette trigger */}
+
+
+
+
+ {/* Right: actions */}
+
+
+
+
+
+
+
+
+
+
+
+ {/* Export CTA */}
+
+
+
+
+
+
+
+ )}
+
+ {/* ── 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 }) => (
+
+ ))}
+ {isDesktop && (
+ <>
+
+
+ >
+ )}
+
+
+
+ {/* Right: panel content (desktop) */}
+ {!isFullscreen && (
+
+ )}
+
+ {/* 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;
diff --git a/src/components/builder/components/LayerPanel.tsx b/src/components/builder/components/LayerPanel.tsx
index a47dc3c..29d6e28 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 PanelSwitcher from './layout/PanelSwitcher';
+import { Section, SegmentedRow, SelectBox, SliderRow, ToggleRow } from './controls/BuilderControls';
type BadgeIconKey = keyof typeof BADGE_ICONS;
@@ -47,6 +39,7 @@ interface Props {
setConfig: React.Dispatch>;
selectedIds: Set;
onSelect: (id: RatingType, multi: boolean) => void;
+ forcedPanel?: 'source' | 'layers' | 'poster';
}
interface SearchResult {
@@ -62,375 +55,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) => (
-
- ))}
-
-
-);
-
-// ── 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 (
-
-
- {open &&
{children}
}
-
-
- );
-};
-
// ── API Keys panel ────────────────────────────────────────────────────────────
const ApiKeysPanel: React.FC<{
config: PosterConfig;
@@ -529,7 +165,7 @@ const ApiKeysPanel: React.FC<{
};
// ── Main LayerPanel component ─────────────────────────────────────────────────
-const LayerPanel: React.FC = ({ config, setConfig, selectedIds, onSelect }) => {
+const LayerPanel: React.FC = ({ config, setConfig, selectedIds, onSelect, forcedPanel }) => {
const {
setBatchSelection,
activeTab,
@@ -545,13 +181,19 @@ const LayerPanel: React.FC = ({ config, setConfig, selectedIds, onSelect
toggleViewOption,
} = useEditor();
- const [localMode, setLocalMode] = useState<'source' | 'layers' | 'poster'>('source');
+ const [localMode, setLocalMode] = useState<'source' | 'layers' | 'poster'>(
+ forcedPanel ?? 'source'
+ );
const [inactiveOrder, setInactiveOrder] = useState([]);
useEffect(() => {
+ if (forcedPanel) {
+ setLocalMode(forcedPanel);
+ return;
+ }
if (activeTab === 'source' || activeTab === 'poster' || activeTab === 'layers')
setLocalMode(activeTab);
- }, [activeTab]);
+ }, [activeTab, forcedPanel]);
const [searchQuery, setSearchQuery] = useState('');
const [results, setResults] = useState([]);
@@ -732,8 +374,10 @@ 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;
+ if (nextItems[id]) {
+ delete nextItems[id].x;
+ delete nextItems[id].y;
+ }
return { ...prev, ratings: [id, ...prev.ratings], items: nextItems };
});
setInactiveOrder((prev) => prev.filter((x) => x !== id));
@@ -819,10 +463,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 +686,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">
+
-
-
-
-
-
- Logo
-
-
-
e.stopPropagation()} className="shrink-0">
-
-
-
);
};
@@ -1130,37 +771,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) => (
-
- ))}
-
+ forcedPanel ? null : (
+ setActiveTab(id)}
+ items={[
+ { id: 'source', label: 'Source', icon: },
+ { id: 'layers', label: 'Layers', icon: },
+ { id: 'poster', label: 'Poster', icon: },
+ ]}
+ />
+ )
}
>
{/* ── Source Tab ──────────────────────────────────────────────────────── */}
@@ -1501,7 +1123,10 @@ const LayerPanel: React.FC = ({ config, setConfig, selectedIds, onSelect
>
Badges
-
+
Show/hide all layers with badge behavior
@@ -1574,7 +1199,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}
/>
@@ -1586,7 +1216,7 @@ const LayerPanel: React.FC = ({ config, setConfig, selectedIds, onSelect
{/* API Keys — Section */}
- }>
+ } compact>
@@ -1610,7 +1240,7 @@ const LayerPanel: React.FC = ({ config, setConfig, selectedIds, onSelect
- } defaultOpen>
+ } defaultOpen compact>
= ({ config, setConfig, selectedIds, onSelect
/>
- } defaultOpen>
+ } defaultOpen compact>
-
)}
@@ -1678,183 +1307,177 @@ const LayerPanel: React.FC = ({ config, setConfig, selectedIds, onSelect
{localMode === 'layers' && (
<>
-
-
+
+ Badges
+
+
+
+
+
+ {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..f1b2f3c 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,23 +15,18 @@ 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 './controls/BuilderControls';
-interface Props {
- config: PosterConfig;
- setConfig: React.Dispatch
>;
- selectedIds: Set;
- selectedLogo?: boolean;
- selectedMinimalElements?: Set;
- viewMode?: 'global' | 'selection';
- mode?: 'badges' | 'logo' | 'selection';
-}
-
-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,483 +44,15 @@ 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
-
- )}
-
-
-
-);
+interface Props {
+ config: PosterConfig;
+ setConfig: React.Dispatch>;
+ selectedIds: Set;
+ selectedLogo?: boolean;
+ selectedMinimalElements?: Set;
+ viewMode?: 'global' | 'selection';
+ mode?: 'badges' | 'logo' | 'selection';
+}
// ── Alignment grid ─────────────────────────────────────────────────────────────
const GRID_POSITIONS: { id: PresetType; label: string }[] = [
@@ -1032,7 +556,9 @@ const PropertyPanel: React.FC = ({
onChange={(v) =>
updateConfig(
'labelPos',
- (v === 'none' ? undefined : (v as PosterConfig['labelPos'])) as PosterConfig['labelPos']
+ (v === 'none'
+ ? undefined
+ : (v as PosterConfig['labelPos'])) as PosterConfig['labelPos']
)
}
/>
@@ -1131,121 +657,121 @@ const PropertyPanel: React.FC = ({
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 && (
- <>
+ `${Math.round(v * 100)}%`}
+ onChange={(v) => updateConfig('logoOpacity', v)}
+ />
+ updateConfig('logoShadow', v)}
+ />
+ updateConfig('logoBgEnabled', v)}
+ />
+ updateConfig('logoBgBorderW', v)}
+ />
+ {config.logoBgBorderW > 0 && (
updateConfig('logoBgColor', v)}
- showOpacity
- opacity={config.logoBgOpacity}
- onOpacityChange={(v) => updateConfig('logoBgOpacity', v)}
- />
- updateConfig('logoBgPadding', v)}
- />
- updateConfig('logoBgShadow', v)}
+ label="Border Color"
+ value={config.logoBgBorderC ?? '#ffffff'}
+ onChange={(v) => updateConfig('logoBgBorderC', 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.
-
+ )}
+ {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 +825,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 +834,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('-').slice(-1)[0] ?? 0) + 1}`;
}
const first = Array.from(selectedIds)[0];
if (!first) return 'Selection';
@@ -1322,8 +850,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 +931,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 +973,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 +996,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 +1009,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 && (
+ }
+ 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 && (
+ }
+ 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/controls/BuilderControls.tsx b/src/components/builder/components/controls/BuilderControls.tsx
new file mode 100644
index 0000000..3c38a8a
--- /dev/null
+++ b/src/components/builder/components/controls/BuilderControls.tsx
@@ -0,0 +1,456 @@
+import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
+import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Switch } from '@headlessui/react';
+import clsx from 'clsx';
+import { Check, ChevronDown, ChevronRight } from 'lucide-react';
+import ColorPicker from '../ColorPicker';
+
+const SECTION_STORAGE_KEY = 'posterium_section_states_v2';
+
+const readSectionStates = (): Record => {
+ try {
+ return JSON.parse(localStorage.getItem(SECTION_STORAGE_KEY) || '{}');
+ } catch {
+ return {};
+ }
+};
+
+const writeSectionState = (id: string, open: boolean) => {
+ try {
+ const states = readSectionStates();
+ localStorage.setItem(SECTION_STORAGE_KEY, JSON.stringify({ ...states, [id]: open }));
+ } catch {}
+};
+
+export const Section: React.FC<{
+ title: string;
+ icon?: React.ReactNode;
+ children: React.ReactNode;
+ defaultOpen?: boolean;
+ sectionId?: string;
+ compact?: boolean;
+}> = ({ title, icon, children, defaultOpen = true, sectionId, compact = false }) => {
+ const [open, setOpen] = useState(() => {
+ if (!sectionId) return defaultOpen;
+ const states = readSectionStates();
+ return sectionId in states ? states[sectionId] : defaultOpen;
+ });
+
+ const toggle = useCallback(() => {
+ setOpen((value) => {
+ const next = !value;
+ if (sectionId) writeSectionState(sectionId, next);
+ return next;
+ });
+ }, [sectionId]);
+
+ const xPad = compact ? 'px-1' : 'px-3';
+ const mx = compact ? 'mx-1' : 'mx-3';
+
+ return (
+
+
+
+ {icon && {icon}}
+ {title}
+
+
+ {open ? : }
+
+
+ {open &&
{children}
}
+
+
+ );
+};
+
+export 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>(null);
+ const inputRef = useRef(null);
+ const isFocused = useRef(false);
+
+ useEffect(() => {
+ setLocalValue(value);
+ if (!isFocused.current) setInputText(formatValue ? formatValue(value) : `${value}`);
+ }, [value, formatValue]);
+
+ useEffect(
+ () => () => {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ },
+ []
+ );
+
+ const commitInput = useCallback(
+ (text: string) => {
+ const raw = text.replace(unit, '').replace(/[^0-9.\-]/g, '');
+ const n = parseFloat(raw);
+ if (!Number.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}`);
+ }
+ },
+ [formatValue, localValue, max, min, onChange, unit]
+ );
+
+ 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);
+ }
+ },
+ [formatValue, onChange]
+ );
+
+ 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 && (
+
+ 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',
+ }}
+ />
+
+
+
+ );
+};
+
+export 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}
+
+ )}
+
+
+
+
+
+);
+
+export 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}
+
+ ))}
+
+
+);
+
+export const TextInputRow: React.FC<{
+ label: string;
+ value: string;
+ placeholder?: string;
+ onChange: (v: string) => void;
+ onClear?: () => void;
+}> = ({ label, value, placeholder, onChange, onClear }) => {
+ const [focused, setFocused] = useState(false);
+ return (
+
+
+
+ {label}
+
+ {onClear && value && (
+
+ 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 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';
+
+export 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 && (
+
+ Reset
+
+ )}
+
+
+
+);
diff --git a/src/components/builder/components/layout/BuilderModeToggle.tsx b/src/components/builder/components/layout/BuilderModeToggle.tsx
new file mode 100644
index 0000000..bbc5854
--- /dev/null
+++ b/src/components/builder/components/layout/BuilderModeToggle.tsx
@@ -0,0 +1,45 @@
+import React from 'react';
+import clsx from 'clsx';
+
+type BuilderMode = 'simple' | 'advanced';
+
+interface Props {
+ mode: BuilderMode;
+ className?: string;
+}
+
+const items: Array<{ mode: BuilderMode; label: string; href: string }> = [
+ { mode: 'simple', label: 'Simple', href: '/build' },
+ { mode: 'advanced', label: 'Advanced', href: '/abuild' },
+];
+
+const BuilderModeToggle: React.FC = ({ mode, className }) => (
+
+);
+
+export default BuilderModeToggle;
diff --git a/src/components/builder/components/layout/Inspector.tsx b/src/components/builder/components/layout/Inspector.tsx
index 8065763..26d7cde 100644
--- a/src/components/builder/components/layout/Inspector.tsx
+++ b/src/components/builder/components/layout/Inspector.tsx
@@ -3,21 +3,23 @@ import { useEditor } from '../../context/EditorContext';
import PropertyPanel from '../PropertyPanel';
import type { PosterConfig } from '../../types';
import { Badge, MousePointer2 } from 'lucide-react';
-import clsx from 'clsx';
import SidebarLayout from '../SidebarLayout';
+import PanelSwitcher from './PanelSwitcher';
interface Props {
config: PosterConfig;
setConfig: React.Dispatch>;
+ mode?: InspectorTab;
+ hideTabBar?: boolean;
}
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, mode, hideTabBar = false }) => {
+ 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 +35,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',
@@ -43,7 +50,7 @@ const Inspector: React.FC = memo(({ config, setConfig }) => {
];
const visibleTabs = tabs.filter((tab) => tab.visible);
- const activeInspectorTab = isInspectorTab(activeTab) ? activeTab : undefined;
+ const activeInspectorTab = mode ?? (isInspectorTab(activeTab) ? activeTab : undefined);
const currentTab = visibleTabs.some((tab) => tab.id === activeInspectorTab)
? activeInspectorTab
: visibleTabs[0]?.id;
@@ -52,34 +59,20 @@ 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}
-
- ))}
-
+ hideTabBar ? null : (
+ setActiveTab(id)}
+ items={visibleTabs.map(({ id, label, Icon }) => ({
+ id,
+ label,
+ icon: ,
+ }))}
+ />
+ )
}
>
{
+ id: T;
+ label: string;
+ icon?: React.ReactNode;
+}
+
+interface Props {
+ items: readonly PanelSwitcherItem[];
+ activeId: T;
+ onChange: (id: T) => void;
+ ariaLabel?: string;
+}
+
+const PanelSwitcher = ({ items, activeId, onChange, ariaLabel }: Props) => (
+
+ {items.map((item) => {
+ const active = activeId === item.id;
+ return (
+ onChange(item.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',
+ !active && 'hover:bg-[rgba(196,124,46,0.08)] hover:text-[var(--film-text-label)]'
+ )}
+ style={{
+ background: active ? 'var(--film-mid)' : 'transparent',
+ color: active ? 'var(--film-cream)' : 'var(--film-text-dim)',
+ boxShadow: active ? '0 1px 4px rgba(0,0,0,0.3)' : 'none',
+ }}
+ >
+ {item.icon}
+ {item.label}
+
+ );
+ })}
+
+);
+
+export default PanelSwitcher;
diff --git a/src/components/builder/components/panels/left/AdvancedPanelList.tsx b/src/components/builder/components/panels/left/AdvancedPanelList.tsx
new file mode 100644
index 0000000..fe7e909
--- /dev/null
+++ b/src/components/builder/components/panels/left/AdvancedPanelList.tsx
@@ -0,0 +1,64 @@
+import clsx from 'clsx';
+import type { LucideIcon } from 'lucide-react';
+
+export interface AdvancedPanelListItem {
+ id: T;
+ label: string;
+ desc: string;
+ Icon: LucideIcon;
+ badge?: number | null;
+}
+
+interface Props {
+ items: readonly AdvancedPanelListItem[];
+ activeId: T;
+ onSelect: (id: T) => void;
+}
+
+const AdvancedPanelList = ({ items, activeId, onSelect }: Props) => (
+
+ {items.map(({ id, label, desc, Icon, badge }) => {
+ const active = activeId === id;
+ return (
+ onSelect(id)}
+ aria-current={active ? 'true' : undefined}
+ className={clsx(
+ 'w-full flex items-center gap-2.5 rounded-xl border-l-2 px-3 py-2.5 text-left transition-all',
+ active
+ ? 'border-l-[var(--film-amber)] bg-[rgba(196,124,46,0.08)] text-[var(--film-cream)]'
+ : 'border-l-transparent text-[var(--film-text-dim)] hover:bg-[rgba(196,124,46,0.04)] hover:text-[var(--film-text-label)]'
+ )}
+ >
+
+
+
+
+
+ {label}
+ {!!badge && (
+
+ {badge}
+
+ )}
+
+
+ {desc}
+
+
+
+ );
+ })}
+
+);
+
+export default AdvancedPanelList;
diff --git a/src/components/builder/components/panels/left/LayersPanel.tsx b/src/components/builder/components/panels/left/LayersPanel.tsx
new file mode 100644
index 0000000..6b790aa
--- /dev/null
+++ b/src/components/builder/components/panels/left/LayersPanel.tsx
@@ -0,0 +1,9 @@
+import React from 'react';
+import LayerPanel from '../../LayerPanel';
+import type { BuilderLeftPanelProps } from './types';
+
+const LayersPanel: React.FC = (props) => (
+
+);
+
+export default LayersPanel;
diff --git a/src/components/builder/components/panels/left/PosterPanel.tsx b/src/components/builder/components/panels/left/PosterPanel.tsx
new file mode 100644
index 0000000..a6c8611
--- /dev/null
+++ b/src/components/builder/components/panels/left/PosterPanel.tsx
@@ -0,0 +1,9 @@
+import React from 'react';
+import LayerPanel from '../../LayerPanel';
+import type { BuilderLeftPanelProps } from './types';
+
+const PosterPanel: React.FC = (props) => (
+
+);
+
+export default PosterPanel;
diff --git a/src/components/builder/components/panels/left/SourcePanel.tsx b/src/components/builder/components/panels/left/SourcePanel.tsx
new file mode 100644
index 0000000..1a8aba7
--- /dev/null
+++ b/src/components/builder/components/panels/left/SourcePanel.tsx
@@ -0,0 +1,9 @@
+import React from 'react';
+import LayerPanel from '../../LayerPanel';
+import type { BuilderLeftPanelProps } from './types';
+
+const SourcePanel: React.FC = (props) => (
+
+);
+
+export default SourcePanel;
diff --git a/src/components/builder/components/panels/left/types.ts b/src/components/builder/components/panels/left/types.ts
new file mode 100644
index 0000000..fdfea0e
--- /dev/null
+++ b/src/components/builder/components/panels/left/types.ts
@@ -0,0 +1,9 @@
+import type React from 'react';
+import type { PosterConfig, RatingType } from '../../../types';
+
+export interface BuilderLeftPanelProps {
+ config: PosterConfig;
+ setConfig: React.Dispatch>;
+ selectedIds: Set;
+ onSelect: (id: RatingType, multi: boolean) => void;
+}
diff --git a/src/components/builder/components/panels/right/AdvancedPanelHost.tsx b/src/components/builder/components/panels/right/AdvancedPanelHost.tsx
new file mode 100644
index 0000000..e865232
--- /dev/null
+++ b/src/components/builder/components/panels/right/AdvancedPanelHost.tsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import SourcePanel from '../left/SourcePanel';
+import LayersPanel from '../left/LayersPanel';
+import PosterPanel from '../left/PosterPanel';
+import BadgesPanel from './BadgesPanel';
+import SelectionPanel from './SelectionPanel';
+import type { BuilderLeftPanelProps } from '../left/types';
+import type { BuilderRightPanelProps } from './types';
+
+type PanelId = 'source' | 'layers' | 'poster' | 'badges' | 'selection' | 'logo';
+
+type Props = BuilderLeftPanelProps & BuilderRightPanelProps & { activePanel: PanelId };
+
+const AdvancedPanelHost: React.FC = ({ activePanel, ...props }) => {
+ if (activePanel === 'source') return ;
+ if (activePanel === 'layers') return ;
+ if (activePanel === 'poster') return ;
+ if (activePanel === 'badges') return ;
+ return ;
+};
+
+export default AdvancedPanelHost;
diff --git a/src/components/builder/components/panels/right/BadgesPanel.tsx b/src/components/builder/components/panels/right/BadgesPanel.tsx
new file mode 100644
index 0000000..69b2d24
--- /dev/null
+++ b/src/components/builder/components/panels/right/BadgesPanel.tsx
@@ -0,0 +1,9 @@
+import React from 'react';
+import Inspector from '../../layout/Inspector';
+import type { BuilderRightPanelProps } from './types';
+
+const BadgesPanel: React.FC = (props) => (
+
+);
+
+export default BadgesPanel;
diff --git a/src/components/builder/components/panels/right/SelectionPanel.tsx b/src/components/builder/components/panels/right/SelectionPanel.tsx
new file mode 100644
index 0000000..caff208
--- /dev/null
+++ b/src/components/builder/components/panels/right/SelectionPanel.tsx
@@ -0,0 +1,9 @@
+import React from 'react';
+import Inspector from '../../layout/Inspector';
+import type { BuilderRightPanelProps } from './types';
+
+const SelectionPanel: React.FC = (props) => (
+
+);
+
+export default SelectionPanel;
diff --git a/src/components/builder/components/panels/right/types.ts b/src/components/builder/components/panels/right/types.ts
new file mode 100644
index 0000000..626c24c
--- /dev/null
+++ b/src/components/builder/components/panels/right/types.ts
@@ -0,0 +1,7 @@
+import type React from 'react';
+import type { PosterConfig } from '../../../types';
+
+export interface BuilderRightPanelProps {
+ config: PosterConfig;
+ setConfig: React.Dispatch>;
+}
diff --git a/src/components/builder/index.tsx b/src/components/builder/index.tsx
index 3a479d8..fa878ed 100644
--- a/src/components/builder/index.tsx
+++ b/src/components/builder/index.tsx
@@ -2,11 +2,21 @@
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 {
+ 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 SourcePanel from './components/panels/left/SourcePanel';
+import LayersPanel from './components/panels/left/LayersPanel';
+import PosterPanel from './components/panels/left/PosterPanel';
+import BadgesPanel from './components/panels/right/BadgesPanel';
+import SelectionPanel from './components/panels/right/SelectionPanel';
import MobileDock from './components/layout/MobileDock';
import KeyboardShortcutsModal from './components/KeyboardShortcutsModal';
import ResetDialog from './components/ResetDialogue';
@@ -39,11 +49,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';
+import BuilderModeToggle from './components/layout/BuilderModeToggle';
const STORAGE_KEY = 'posterium_config_v2';
const MAX_QUERY_CONFIG_LENGTH = 12000; // Guard against oversized URL payloads/memory abuse in base64 config loading.
@@ -397,7 +407,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 +424,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 +448,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('-').slice(-1)[0] ?? -1);
if (!Number.isFinite(idx) || !list[idx]) return;
list[idx] = {
...list[idx],
@@ -610,7 +628,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)
@@ -1106,7 +1127,9 @@ const StudioLayout: React.FC<{
onSendToBack={(id) => (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 +1203,7 @@ const StudioLayout: React.FC<{
P
-
-
-
- Support
-
-
+
setPaletteOpen(true)}
title="Search commands (⌘K)"
@@ -1408,12 +1422,30 @@ const StudioLayout: React.FC<{
opacity: leftVisible ? 1 : 0,
}}
>
-
+ {activeTab === 'source' && (
+
+ )}
+ {activeTab === 'layers' && (
+
+ )}
+ {activeTab === 'poster' && (
+
+ )}
-
+ {activeTab === 'selection' ? (
+
+ ) : (
+
+ )}
)}
@@ -1534,17 +1570,34 @@ const StudioLayout: React.FC<{
- {(activeTab === 'source' || activeTab === 'layers' || activeTab === 'poster') && (
-
+ )}
+ {activeTab === 'layers' && (
+
+ )}
+ {activeTab === 'poster' && (
+
)}
- {(activeTab === 'badges' || activeTab === 'selection') && (
-
+ {activeTab === 'selection' && (
+
)}
+ {activeTab === 'badges' &&
}
)}