From ee5a4181f29a963460ddae1d423634bce996214f Mon Sep 17 00:00:00 2001 From: a5sh Date: Fri, 8 May 2026 23:44:54 +0530 Subject: [PATCH] Refactor builder panel architecture --- src/components/builder/AdvancedBuilderApp.tsx | 2542 ++++++++++------- .../builder/components/LayerPanel.tsx | 901 ++---- .../builder/components/PropertyPanel.tsx | 1564 ++++------ .../components/controls/BuilderControls.tsx | 456 +++ .../components/layout/BuilderModeToggle.tsx | 45 + .../builder/components/layout/Inspector.tsx | 59 +- .../components/layout/PanelSwitcher.tsx | 51 + .../panels/left/AdvancedPanelList.tsx | 64 + .../components/panels/left/LayersPanel.tsx | 9 + .../components/panels/left/PosterPanel.tsx | 9 + .../components/panels/left/SourcePanel.tsx | 9 + .../builder/components/panels/left/types.ts | 9 + .../panels/right/AdvancedPanelHost.tsx | 22 + .../components/panels/right/BadgesPanel.tsx | 9 + .../panels/right/SelectionPanel.tsx | 9 + .../builder/components/panels/right/types.ts | 7 + src/components/builder/index.tsx | 115 +- 17 files changed, 3218 insertions(+), 2662 deletions(-) create mode 100644 src/components/builder/components/controls/BuilderControls.tsx create mode 100644 src/components/builder/components/layout/BuilderModeToggle.tsx create mode 100644 src/components/builder/components/layout/PanelSwitcher.tsx create mode 100644 src/components/builder/components/panels/left/AdvancedPanelList.tsx create mode 100644 src/components/builder/components/panels/left/LayersPanel.tsx create mode 100644 src/components/builder/components/panels/left/PosterPanel.tsx create mode 100644 src/components/builder/components/panels/left/SourcePanel.tsx create mode 100644 src/components/builder/components/panels/left/types.ts create mode 100644 src/components/builder/components/panels/right/AdvancedPanelHost.tsx create mode 100644 src/components/builder/components/panels/right/BadgesPanel.tsx create mode 100644 src/components/builder/components/panels/right/SelectionPanel.tsx create mode 100644 src/components/builder/components/panels/right/types.ts 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 && ( -
-
- )} - - {/* ── Body ──────────────────────────────────────────────────────── */} -
- - {/* Left: Advanced nav sidebar (desktop only) */} - {!isFullscreen && ( - - )} - - {/* Canvas */} -
{ - if (e.target === e.currentTarget) clearSelection(); - if (mobileSheetMode !== 'hidden') setMobileSheetMode('hidden'); - }}> -
- - {/* 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 && ( +
+
+ )} + + {/* ── Body ──────────────────────────────────────────────────────── */} +
+ {/* Left: Advanced nav sidebar (desktop only) */} + {!isFullscreen && ( + + )} + + {/* Canvas */} +
{ + if (e.target === e.currentTarget) clearSelection(); + if (mobileSheetMode !== 'hidden') setMobileSheetMode('hidden'); + }} + > +
+ + {/* 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 ( -
- - {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 && ( - - )} -
-
- 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) => ( - - ))} -
-
-); - -// ── 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 && ( - - )} -
- 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 && ( - - )} -
- -
-); +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)} /> - - - )} - 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)} + /> + + + )} + 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) && ( -
- -
+
+ +
)} ); 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 ( +
+ + {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 && ( + + )} +
+
+ 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) => ( + + ))} +
+
+); + +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 && ( + + )} +
+ 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 && ( + + )} +
+ +
+); 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 }) => ( - - ))} -
+ 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 ( + + ); + })} +
+); + +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 ( + + ); + })} +
+); + +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 - - +
)}