From 89d537b8576101737fd49ea790af3924eb0c4025 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 25 May 2026 16:21:49 +0000
Subject: [PATCH 1/7] chore: plan builder mode overhaul
Agent-Logs-Url: https://github.com/SpicyDevs/posterium-frontend/sessions/980be9bd-aaee-4015-bd83-a440361f5e95
Co-authored-by: ikrdikhit <155148519+ikrdikhit@users.noreply.github.com>
---
package-lock.json | 20 +-------------------
1 file changed, 1 insertion(+), 19 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 9f62847a..7c98a976 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -207,7 +207,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -4297,7 +4296,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT",
- "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -4308,7 +4306,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -4531,7 +4528,6 @@
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -4741,7 +4737,6 @@
"integrity": "sha512-m4VWilWZ+Xt6NPoYzC4CgGZim/zQUO7WFL0RHCH0AiEavF1153iC3+me2atDvXpf/yX4PyGUeD8wZLq1cirT3g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@astrojs/compiler": "^2.13.0",
"@astrojs/internal-helpers": "0.7.6",
@@ -4865,7 +4860,6 @@
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -5135,7 +5129,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -9782,7 +9775,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -9931,7 +9923,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -9941,7 +9932,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -10064,8 +10054,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -10481,7 +10470,6 @@
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -11624,7 +11612,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -11696,7 +11683,6 @@
"integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"pathe": "^2.0.3"
}
@@ -12176,7 +12162,6 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -13230,7 +13215,6 @@
"integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},
@@ -13399,7 +13383,6 @@
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"workerd": "bin/workerd"
},
@@ -14070,7 +14053,6 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
From 46ab2c2cc7f4d7987f01ed4047beecfbf14a582d Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 25 May 2026 16:24:43 +0000
Subject: [PATCH 2/7] feat: add simple and walkthrough builder modes
Agent-Logs-Url: https://github.com/SpicyDevs/posterium-frontend/sessions/980be9bd-aaee-4015-bd83-a440361f5e95
Co-authored-by: ikrdikhit <155148519+ikrdikhit@users.noreply.github.com>
---
.../components/navigation/ModeToggle.tsx | 2 +-
.../builder/components/simple/MediaPicker.tsx | 134 ++
.../components/simple/SimpleModePanel.tsx | 96 +
.../components/simple/WalkthroughMode.tsx | 258 +++
src/components/builder/index.tsx | 207 ++-
.../builder/legacy/StudioLayout.index.tsx.bak | 1574 +++++++++++++++++
.../legacy/components/LayerPanel.tsx.bak | 1512 ++++++++++++++++
.../legacy/components/PropertyPanel.tsx.bak | 1566 ++++++++++++++++
.../components/layout/Inspector.tsx.bak | 98 +
9 files changed, 5362 insertions(+), 85 deletions(-)
create mode 100644 src/components/builder/components/simple/MediaPicker.tsx
create mode 100644 src/components/builder/components/simple/SimpleModePanel.tsx
create mode 100644 src/components/builder/components/simple/WalkthroughMode.tsx
create mode 100644 src/components/builder/legacy/StudioLayout.index.tsx.bak
create mode 100644 src/components/builder/legacy/components/LayerPanel.tsx.bak
create mode 100644 src/components/builder/legacy/components/PropertyPanel.tsx.bak
create mode 100644 src/components/builder/legacy/components/layout/Inspector.tsx.bak
diff --git a/src/components/builder/components/navigation/ModeToggle.tsx b/src/components/builder/components/navigation/ModeToggle.tsx
index 2deeba8a..bdb61a87 100644
--- a/src/components/builder/components/navigation/ModeToggle.tsx
+++ b/src/components/builder/components/navigation/ModeToggle.tsx
@@ -1,6 +1,6 @@
import React, { memo } from 'react';
-export type BuilderMode = 'simple' | 'advanced';
+export type BuilderMode = 'simple' | 'advanced' | 'walkthrough';
interface Props {
mode: BuilderMode;
diff --git a/src/components/builder/components/simple/MediaPicker.tsx b/src/components/builder/components/simple/MediaPicker.tsx
new file mode 100644
index 00000000..e5fa4d63
--- /dev/null
+++ b/src/components/builder/components/simple/MediaPicker.tsx
@@ -0,0 +1,134 @@
+import React, { useEffect, useState } from 'react';
+import { Search, Loader2, Film, Tv } from 'lucide-react';
+import type { PosterConfig } from '../../types';
+import { DEFAULT_API_BASE } from '../../utils';
+
+interface SearchResult {
+ id: number;
+ title?: string;
+ name?: string;
+ poster_path?: string;
+ release_date?: string;
+ first_air_date?: string;
+ media_type?: 'movie' | 'tv';
+}
+
+interface Props {
+ config: PosterConfig;
+ setConfig: React.Dispatch>;
+ title?: string;
+}
+
+const MediaPicker: React.FC = ({ config, setConfig, title = 'Media selection' }) => {
+ const [query, setQuery] = useState('');
+ const [results, setResults] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [selectedTitle, setSelectedTitle] = useState('');
+
+ useEffect(() => {
+ const ctrl = new AbortController();
+ const t = setTimeout(async () => {
+ if (query.trim().length < 2) {
+ setResults([]);
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const response = await fetch(`${DEFAULT_API_BASE}/search?q=${encodeURIComponent(query)}`, {
+ signal: ctrl.signal,
+ });
+ const data = await response.json();
+ const next = Array.isArray(data.results)
+ ? data.results.filter(
+ (item: SearchResult) =>
+ item.poster_path && (item.media_type === 'movie' || item.media_type === 'tv')
+ )
+ : [];
+ setResults(next);
+ } catch (error: unknown) {
+ if (error instanceof Error && error.name === 'AbortError') return;
+ } finally {
+ setLoading(false);
+ }
+ }, 350);
+
+ return () => {
+ clearTimeout(t);
+ ctrl.abort();
+ };
+ }, [query]);
+
+ const handleSelect = (item: SearchResult) => {
+ const mediaType = item.media_type === 'tv' ? 'tv' : 'movie';
+ setConfig((prev) => ({
+ ...prev,
+ tmdbId: String(item.id),
+ imdbId: undefined,
+ mediaType,
+ }));
+ setSelectedTitle(item.title || item.name || '');
+ setQuery('');
+ setResults([]);
+ };
+
+ return (
+
+
+ {title}
+
+
+
+
+ {loading ? : }
+
+ setQuery(e.target.value)}
+ placeholder="Search a movie or show"
+ className="w-full h-full px-2 bg-transparent border-none outline-none syne-font"
+ style={{ fontSize: 11, color: 'var(--film-cream)' }}
+ />
+
+
+ {results.length > 0 && (
+
+ {results.map((item) => {
+ const label = item.title || item.name || 'Untitled';
+ const year = (item.release_date || item.first_air_date || '').split('-')[0];
+ const isTv = item.media_type === 'tv';
+ return (
+
handleSelect(item)}
+ className="w-full text-left px-2.5 py-2 rounded-lg hover:bg-[rgba(196,124,46,0.1)] transition-colors"
+ >
+
+ {label}
+
+
+ {year || '—'} {isTv ? : } {isTv ? 'TV' : 'Movie'}
+
+
+ );
+ })}
+
+ )}
+
+ {(selectedTitle || config.tmdbId) && (
+
+ Selected: {selectedTitle || `TMDB #${config.tmdbId}`}
+
+ )}
+
+ );
+};
+
+export default MediaPicker;
diff --git a/src/components/builder/components/simple/SimpleModePanel.tsx b/src/components/builder/components/simple/SimpleModePanel.tsx
new file mode 100644
index 00000000..23df6c63
--- /dev/null
+++ b/src/components/builder/components/simple/SimpleModePanel.tsx
@@ -0,0 +1,96 @@
+import React from 'react';
+import type { PosterConfig, PresetType, RatingType } from '../../types';
+import { ALL_BADGES } from '../../types';
+import SidebarLayout from '../SidebarLayout';
+import { Section, SelectBox, ToggleRow } from '../ui';
+import MediaPicker from './MediaPicker';
+
+interface Props {
+ config: PosterConfig;
+ setConfig: React.Dispatch>;
+}
+
+const SIMPLE_BADGE_IDS: RatingType[] = [
+ 'imdb',
+ 'rt',
+ 'rt_popcorn',
+ 'meta',
+ 'tmdb',
+ 'age',
+ 'runtime',
+ 'year',
+ 'title',
+];
+
+const PRESET_OPTIONS: { id: PresetType; label: string }[] = [
+ { id: 'bl', label: 'Bottom Left' },
+ { id: 'br', label: 'Bottom Right' },
+ { id: 'tl', label: 'Top Left' },
+ { id: 'tr', label: 'Top Right' },
+ { id: 'bc', label: 'Bottom Center' },
+ { id: 'tc', label: 'Top Center' },
+];
+
+const SimpleModePanel: React.FC = ({ config, setConfig }) => {
+ const update = (key: K, value: PosterConfig[K]) => {
+ setConfig((prev) => ({ ...prev, [key]: value }));
+ };
+
+ const toggleBadge = (id: RatingType, enabled: boolean) => {
+ setConfig((prev) => {
+ if (enabled && !prev.ratings.includes(id)) {
+ return { ...prev, ratings: [...prev.ratings, id] };
+ }
+ if (!enabled) {
+ return { ...prev, ratings: prev.ratings.filter((rating) => rating !== id) };
+ }
+ return prev;
+ });
+ };
+
+ return (
+
+
+
+
+ update('theme', value as PosterConfig['theme'])}
+ options={[
+ { id: 'glass', label: 'Glass' },
+ { id: 'solid', label: 'Solid' },
+ ]}
+ />
+
+
+
+ {
+ update('preset', value as PresetType);
+ update('layout', 'row');
+ }}
+ options={PRESET_OPTIONS}
+ />
+
+
+
+ {SIMPLE_BADGE_IDS.map((id) => {
+ const label = ALL_BADGES.find((badge) => badge.id === id)?.label ?? id;
+ return (
+ toggleBadge(id, enabled)}
+ />
+ );
+ })}
+
+
+ );
+};
+
+export default SimpleModePanel;
diff --git a/src/components/builder/components/simple/WalkthroughMode.tsx b/src/components/builder/components/simple/WalkthroughMode.tsx
new file mode 100644
index 00000000..b7747545
--- /dev/null
+++ b/src/components/builder/components/simple/WalkthroughMode.tsx
@@ -0,0 +1,258 @@
+import React, { useMemo, useRef, useState } from 'react';
+import { ArrowLeft, ArrowRight, Sparkles, Download } from 'lucide-react';
+import type { ExtensionType, PosterConfig, PresetType, RatingType } from '../../types';
+import { ALL_BADGES } from '../../types';
+import PreviewCanvas from '../PreviewCanvas';
+import ExportPopover from '../ExportPopover';
+import { Button, Card } from '@/components/ui';
+import MediaPicker from './MediaPicker';
+import type { BuilderMode } from '../navigation/ModeToggle';
+
+type WalkthroughStyle = 'glass' | 'solid' | 'minimal';
+
+interface Props {
+ config: PosterConfig;
+ setConfig: React.Dispatch>;
+ baseUrl: string;
+ onLoadConfig: (url: string) => void;
+ onExtensionChange: (ext: ExtensionType) => void;
+ onExit: (mode: BuilderMode) => void;
+}
+
+const BADGE_OPTIONS: RatingType[] = ['imdb', 'rt', 'rt_popcorn', 'meta', 'tmdb', 'age', 'runtime', 'year'];
+const PRESET_OPTIONS: { id: PresetType; label: string }[] = [
+ { id: 'bl', label: 'Bottom Left' },
+ { id: 'br', label: 'Bottom Right' },
+ { id: 'tl', label: 'Top Left' },
+ { id: 'tr', label: 'Top Right' },
+];
+
+const WalkthroughMode: React.FC = ({
+ config,
+ setConfig,
+ baseUrl,
+ onLoadConfig,
+ onExtensionChange,
+ onExit,
+}) => {
+ const [step, setStep] = useState(1);
+ const [exportOpen, setExportOpen] = useState(false);
+ const exportBtnRef = useRef(null);
+
+ const style: WalkthroughStyle = useMemo(() => {
+ if ((config.uiPreset ?? 'b') === 'm') return 'minimal';
+ return config.theme;
+ }, [config.theme, config.uiPreset]);
+
+ const selectedSet = useMemo(() => new Set(), []);
+
+ const applyStyle = (next: WalkthroughStyle) => {
+ setConfig((prev) => {
+ if (next === 'minimal') {
+ return { ...prev, theme: 'solid', uiPreset: 'm' };
+ }
+ return { ...prev, theme: next, uiPreset: 'b' };
+ });
+ };
+
+ const toggleBadge = (id: RatingType, enabled: boolean) => {
+ setConfig((prev) => {
+ if (enabled && !prev.ratings.includes(id)) return { ...prev, ratings: [...prev.ratings, id] };
+ if (!enabled) return { ...prev, ratings: prev.ratings.filter((rating) => rating !== id) };
+ return prev;
+ });
+ };
+
+ return (
+
+
+
+
+
+ Walkthrough
+
+
+ Step {step} of 4
+
+
+
onExit('simple')}>
+ Skip to Simple
+
+
+
+ {step === 1 && (
+
+
+
+ )}
+
+ {step === 2 && (
+
+
+ Step 2 · Choose the vibe
+
+
+ {([
+ { id: 'glass', label: 'Glass' },
+ { id: 'solid', label: 'Solid' },
+ { id: 'minimal', label: 'Minimal' },
+ ] as const).map((option) => {
+ const active = style === option.id;
+ return (
+
applyStyle(option.id)}
+ className="rounded-xl p-4 text-left transition-colors"
+ style={{
+ border: active ? '1px solid rgba(196,124,46,0.5)' : '1px solid rgba(255,255,255,0.1)',
+ background: active ? 'rgba(196,124,46,0.12)' : 'rgba(255,255,255,0.03)',
+ color: 'var(--film-cream)',
+ }}
+ >
+ {option.label}
+
+ );
+ })}
+
+
+
+ Preset layout
+
+
+ {PRESET_OPTIONS.map((option) => {
+ const active = config.preset === option.id;
+ return (
+
+ setConfig((prev) => ({ ...prev, preset: option.id, layout: 'row' }))
+ }
+ className="px-3 py-2 rounded-lg syne-font text-[11px]"
+ style={{
+ border: active ? '1px solid rgba(196,124,46,0.5)' : '1px solid rgba(255,255,255,0.1)',
+ background: active ? 'rgba(196,124,46,0.12)' : 'rgba(255,255,255,0.03)',
+ color: active ? 'var(--film-amber)' : 'var(--film-text-label)',
+ }}
+ >
+ {option.label}
+
+ );
+ })}
+
+
+
+ )}
+
+ {step === 3 && (
+
+
+ Step 3 · Choose badges
+
+
+ {BADGE_OPTIONS.map((id) => {
+ const label = ALL_BADGES.find((badge) => badge.id === id)?.label ?? id;
+ const active = config.ratings.includes(id);
+ return (
+ toggleBadge(id, !active)}
+ className="w-full px-3 py-2 rounded-lg text-left syne-font text-[11px]"
+ style={{
+ border: active ? '1px solid rgba(196,124,46,0.5)' : '1px solid rgba(255,255,255,0.1)',
+ background: active ? 'rgba(196,124,46,0.12)' : 'rgba(255,255,255,0.03)',
+ color: active ? 'var(--film-amber)' : 'var(--film-text-label)',
+ }}
+ >
+ {label}
+
+ );
+ })}
+
+
+ )}
+
+ {step === 4 && (
+
+
+
+ Preview
+
+
+
undefined}
+ onContextMenu={() => undefined}
+ onLogoContextMenu={() => undefined}
+ />
+
+
+
+
+
+ Step 4 · Generate & Export
+
+ }
+ onClick={() => setExportOpen((open) => !open)}
+ >
+ Export Options
+
+ {exportOpen && (
+ setExportOpen(false)}
+ anchorRef={exportBtnRef}
+ />
+ )}
+
+
+ onExit('simple')}>
+ Tweak in Simple Mode
+
+ onExit('advanced')}>
+ Fine-tune in Advanced Mode
+
+
+
+
+ )}
+
+
+ }
+ onClick={() => setStep((value) => Math.max(1, value - 1))}
+ disabled={step === 1}
+ >
+ Back
+
+ }
+ onClick={() => setStep((value) => Math.min(4, value + 1))}
+ disabled={(step === 1 && !config.tmdbId) || step === 4}
+ >
+ Next
+
+
+
+
+ );
+};
+
+export default WalkthroughMode;
diff --git a/src/components/builder/index.tsx b/src/components/builder/index.tsx
index 6dc99d42..56abc570 100644
--- a/src/components/builder/index.tsx
+++ b/src/components/builder/index.tsx
@@ -22,6 +22,8 @@ import LayerPanel from './components/LayerPanel';
import Inspector from './components/layout/Inspector';
import AdvancedPanelNav, { type BuilderPanelId } from './components/navigation/AdvancedPanelNav';
import ModeToggle, { type BuilderMode } from './components/navigation/ModeToggle';
+import SimpleModePanel from './components/simple/SimpleModePanel';
+import WalkthroughMode from './components/simple/WalkthroughMode';
import {
SourcePanel,
LayersPanel,
@@ -70,6 +72,7 @@ const ImportDialog = lazy(() => import('./components/ImportDialogue'));
const ExportPopover = lazy(() => import('./components/ExportPopover'));
const ContextMenu = lazy(() => import('./components/ContextMenu'));
const CommandPalette = lazy(() => import('./components/CommandPalette'));
+const WALKTHROUGH_COMPLETED_KEY = 'posterium_builder_walkthrough_completed_v1';
// ── Studio layout ─────────────────────────────────────────────────────────────
const StudioLayout: React.FC<{
@@ -123,6 +126,26 @@ const StudioLayout: React.FC<{
const importBtnRef = useRef(null);
const exportBtnRef = useRef(null);
const toggleFullscreen = useCallback(() => setIsFullscreen((v) => !v), []);
+ const handleModeChange = useCallback((mode: BuilderMode) => {
+ setBuilderMode(mode);
+ if (mode !== 'walkthrough') {
+ try {
+ localStorage.setItem(WALKTHROUGH_COMPLETED_KEY, '1');
+ } catch {
+ // ignore
+ }
+ }
+ }, []);
+
+ useEffect(() => {
+ if (initialMode !== 'simple') return;
+ try {
+ const seen = localStorage.getItem(WALKTHROUGH_COMPLETED_KEY);
+ if (!seen) setBuilderMode('walkthrough');
+ } catch {
+ // ignore
+ }
+ }, [initialMode]);
useEffect(() => {
if (['source', 'layers', 'poster', 'badges', 'selection'].includes(activeTab)) {
@@ -397,6 +420,7 @@ const StudioLayout: React.FC<{
// ── Keyboard shortcuts ──────────────────────────────────────────────────
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
+ if (builderMode === 'walkthrough') return;
const t = e.target as HTMLElement;
const inInput = t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable;
const mod = e.ctrlKey || e.metaKey;
@@ -591,6 +615,7 @@ const StudioLayout: React.FC<{
selectedLogo,
selectedMinimalElements,
isDesktop,
+ builderMode,
]);
// ── Panel widths ──────────────────────────────────────────────────────────
@@ -1019,7 +1044,7 @@ const StudioLayout: React.FC<{
)}
{/* ── HEADER ── */}
- {!isFullscreen && (
+ {!isFullscreen && builderMode !== 'walkthrough' && (
-
+
setPaletteOpen(true)}
title="Search commands (⌘K)"
@@ -1275,7 +1300,7 @@ const StudioLayout: React.FC<{
{/* ── BODY ── */}
{/* Left sidebar */}
- {!isFullscreen && (
+ {!isFullscreen && builderMode !== 'walkthrough' && (
) : (
-
+
)}
{
+ if (builderMode === 'walkthrough') return;
if (e.target === e.currentTarget) clearSelection();
if (mobileSheetMode !== 'hidden') setMobileSheetMode('hidden');
}}
>
-
+ {builderMode === 'walkthrough' ? (
+
+ ) : (
+ <>
+
- {/* Sidebar Canvas Toggles removed — now in navbar */}
+ {/* Sidebar Canvas Toggles removed — now in navbar */}
-
openCtxMenu('logo', e)}
- />
- {/* Film corner accents */}
- {(['tl', 'tr', 'bl', 'br'] as const).map((c) => (
-
- ))}
+ openCtxMenu('logo', e)}
+ />
+ {/* Film corner accents */}
+ {(['tl', 'tr', 'bl', 'br'] as const).map((c) => (
+
+ ))}
+ >
+ )}
{/* Right sidebar */}
- {!isFullscreen && (
+ {!isFullscreen && builderMode === 'advanced' && (
- {builderMode === 'advanced' ? (
- renderAdvancedPanel()
- ) : (
-
- )}
+ {renderAdvancedPanel()}
)}
{/* Mobile Panel (In-flow flex item) */}
- {!isFullscreen && (
+ {!isFullscreen && builderMode !== 'walkthrough' && (
- {(activeTab === 'source' || activeTab === 'layers' || activeTab === 'poster') && (
-
- )}
- {(activeTab === 'badges' || activeTab === 'selection') && (
-
+ {builderMode === 'advanced' ? (
+ <>
+ {(activeTab === 'source' || activeTab === 'layers' || activeTab === 'poster') && (
+
+ )}
+ {(activeTab === 'badges' || activeTab === 'selection') && (
+
+ )}
+ >
+ ) : (
+
)}
@@ -1440,25 +1475,29 @@ const StudioLayout: React.FC<{
{/* Mobile dock */}
- 0}
- hasLogo={config.logo}
- isMinimalPreset={(config.uiPreset ?? 'b') === 'm'}
- selectedCount={selectedIds.size + (selectedLogo ? 1 : 0)}
- />
+ {builderMode === 'advanced' && (
+ 0}
+ hasLogo={config.logo}
+ isMinimalPreset={(config.uiPreset ?? 'b') === 'm'}
+ selectedCount={selectedIds.size + (selectedLogo ? 1 : 0)}
+ />
+ )}
{/* Zoom + fullscreen overlay — always visible */}
- dispatchZoom(0.25)}
- onZoomOut={() => dispatchZoom(-0.25)}
- onResetView={dispatchResetView}
- isMobile={!isDesktop}
- viewOptions={viewOptions}
- onToggleViewOption={toggleViewOption}
- />
+ {builderMode !== 'walkthrough' && (
+ dispatchZoom(0.25)}
+ onZoomOut={() => dispatchZoom(-0.25)}
+ onResetView={dispatchResetView}
+ isMobile={!isDesktop}
+ viewOptions={viewOptions}
+ onToggleViewOption={toggleViewOption}
+ />
+ )}
>
);
diff --git a/src/components/builder/legacy/StudioLayout.index.tsx.bak b/src/components/builder/legacy/StudioLayout.index.tsx.bak
new file mode 100644
index 00000000..6dc99d42
--- /dev/null
+++ b/src/components/builder/legacy/StudioLayout.index.tsx.bak
@@ -0,0 +1,1574 @@
+// src/components/builder/index.tsx
+import React, { Suspense, lazy, useState, useEffect, useRef, useCallback } from 'react';
+import clsx from 'clsx';
+import type { PosterConfig, ExtensionType, ApiKeys, RatingType } from './types';
+import {
+ DEFAULT_CONFIG,
+ ALL_BADGES,
+ CANVAS_WIDTH,
+ CANVAS_HEIGHT,
+ BASE_BADGE_W,
+ BASE_BADGE_H,
+} from './types';
+import { parseUrlToConfig, DEFAULT_API_BASE, calculateAutoPosition, getScale } from './utils';
+import {
+ BUILDER_STORAGE_KEY,
+ MAX_QUERY_CONFIG_LENGTH,
+ loadKeysFromCookie,
+ saveKeysToCookie,
+} from './systems/storage/builderStorage';
+import PreviewCanvas from './components/PreviewCanvas';
+import LayerPanel from './components/LayerPanel';
+import Inspector from './components/layout/Inspector';
+import AdvancedPanelNav, { type BuilderPanelId } from './components/navigation/AdvancedPanelNav';
+import ModeToggle, { type BuilderMode } from './components/navigation/ModeToggle';
+import {
+ SourcePanel,
+ LayersPanel,
+ PosterPanel,
+ BadgesPanel,
+ SelectionPanel,
+} from './components/panels';
+import MobileDock from './components/layout/MobileDock';
+import ToolbarBtn from './components/toolbar/ToolbarButton';
+import ZoomOverlay from './components/canvas/ZoomOverlay';
+import { EditorProvider, useEditor } from './context/EditorContext';
+import {
+ RotateCcw,
+ Undo2,
+ Redo2,
+ PanelLeft,
+ PanelRight,
+ Maximize2,
+ Minimize2,
+ ZoomIn,
+ ZoomOut,
+ Grid3x3,
+ ShieldCheck,
+ Eye,
+ EyeOff,
+ Layers,
+ CheckSquare,
+ MousePointer2Off,
+ Download,
+ Contrast,
+ ArrowUpToLine,
+ ArrowDownToLine,
+ ScanLine,
+ Keyboard,
+ Type,
+ ChevronDown,
+ Search,
+} from 'lucide-react';
+import { usePosterHistory } from './hooks/usePosterHistory';
+import type { ContextMenuState, LayerTargetId } from './components/ContextMenu';
+import type { PaletteCommand } from './components/CommandPalette';
+
+const KeyboardShortcutsModal = lazy(() => import('./components/KeyboardShortcutsModal'));
+const ResetDialog = lazy(() => import('./components/ResetDialogue'));
+const ImportDialog = lazy(() => import('./components/ImportDialogue'));
+const ExportPopover = lazy(() => import('./components/ExportPopover'));
+const ContextMenu = lazy(() => import('./components/ContextMenu'));
+const CommandPalette = lazy(() => import('./components/CommandPalette'));
+
+// ── Studio layout ─────────────────────────────────────────────────────────────
+const StudioLayout: React.FC<{
+ config: PosterConfig;
+ setConfig: React.Dispatch>;
+ handleReset: () => void;
+ baseUrl: string;
+ handleLoadConfig: (url: string) => void;
+ undo: () => void;
+ redo: () => void;
+ canUndo: boolean;
+ canRedo: boolean;
+ initialMode?: BuilderMode;
+}> = ({
+ config,
+ setConfig,
+ handleReset,
+ baseUrl,
+ handleLoadConfig,
+ undo,
+ redo,
+ canUndo,
+ canRedo,
+ initialMode = 'simple',
+}) => {
+ const {
+ activeTab,
+ setActiveTab,
+ mobileSheetMode,
+ setMobileSheetMode,
+ selectedIds,
+ selectedLogo,
+ selectedMinimalElements,
+ handleSelection,
+ handleLogoSelection,
+ clearSelection,
+ setBatchSelection,
+ viewOptions,
+ toggleViewOption,
+ } = useEditor();
+
+ const [builderMode, setBuilderMode] = useState(initialMode);
+ const [advancedPanel, setAdvancedPanel] = useState('source');
+ const [isResetOpen, setIsResetOpen] = useState(false);
+ const [isImportOpen, setIsImportOpen] = useState(false);
+ const [leftVisible, setLeftVisible] = useState(true);
+ const [rightVisible, setRightVisible] = useState(true);
+ const [isFullscreen, setIsFullscreen] = useState(false);
+ const [shortcutsOpen, setShortcutsOpen] = useState(false);
+ const [exportOpen, setExportOpen] = useState(false);
+ const importBtnRef = useRef(null);
+ const exportBtnRef = useRef(null);
+ const toggleFullscreen = useCallback(() => setIsFullscreen((v) => !v), []);
+
+ useEffect(() => {
+ if (['source', 'layers', 'poster', 'badges', 'selection'].includes(activeTab)) {
+ setAdvancedPanel(activeTab as BuilderPanelId);
+ }
+ }, [activeTab]);
+
+ const switchAdvancedPanel = useCallback(
+ (panel: BuilderPanelId) => {
+ setAdvancedPanel(panel);
+ setActiveTab(panel);
+ },
+ [setActiveTab]
+ );
+
+ const [isDesktop, setIsDesktop] = useState(
+ () => typeof window !== 'undefined' && window.innerWidth >= 1024
+ );
+ useEffect(() => {
+ const mq = window.matchMedia('(min-width: 1024px)');
+ const handle = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
+ mq.addEventListener('change', handle);
+ return () => mq.removeEventListener('change', handle);
+ }, []);
+
+ 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 })), []);
+ const [paletteOpen, setPaletteOpen] = useState(false);
+
+ 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;
+ });
+
+ const dispatchZoom = useCallback(
+ (delta: number) => window.dispatchEvent(new CustomEvent('canvas-zoom', { detail: delta })),
+ []
+ );
+ const dispatchResetView = useCallback(
+ () => window.dispatchEvent(new CustomEvent('reset-canvas-view')),
+ []
+ );
+ const nudgeSelection = useCallback(
+ (dx: number, dy: number) => {
+ const activeBadges = Array.from(selectedIdsRef.current);
+ const activeMinimal = Array.from(selectedMinimalElementsRef.current);
+ const hasLogo = selectedLogoRef.current || activeMinimal.includes('minimal-logo');
+ if (activeBadges.length === 0 && activeMinimal.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;
+ const currY = base.y ?? auto.y;
+ const scale = getScale(next.size) * (base.scale ?? next.scale ?? 1.0);
+ const w = BASE_BADGE_W * scale;
+ const 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 currentX =
+ 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));
+ }
+ if (activeMinimal.includes('minimal-title')) {
+ const boxW = Math.max(120, next.minimalTitleWidth ?? 420);
+ const boxH = Math.max(36, (next.minimalTextSize ?? 42) * 1.5);
+ next.minimalTextX = Math.max(0, Math.min(CANVAS_WIDTH - boxW, next.minimalTextX + dx));
+ const flow = next.minimalTitleFlow ?? 'up';
+ next.minimalTextY =
+ flow === 'up'
+ ? Math.max(boxH, Math.min(CANVAS_HEIGHT, next.minimalTextY + dy))
+ : 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)
+ );
+ }
+ if (activeMinimal.includes('minimal-duration')) {
+ next.minimalDurationX = Math.max(
+ 0,
+ Math.min(CANVAS_WIDTH - 120, (next.minimalDurationX ?? 90) + dx)
+ );
+ next.minimalDurationY = Math.max(
+ 0,
+ Math.min(CANVAS_HEIGHT - 40, (next.minimalDurationY ?? 672) + dy)
+ );
+ }
+ if (activeMinimal.some((id) => id.startsWith('minimal-rating-'))) {
+ const list = [...(next.minimalRatings ?? [])];
+ activeMinimal
+ .filter((id) => id.startsWith('minimal-rating-'))
+ .forEach((id) => {
+ const idx = Number(id.split('-').pop() ?? -1);
+ if (!Number.isFinite(idx) || !list[idx]) return;
+ list[idx] = {
+ ...list[idx],
+ x: Math.max(0, Math.min(CANVAS_WIDTH - 140, list[idx].x + dx)),
+ y: Math.max(0, Math.min(CANVAS_HEIGHT - 40, list[idx].y + dy)),
+ };
+ });
+ next.minimalRatings = list;
+ }
+ return next;
+ });
+ },
+ [setConfig]
+ );
+
+ // Auto-open Edit panel on mobile when badge is tapped — DISABLED (drawer requires deliberate open)
+ const handleSelectionOverride = useCallback(
+ (id: RatingType, multi: boolean) => {
+ handleSelection(id, multi);
+ },
+ [handleSelection]
+ );
+
+ const moveLayer = useCallback(
+ (id: RatingType, direction: '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 (direction === 'front') arr.push(id);
+ else if (direction === 'forward') arr.splice(Math.min(idx + 1, arr.length), 0, id);
+ else if (direction === '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(
+ (direction: 'front' | 'forward' | 'back' | 'toback') => {
+ setConfig((prev) => {
+ const current = prev.logoZ ?? 90;
+ if (direction === 'front') return { ...prev, logoZ: 220 };
+ if (direction === 'toback') return { ...prev, logoZ: 1 };
+ if (direction === 'forward') return { ...prev, logoZ: Math.min(220, current + 1) };
+ return { ...prev, logoZ: Math.max(1, current - 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]
+ );
+
+ // ── 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 (selectedIds.size > 0 || selectedLogo) {
+ 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 (
+ (e.key === 'ArrowUp' ||
+ e.key === 'ArrowDown' ||
+ e.key === 'ArrowLeft' ||
+ e.key === 'ArrowRight') &&
+ (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 if (e.key === 'ArrowRight') 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();
+ setLeftVisible((v) => !v);
+ 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 === 0) 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;
+ }
+ if (mod && e.key.toLowerCase() === 'b') {
+ e.preventDefault();
+ setConfig((p) => ({ ...p, posterBlur: p.posterBlur > 0 ? 0 : 8 }));
+ 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,
+ ]);
+
+ // ── Panel widths ──────────────────────────────────────────────────────────
+ const [leftW, setLeftW] = useState(272);
+ const [rightW, setRightW] = useState(272);
+
+ const startResizeLeft = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ const sx = e.clientX,
+ sw = leftW;
+ const move = (m: MouseEvent) => setLeftW(Math.max(220, Math.min(sw + m.clientX - sx, 540)));
+ const up = () => {
+ document.removeEventListener('mousemove', move);
+ document.removeEventListener('mouseup', up);
+ document.body.style.cursor = '';
+ document.body.classList.remove('sidebar-resizing');
+ };
+ document.addEventListener('mousemove', move);
+ document.addEventListener('mouseup', up);
+ document.body.style.cursor = 'col-resize';
+ document.body.classList.add('sidebar-resizing');
+ },
+ [leftW]
+ );
+
+ const startResizeRight = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ const sx = e.clientX,
+ sw = rightW;
+ const move = (m: MouseEvent) =>
+ setRightW(Math.max(248, Math.min(sw - (m.clientX - sx), 540)));
+ const up = () => {
+ document.removeEventListener('mousemove', move);
+ document.removeEventListener('mouseup', up);
+ document.body.style.cursor = '';
+ document.body.classList.remove('sidebar-resizing');
+ };
+ document.addEventListener('mousemove', move);
+ document.addEventListener('mouseup', up);
+ document.body.style.cursor = 'col-resize';
+ document.body.classList.add('sidebar-resizing');
+ },
+ [rightW]
+ );
+
+ const handleExtensionChange = useCallback(
+ (ext: ExtensionType) => {
+ setConfig((prev) => ({ ...prev, extension: ext }));
+ },
+ [setConfig]
+ );
+
+ // ── Command palette commands ──────────────────────────────────────────────
+ const paletteCommands: PaletteCommand[] = [
+ {
+ id: 'zoom-fit',
+ label: 'Zoom to Fit',
+ category: 'View & Canvas',
+ icon: ,
+ shortcut: '⌘1',
+ keywords: ['reset', 'fit', 'view'],
+ 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 Canvas',
+ category: 'View & Canvas',
+ icon: isFullscreen ? : ,
+ shortcut: 'F',
+ action: toggleFullscreen,
+ },
+ {
+ id: 'grid',
+ label: `${viewOptions.showGrid ? 'Hide' : 'Show'} Grid Overlay`,
+ category: 'View & Canvas',
+ icon: ,
+ shortcut: 'G',
+ keywords: ['grid', 'lines'],
+ action: () => toggleViewOption('showGrid'),
+ },
+ {
+ id: 'safe-area',
+ label: `${viewOptions.showSafeArea ? 'Hide' : 'Show'} Safe Area`,
+ category: 'View & Canvas',
+ icon: ,
+ shortcut: "'",
+ keywords: ['safe', 'area', 'zone'],
+ action: () => toggleViewOption('showSafeArea'),
+ },
+ {
+ id: 'sidebar-left',
+ label: `${leftVisible ? 'Hide' : 'Show'} Left Sidebar`,
+ category: 'View & Canvas',
+ icon: ,
+ shortcut: '[',
+ keywords: ['layers', 'source', 'panel'],
+ action: () => setLeftVisible((v) => !v),
+ },
+ {
+ id: 'sidebar-right',
+ label: `${rightVisible ? 'Hide' : 'Show'} Right Sidebar`,
+ category: 'View & Canvas',
+ icon: ,
+ shortcut: ']',
+ keywords: ['inspector', 'panel'],
+ action: () => setRightVisible((v) => !v),
+ },
+ {
+ id: 'shortcuts-help',
+ label: 'Show Keyboard Shortcuts',
+ category: 'View & Canvas',
+ icon: ,
+ shortcut: '⌘/',
+ keywords: ['help', 'keys', 'hotkeys'],
+ action: () => setShortcutsOpen(true),
+ },
+ {
+ 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: ,
+ keywords: ['reveal', 'unhide'],
+ action: showAllBadges,
+ },
+ {
+ id: 'hide-sel',
+ label: 'Hide Selected Badges',
+ category: 'Layers & Selection',
+ icon: ,
+ shortcut: 'H',
+ keywords: ['hide', 'selected'],
+ action: () => Array.from(selectedIds).forEach((id) => hideBadge(id as RatingType)),
+ },
+ {
+ id: 'layer-front',
+ label: 'Bring to Front',
+ category: 'Layers & Selection',
+ icon: ,
+ shortcut: '⌘⇧]',
+ action: () => Array.from(selectedIds).forEach((id) => moveLayer(id as RatingType, 'front')),
+ },
+ {
+ id: 'layer-back',
+ label: 'Send to Back',
+ category: 'Layers & Selection',
+ icon: ,
+ shortcut: '⌘⇧[',
+ action: () => Array.from(selectedIds).forEach((id) => moveLayer(id as RatingType, 'toback')),
+ },
+ {
+ id: 'delete-sel',
+ label: 'Delete Selected Badges',
+ category: 'Layers & Selection',
+ icon: ,
+ shortcut: 'Del',
+ keywords: ['remove', 'delete'],
+ action: () => {
+ const rm = new Set(selectedIds);
+ setConfig((p) => ({ ...p, ratings: p.ratings.filter((r) => !rm.has(r)) }));
+ clearSelection();
+ },
+ },
+ {
+ id: 'grayscale',
+ label: `${config.grayscale ? 'Remove' : 'Apply'} Grayscale`,
+ category: 'Canvas Properties',
+ icon: ,
+ keywords: ['grayscale', 'bw', 'black', 'white'],
+ action: () => setConfig((p) => ({ ...p, grayscale: !p.grayscale })),
+ },
+ {
+ id: 'blur-0',
+ label: 'Remove Poster Blur',
+ category: 'Canvas Properties',
+ icon: ,
+ keywords: ['blur', 'clear', 'sharp'],
+ action: () => setConfig((p) => ({ ...p, posterBlur: 0 })),
+ },
+ {
+ id: 'blur-8',
+ label: 'Poster Blur: Medium (8px)',
+ category: 'Canvas Properties',
+ icon: ,
+ keywords: ['blur', 'medium'],
+ action: () => setConfig((p) => ({ ...p, posterBlur: 8 })),
+ },
+ {
+ id: 'toggle-text',
+ label: `${config.showText !== false ? 'Hide' : 'Show'} Rating Text`,
+ category: 'Badges',
+ icon: ,
+ keywords: ['text', 'numbers', 'rating', 'show', 'hide'],
+ action: () => setConfig((p) => ({ ...p, showText: !(p.showText !== false) })),
+ },
+ {
+ 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);
+ },
+ },
+ {
+ id: 'reset',
+ label: 'Reset All Settings',
+ category: 'File',
+ icon: ,
+ keywords: ['reset', 'clear', 'default'],
+ action: () => setIsResetOpen(true),
+ },
+ {
+ id: 'undo',
+ label: 'Undo',
+ category: 'File',
+ icon: ,
+ shortcut: '⌘Z',
+ action: undo,
+ },
+ {
+ id: 'redo',
+ label: 'Redo',
+ category: 'File',
+ icon: ,
+ shortcut: '⌘Y',
+ action: redo,
+ },
+ ];
+
+ const ctxBadgeSelected = ctxMenu.badgeId
+ ? ctxMenu.badgeId === 'logo'
+ ? selectedLogo
+ : selectedIds.has(ctxMenu.badgeId)
+ : false;
+
+ const advancedDetailLevel = builderMode === 'advanced' ? 'advanced' : 'simple';
+ const sharedPanelProps = {
+ config,
+ setConfig,
+ selectedIds,
+ onSelect: handleSelectionOverride,
+ detailLevel: advancedDetailLevel as 'simple' | 'advanced',
+ };
+ const sharedInspectorProps = {
+ config,
+ setConfig,
+ selectedIds,
+ selectedLogo,
+ selectedMinimalElements,
+ detailLevel: advancedDetailLevel as 'simple' | 'advanced',
+ };
+ const renderAdvancedPanel = () => {
+ switch (advancedPanel) {
+ case 'source':
+ return ;
+ case 'layers':
+ return ;
+ case 'poster':
+ return ;
+ case 'badges':
+ return ;
+ case 'selection':
+ return ;
+ default:
+ return null;
+ }
+ };
+
+ return (
+ <>
+
+ Skip to canvas
+
+
+
+
+
+
Posterium Poster Builder
+
+ {(isResetOpen || isImportOpen || shortcutsOpen || ctxMenu.visible || paletteOpen || exportOpen) && (
+
+ {isResetOpen && (
+ setIsResetOpen(false)}
+ onConfirm={handleReset}
+ />
+ )}
+ {isImportOpen && (
+ setIsImportOpen(false)}
+ onLoad={handleLoadConfig}
+ anchorRef={importBtnRef}
+ />
+ )}
+ {shortcutsOpen && (
+ setShortcutsOpen(false)}
+ />
+ )}
+ {ctxMenu.visible && (
+
+ 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}
+ />
+ )}
+ {paletteOpen && (
+ setPaletteOpen(false)}
+ commands={paletteCommands}
+ />
+ )}
+
+ {exportOpen && (
+ setExportOpen(false)}
+ anchorRef={exportBtnRef}
+ />
+ )}
+
+ )}
+
+ {/* ── HEADER ── */}
+ {!isFullscreen && (
+
+ )}
+
+ {/* ── BODY ── */}
+
+ {/* Left sidebar */}
+ {!isFullscreen && (
+
+ {builderMode === 'advanced' ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+
+ {/* Canvas */}
+
{
+ if (e.target === e.currentTarget) clearSelection();
+ if (mobileSheetMode !== 'hidden') setMobileSheetMode('hidden');
+ }}
+ >
+
+
+ {/* Sidebar Canvas Toggles removed — now in navbar */}
+
+ openCtxMenu('logo', e)}
+ />
+ {/* Film corner accents */}
+ {(['tl', 'tr', 'bl', 'br'] as const).map((c) => (
+
+ ))}
+
+
+ {/* Right sidebar */}
+ {!isFullscreen && (
+
+
+ {builderMode === 'advanced' ? (
+ renderAdvancedPanel()
+ ) : (
+
+ )}
+
+ )}
+
+ {/* Mobile Panel (In-flow flex item) */}
+ {!isFullscreen && (
+
+ {/* Swipe-down dismiss handle */}
+
setMobileSheetMode('hidden')}
+ onTouchStart={(e) => {
+ const startY = e.touches[0].clientY;
+ const onMove = (ev: TouchEvent) => {
+ if (ev.touches[0].clientY - startY > 40) {
+ setMobileSheetMode('hidden');
+ window.removeEventListener('touchmove', onMove);
+ }
+ };
+ window.addEventListener('touchmove', onMove, { passive: true });
+ const cleanup = () => {
+ window.removeEventListener('touchmove', onMove);
+ };
+ window.addEventListener('touchend', cleanup, { once: true });
+ }}
+ >
+
+
+
+
+ {(activeTab === 'source' || activeTab === 'layers' || activeTab === 'poster') && (
+
+ )}
+ {(activeTab === 'badges' || activeTab === 'selection') && (
+
+ )}
+
+
+ )}
+
+
+ {/* Mobile dock */}
+
0}
+ hasLogo={config.logo}
+ isMinimalPreset={(config.uiPreset ?? 'b') === 'm'}
+ selectedCount={selectedIds.size + (selectedLogo ? 1 : 0)}
+ />
+
+ {/* Zoom + fullscreen overlay — always visible */}
+ dispatchZoom(0.25)}
+ onZoomOut={() => dispatchZoom(-0.25)}
+ onResetView={dispatchResetView}
+ isMobile={!isDesktop}
+ viewOptions={viewOptions}
+ onToggleViewOption={toggleViewOption}
+ />
+
+ >
+ );
+};
+
+// ── Root app ──────────────────────────────────────────────────────────────────
+const BuilderApp: React.FC<{ initialMode?: BuilderMode }> = ({ initialMode = 'simple' }) => {
+ const {
+ state: config,
+ setState: setConfig,
+ undo,
+ redo,
+ canUndo,
+ canRedo,
+ } = usePosterHistory(() => {
+ try {
+ const saved = localStorage.getItem(BUILDER_STORAGE_KEY);
+ const cfg = saved ? (JSON.parse(saved) as PosterConfig) : DEFAULT_CONFIG;
+ const cookieKeys = loadKeysFromCookie();
+ if (cookieKeys && Object.keys(cookieKeys).some((k) => cookieKeys[k as keyof ApiKeys])) {
+ return { ...cfg, keys: { ...cookieKeys, ...cfg.keys } };
+ }
+ return cfg;
+ } catch {
+ return DEFAULT_CONFIG;
+ }
+ });
+
+ const [baseUrl, setBaseUrl] = useState(DEFAULT_API_BASE);
+
+ useEffect(() => {
+ localStorage.setItem(BUILDER_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 {
+ /* keep */
+ }
+ },
+ [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) return;
+ if (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 {
+ // ignore malformed config input
+ }
+ }, [handleLoadConfig, setConfig]);
+
+ return (
+
+
+
+ );
+};
+
+export default BuilderApp;
diff --git a/src/components/builder/legacy/components/LayerPanel.tsx.bak b/src/components/builder/legacy/components/LayerPanel.tsx.bak
new file mode 100644
index 00000000..01ef33ea
--- /dev/null
+++ b/src/components/builder/legacy/components/LayerPanel.tsx.bak
@@ -0,0 +1,1512 @@
+// src/components/builder/components/LayerPanel.tsx
+import React, { useState, useEffect, useCallback, useRef } from 'react';
+import { Combobox, Switch } from '@headlessui/react';
+import {
+ Check,
+ Search,
+ Loader2,
+ GripVertical,
+ Film,
+ Layers,
+ Badge,
+ Tv,
+ Clapperboard,
+ Eye,
+ EyeOff,
+ ImagePlay,
+ KeyRound,
+ Monitor,
+ ShieldCheck,
+ Grid3x3,
+ Magnet,
+} from 'lucide-react';
+import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
+import type { DropResult, DraggableProvided } from '@hello-pangea/dnd';
+import clsx from 'clsx';
+import type { PosterConfig, RatingType, ApiKeys } from '../types';
+import { ALL_BADGES, DEFAULT_CONFIG } from '../types';
+import { BADGE_ICONS } from '../constants';
+import { DEFAULT_API_BASE } from '../utils';
+import { useEditor } from '../context/EditorContext';
+import SidebarLayout from './SidebarLayout';
+import PanelTabs from './navigation/PanelTabs';
+import { Section, SegmentedRow, SelectBox, SliderRow, ToggleRow } from './ui';
+
+type BadgeIconKey = keyof typeof BADGE_ICONS;
+
+interface Props {
+ config: PosterConfig;
+ setConfig: React.Dispatch>;
+ selectedIds: Set;
+ onSelect: (id: RatingType, multi: boolean) => void;
+ panelMode?: 'source' | 'layers' | 'poster';
+ hideTabs?: boolean;
+ detailLevel?: 'simple' | 'advanced';
+}
+
+interface SearchResult {
+ id: number;
+ title?: string;
+ name?: string;
+ poster_path: string;
+ release_date?: string;
+ first_air_date?: string;
+ media_type: 'movie' | 'tv';
+}
+
+const BADGES_PREF_STORAGE_KEY = 'posterium_badges_toggle_pref_v1';
+const TEXTLESS_PREF_STORAGE_KEY = 'posterium_textless_toggle_pref_v1';
+
+const writeBadgesPreference = (enabled: boolean) => {
+ try {
+ localStorage.setItem(BADGES_PREF_STORAGE_KEY, enabled ? '1' : '0');
+ } catch {}
+};
+
+const writeTextlessPreference = (enabled: boolean) => {
+ try {
+ localStorage.setItem(TEXTLESS_PREF_STORAGE_KEY, enabled ? '1' : '0');
+ } catch {}
+};
+
+// ── API Keys panel ────────────────────────────────────────────────────────────
+const ApiKeysPanel: React.FC<{
+ config: PosterConfig;
+ setConfig: React.Dispatch>;
+}> = ({ config, setConfig }) => {
+ const [showTmdb, setShowTmdb] = useState(false);
+ const [showFanart, setShowFanart] = useState(false);
+
+ const updateKeys = useCallback(
+ (key: keyof ApiKeys, value: string) =>
+ setConfig((prev) => ({ ...prev, keys: { ...prev.keys, [key]: value } })),
+ [setConfig]
+ );
+
+ const inputStyle: React.CSSProperties = {
+ width: '100%',
+ height: 32,
+ paddingLeft: 10,
+ paddingRight: 32,
+ borderRadius: 8,
+ background: 'rgba(255,255,255,0.04)',
+ border: '1px solid rgba(255,255,255,0.1)',
+ fontSize: 11,
+ fontFamily: 'JetBrains Mono, monospace',
+ color: 'var(--film-pale)',
+ transition: 'border-color 0.15s',
+ };
+
+ return (
+
+
+ Override the default API keys used to fetch ratings and posters. Stored in a browser cookie.
+
+
+ {[
+ { key: 'tmdb' as const, label: 'TMDB Key', show: showTmdb, setShow: setShowTmdb },
+ {
+ key: 'fanart' as const,
+ label: 'Fanart.tv Key',
+ show: showFanart,
+ setShow: setShowFanart,
+ },
+ ].map(({ key, label, show, setShow }) => (
+
+
+ {label}
+
+
+ updateKeys(key, e.target.value)}
+ placeholder={`Override default ${key === 'tmdb' ? 'TMDB' : 'Fanart.tv'} key`}
+ style={inputStyle}
+ className="focus:outline-none"
+ onMouseEnter={(e) => {
+ (e.currentTarget as HTMLElement).style.borderColor = 'rgba(196,124,46,0.4)';
+ }}
+ onMouseLeave={(e) => {
+ if (document.activeElement !== e.currentTarget) {
+ (e.currentTarget as HTMLElement).style.borderColor = 'rgba(255,255,255,0.1)';
+ }
+ }}
+ onFocus={(e) => {
+ (e.currentTarget as HTMLElement).style.borderColor = 'rgba(196,124,46,0.4)';
+ }}
+ onBlur={(e) => {
+ (e.currentTarget as HTMLElement).style.borderColor = 'rgba(255,255,255,0.1)';
+ }}
+ />
+ setShow((v) => !v)}
+ className="absolute right-2 top-1/2 -translate-y-1/2 transition-colors"
+ style={{ color: 'var(--film-text-dim)' }}
+ onMouseEnter={(e) => {
+ (e.currentTarget as HTMLElement).style.color = 'var(--film-text-label)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLElement).style.color = 'var(--film-text-dim)';
+ }}
+ >
+
+
+
+
+ ))}
+
+ );
+};
+
+// ── Main LayerPanel component ─────────────────────────────────────────────────
+const LayerPanel: React.FC = ({
+ config,
+ setConfig,
+ selectedIds,
+ onSelect,
+ panelMode,
+ hideTabs = false,
+ detailLevel = 'simple',
+}) => {
+ const {
+ setBatchSelection,
+ activeTab,
+ setActiveTab,
+ selectedLogo,
+ handleLogoSelection,
+ setLiveRatings,
+ setLiveTitle,
+ setLiveYear,
+ fallbackEnabled,
+ setFallbackEnabled,
+ viewOptions,
+ toggleViewOption,
+ } = useEditor();
+
+ const isAdvanced = detailLevel === 'advanced';
+
+ const [localMode, setLocalMode] = useState<'source' | 'layers' | 'poster'>(panelMode ?? 'source');
+ const [inactiveOrder, setInactiveOrder] = useState([]);
+
+ useEffect(() => {
+ if (panelMode) {
+ setLocalMode(panelMode);
+ return;
+ }
+ if (activeTab === 'source' || activeTab === 'poster' || activeTab === 'layers')
+ setLocalMode(activeTab);
+ }, [activeTab, panelMode]);
+
+ const [searchQuery, setSearchQuery] = useState('');
+ const [results, setResults] = useState([]);
+ const [isSearching, setIsSearching] = useState(false);
+
+ useEffect(() => {
+ const ctrl = new AbortController();
+ const t = setTimeout(async () => {
+ if (!searchQuery || searchQuery.length < 2) {
+ setResults([]);
+ return;
+ }
+ setIsSearching(true);
+ try {
+ const res = await fetch(`${DEFAULT_API_BASE}/search?q=${encodeURIComponent(searchQuery)}`, {
+ signal: ctrl.signal,
+ });
+ const data = await res.json();
+ if (data.results)
+ setResults(
+ data.results.filter(
+ (i: SearchResult) => i.poster_path && ['movie', 'tv'].includes(i.media_type)
+ )
+ );
+ } catch (e) {
+ if (e instanceof Error && e.name === 'AbortError') return;
+ } finally {
+ setIsSearching(false);
+ }
+ }, 400);
+ return () => {
+ clearTimeout(t);
+ ctrl.abort();
+ };
+ }, [searchQuery]);
+
+ useEffect(() => {
+ if (!searchQuery) {
+ setResults([]);
+ }
+ }, [searchQuery]);
+
+ const [fetchedData, setFetchedData] = useState>({});
+ const savedActiveBadgesRef = useRef([]);
+ const badgesVisible = config.ratings.length > 0;
+ const titleBadgeEnabled = config.ratings.includes('title');
+
+ useEffect(() => {
+ if ((config.uiPreset ?? 'b') !== 'b') {
+ setConfig((prev) => ({ ...prev, uiPreset: 'b' }));
+ }
+ }, [config.uiPreset, setConfig]);
+
+ useEffect(() => {
+ if (!config.tmdbId && !config.imdbId) return;
+ const ctrl = new AbortController();
+ (async () => {
+ try {
+ const idPath = config.imdbId
+ ? `/poster/${config.imdbId}`
+ : `/${config.mediaType}/${config.tmdbId}`;
+ const res = await fetch(`${DEFAULT_API_BASE}${idPath}.json?source=${config.source}`, {
+ signal: ctrl.signal,
+ });
+ if (!res.ok) return;
+ const data = await res.json();
+ const merged: Record = {};
+ if (data.meta?.title) merged.title = data.meta.title;
+ if (data.meta?.year) merged.year = String(data.meta.year).replace(/\.0+$/, '');
+ const VALID_RATING_KEYS = [
+ 'imdb',
+ 'rt',
+ 'rt_popcorn',
+ 'letterboxd',
+ 'meta',
+ 'tmdb',
+ 'mal',
+ 'anilist',
+ 'age',
+ 'runtime',
+ ];
+ if (data.ratings) {
+ Object.entries(data.ratings).forEach(([k, v]) => {
+ if (VALID_RATING_KEYS.includes(k)) merged[k] = String(v);
+ });
+ }
+ setFetchedData(merged);
+ const liveRatingsFiltered: Record = {};
+ if (data.ratings) {
+ Object.entries(data.ratings).forEach(([k, v]) => {
+ if (VALID_RATING_KEYS.includes(k)) liveRatingsFiltered[k] = String(v);
+ });
+ }
+ setLiveRatings(liveRatingsFiltered);
+ setLiveTitle(merged.title ?? '');
+ setLiveYear(merged.year ?? '');
+ if (data.ids?.imdb && data.ids.imdb !== config.imdbId) {
+ setConfig((prev) => ({ ...prev, imdbId: data.ids.imdb }));
+ }
+ } catch (e: unknown) {
+ if (e instanceof Error && e.name === 'AbortError') return;
+ }
+ })();
+ return () => ctrl.abort();
+ }, [
+ config.tmdbId,
+ config.imdbId,
+ config.mediaType,
+ config.source,
+ setLiveRatings,
+ setLiveTitle,
+ setLiveYear,
+ setConfig,
+ ]);
+
+ useEffect(() => {
+ setFallbackEnabled(config.fallbackEnabled);
+ }, [config.fallbackEnabled, setFallbackEnabled]);
+
+ const handleSelectMedia = useCallback(
+ (item: SearchResult | null) => {
+ if (!item) return;
+ setFetchedData({
+ title: item.title || item.name || '',
+ year: (item.release_date || item.first_air_date)?.split('-')[0] || '',
+ });
+ setConfig((prev) => ({
+ ...prev,
+ tmdbId: item.id.toString(),
+ imdbId: undefined,
+ mediaType: item.media_type as PosterConfig['mediaType'],
+ }));
+ setSearchQuery('');
+ setResults([]);
+ },
+ [setConfig]
+ );
+
+ const updateConfig = useCallback(
+ (key: K, value: PosterConfig[K]) =>
+ setConfig((prev) => ({ ...prev, [key]: value })),
+ [setConfig]
+ );
+
+ const disableBadges = useCallback(
+ ({ persistPreference = true }: { persistPreference?: boolean } = {}) => {
+ if (persistPreference) writeBadgesPreference(false);
+ setConfig((prev) => {
+ if (prev.ratings.length > 0) {
+ savedActiveBadgesRef.current = [...prev.ratings];
+ }
+ return { ...prev, ratings: [] };
+ });
+ setBatchSelection([]);
+ },
+ [setConfig, setBatchSelection]
+ );
+
+ const enableBadges = useCallback(() => {
+ writeBadgesPreference(true);
+ setConfig((prev) => {
+ if (prev.ratings.length > 0) return prev;
+ const restored = savedActiveBadgesRef.current.filter((id) =>
+ ALL_BADGES.some((badge) => badge.id === id)
+ );
+ return { ...prev, ratings: restored.length > 0 ? restored : DEFAULT_CONFIG.ratings };
+ });
+ }, [setConfig]);
+
+ useEffect(() => {
+ writeTextlessPreference(config.textless);
+ }, [config.textless]);
+
+ const handleToggleVisibility = useCallback(
+ (id: RatingType, visible: boolean) => {
+ if (visible) {
+ setConfig((prev) => {
+ 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] ?? {}) } };
+ const titleItem = nextItems[id];
+ if (titleItem) {
+ delete titleItem.x;
+ delete titleItem.y;
+ }
+ return { ...prev, ratings: [id, ...prev.ratings], items: nextItems };
+ });
+ setInactiveOrder((prev) => prev.filter((x) => x !== id));
+ onSelect(id, false);
+ setActiveTab('selection');
+ } else {
+ setConfig((prev) => ({ ...prev, ratings: prev.ratings.filter((r) => r !== id) }));
+ setInactiveOrder((prev) => [id, ...prev.filter((x) => x !== id)]);
+ }
+ },
+ [onSelect, setActiveTab, setConfig]
+ );
+
+ const allVisible = config.ratings.length === ALL_BADGES.length;
+ const handleToggleAll = useCallback(() => {
+ if (allVisible) {
+ setConfig((prev) => {
+ setInactiveOrder((io) => [...[...prev.ratings].reverse(), ...io]);
+ return { ...prev, ratings: [] };
+ });
+ } else {
+ setConfig((prev) => {
+ const missing = ALL_BADGES.filter((b) => !prev.ratings.includes(b.id)).map((b) => b.id);
+ return { ...prev, ratings: [...missing, ...prev.ratings] };
+ });
+ setInactiveOrder([]);
+ }
+ }, [allVisible, setConfig]);
+
+ const allVisibleSelected =
+ config.ratings.length > 0 && config.ratings.every((r) => selectedIds.has(r));
+
+ const handleSelectAll = useCallback(
+ (checked: boolean) => {
+ setBatchSelection(
+ checked ? ALL_BADGES.filter((b) => config.ratings.includes(b.id)).map((b) => b.id) : []
+ );
+ },
+ [setBatchSelection, config.ratings]
+ );
+
+ const activeBadges = [...config.ratings]
+ .filter((id) => ALL_BADGES.some((b) => b.id === id))
+ .map((id, idx) => ({
+ kind: 'badge' as const,
+ id,
+ label: ALL_BADGES.find((b) => b.id === id)?.label ?? id.toUpperCase(),
+ z: 100 + idx,
+ }));
+ const activeLayers = [
+ ...activeBadges,
+ ...(config.logo
+ ? [
+ {
+ kind: 'logo' as const,
+ id: 'logo',
+ label: 'Logo',
+ z: config.logoZ ?? 90,
+ },
+ ]
+ : []),
+ ].sort((a, b) => b.z - a.z);
+
+ const inactiveBadges = ALL_BADGES.filter((b) => !config.ratings.includes(b.id)).sort((a, b) => {
+ const ia = inactiveOrder.indexOf(a.id),
+ ib = inactiveOrder.indexOf(b.id);
+ if (ia === -1 && ib === -1) return ALL_BADGES.indexOf(a) - ALL_BADGES.indexOf(b);
+ if (ia === -1) return 1;
+ if (ib === -1) return -1;
+ return ia - ib;
+ });
+
+ const handleDragEnd = useCallback(
+ (result: DropResult) => {
+ if (!result.destination) return;
+ if (result.source.droppableId === 'active' && result.destination.droppableId === 'active') {
+ if (result.source.index === result.destination.index) return;
+ const ordered = activeLayers.map((l) => l.id);
+ const [removed] = ordered.splice(result.source.index, 1);
+ ordered.splice(result.destination.index, 0, removed);
+ const logoIndex = ordered.indexOf('logo');
+ const badgeTopToBottom = ordered.filter((id): id is RatingType => id !== 'logo');
+ setConfig((prev) => ({
+ ...prev,
+ ratings: [...badgeTopToBottom].reverse(),
+ logoZ: logoIndex === -1 ? prev.logoZ : 100 + (ordered.length - logoIndex - 1),
+ }));
+ } else if (
+ result.source.droppableId === 'inactive' &&
+ result.destination.droppableId === 'inactive' &&
+ fallbackEnabled
+ ) {
+ if (result.source.index === result.destination.index) return;
+ const nextOrder = inactiveBadges.map((b) => b.id);
+ const [removed] = nextOrder.splice(result.source.index, 1);
+ nextOrder.splice(result.destination.index, 0, removed);
+ setInactiveOrder(nextOrder);
+ setConfig((prev) => ({ ...prev, fallbackPool: nextOrder }));
+ }
+ },
+ [activeLayers, inactiveBadges, fallbackEnabled, setConfig]
+ );
+
+ const getIconKey = (id: string): BadgeIconKey =>
+ id === 'rt' ? 'rt_fresh' : id === 'rt_popcorn' ? 'popcorn_fresh' : (id as BadgeIconKey);
+
+ const MediaIcon =
+ config.mediaType === 'tv' ? Tv : config.mediaType === 'anime' ? Clapperboard : Film;
+
+ const sourceOptions = [
+ { id: 'tmdb', label: 'TMDB' },
+ { id: 'fanart', label: 'Fanart.tv' },
+ { id: 'metahub', label: 'Metahub' },
+ { id: 'imdb', label: 'IMDb' },
+ ...(config.mediaType === 'anime'
+ ? [
+ { id: 'mal', label: 'MyAnimeList' },
+ { id: 'anilist', label: 'AniList' },
+ ]
+ : []),
+ ];
+
+ const ptypeOptions = [
+ { id: 'auto', label: 'Auto (Default)' },
+ { id: 'top1', label: 'Top 1' },
+ { id: 'top2', label: 'Top 2' },
+ { id: 'top3', label: 'Top 3' },
+ ...(config.source === 'tmdb' ? [{ id: 'best', label: 'Best (Bayesian)' }] : []),
+ ...(config.source === 'fanart'
+ ? [
+ { id: 'latest', label: 'Latest' },
+ { id: 'oldest', label: 'Oldest' },
+ ]
+ : []),
+ { id: 'random', label: 'Random' },
+ ];
+ const logoSourceOptions = [
+ { id: 'auto', label: 'Auto' },
+ { id: 'fanart', label: 'Fanart' },
+ { id: 'tmdb', label: 'TMDB' },
+ { id: 'metahub', label: 'Hub' },
+ ];
+
+ const renderBadgeRow = (
+ badge: { id: RatingType; label: string },
+ isActive: boolean,
+ provided?: DraggableProvided,
+ isDraggingItem?: boolean
+ ) => {
+ const isSel = selectedIds.has(badge.id);
+ const ratingVal = fetchedData[badge.id];
+ const iconKey = getIconKey(badge.id);
+ const iconData = BADGE_ICONS[iconKey] || BADGE_ICONS[badge.id];
+ const iconColor = isActive ? (iconData?.color ?? 'var(--film-text-dim)') : 'rgba(74,74,82,0.6)';
+ const inactiveOpacity = fallbackEnabled ? 'opacity-70' : 'opacity-50';
+
+ const handleCheckboxClick = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ if (isActive) {
+ onSelect(badge.id, true);
+ } else {
+ handleToggleVisibility(badge.id, true);
+ }
+ };
+
+ return (
+ {
+ if (isActive) onSelect(badge.id, e.shiftKey || e.ctrlKey || e.metaKey);
+ }}
+ className={clsx(
+ 'flex items-center gap-2 px-2 py-2 rounded-lg transition-all select-none',
+ isSel
+ ? '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'
+ : inactiveOpacity,
+ isDraggingItem && 'shadow-2xl rotate-[0.5deg]'
+ )}
+ style={
+ isDraggingItem
+ ? { background: 'var(--film-mid)', ...(provided?.draggableProps.style ?? {}) }
+ : (provided?.draggableProps.style ?? {})
+ }
+ >
+ {/* Drag handle */}
+ {isActive || fallbackEnabled ? (
+
e.stopPropagation()}
+ className="p-0.5 outline-none transition-colors shrink-0"
+ style={{ color: 'var(--film-text-dim)', cursor: 'grab' }}
+ onMouseEnter={(e) => {
+ (e.currentTarget as HTMLElement).style.color = 'var(--film-text-label)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLElement).style.color = 'var(--film-text-dim)';
+ }}
+ >
+
+
+ ) : (
+
+ )}
+
+ {/* Checkbox */}
+
+
+ {/* Icon */}
+
+ {badge.id === 'age' ? (
+
+ PG
+
+ ) : iconData ? (
+
+ ) : (
+
+ {badge.label.slice(0, 2)}
+
+ )}
+
+
+ {/* Label + rating */}
+
+
+ {badge.label}
+
+ {isActive && ratingVal && badge.id !== 'title' && (
+
+ {badge.id === 'year' ? ratingVal.replace(/\.0+$/, '') : ratingVal}
+
+ )}
+
+
+ {/* Visibility toggle */}
+
e.stopPropagation()} className="shrink-0">
+ handleToggleVisibility(badge.id, !isActive)}
+ className="w-7 h-7 rounded-md flex items-center justify-center transition-colors"
+ style={{ color: isActive ? 'var(--film-text-dim)' : 'rgba(110,110,120,0.7)' }}
+ title={isActive ? 'Hide badge' : 'Show badge'}
+ onMouseEnter={(e) => {
+ (e.currentTarget as HTMLElement).style.color = 'var(--film-text-dim)';
+ (e.currentTarget as HTMLElement).style.background = 'rgba(255,255,255,0.05)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLElement).style.color = isActive
+ ? 'var(--film-text-dim)'
+ : 'rgba(110,110,120,0.7)';
+ (e.currentTarget as HTMLElement).style.background = 'transparent';
+ }}
+ >
+ {isActive ? : }
+
+
+
+ );
+ };
+
+ const renderLogoLayerRow = (
+ isActive = true,
+ provided?: DraggableProvided,
+ isDraggingItem?: boolean
+ ) => {
+ const enableLogoAndFocus = () => {
+ updateConfig('logo', true);
+ handleLogoSelection(false);
+ 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)]'
+ : 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 ? (
+
e.stopPropagation()}
+ className="p-0.5 outline-none transition-colors shrink-0"
+ style={{ color: 'var(--film-text-dim)', cursor: 'grab' }}
+ >
+
+
+ ) : (
+
+ )}
+
{
+ e.stopPropagation();
+ if (!isActive) enableLogoAndFocus();
+ else handleLogoSelection(false);
+ }}
+ >
+
+ {selectedLogo && isActive &&
}
+
+
+
+
+
+
+
+ Logo
+
+
+
e.stopPropagation()} className="shrink-0">
+ (config.logo ? updateConfig('logo', false) : enableLogoAndFocus())}
+ className="w-7 h-7 rounded-md flex items-center justify-center transition-colors"
+ style={{ color: config.logo ? 'var(--film-text-dim)' : 'rgba(110,110,120,0.7)' }}
+ title={config.logo ? 'Hide layer' : 'Show layer'}
+ >
+ {config.logo ? : }
+
+
+
+ );
+ };
+
+ return (
+ setActiveTab(id)}
+ tabs={[
+ { id: 'source', label: 'Source', icon: },
+ { id: 'layers', label: 'Layers', icon: },
+ { id: 'poster', label: 'Poster', icon: },
+ ]}
+ />
+ )
+ }
+ >
+ {/* ── Source Tab ──────────────────────────────────────────────────────── */}
+ {localMode === 'source' && (
+
+ {/* Media info card */}
+ {(fetchedData.title || config.tmdbId) && (
+
+
+
+
+ {fetchedData.title || (
+
+ Untitled
+
+ )}
+
+
+ {fetchedData.year && (
+
+ {fetchedData.year}
+
+ )}
+
+
+ {config.mediaType}
+
+ {config.imdbId && (
+
+ {config.imdbId}
+
+ )}
+
+
+
+
+
+
+
+ )}
+
+ {/* Search */}
+
+
+ Search Media
+
+
+
+
{
+ (e.currentTarget as HTMLElement).style.borderColor = 'rgba(196,124,46,0.4)';
+ }}
+ onMouseLeave={(e) => {
+ if (!e.currentTarget.contains(document.activeElement)) {
+ (e.currentTarget as HTMLElement).style.borderColor = 'rgba(255,255,255,0.1)';
+ }
+ }}
+ onFocusCapture={(e) => {
+ (e.currentTarget as HTMLElement).style.borderColor = 'rgba(196,124,46,0.4)';
+ }}
+ onBlurCapture={(e) => {
+ (e.currentTarget as HTMLElement).style.borderColor = 'rgba(255,255,255,0.1)';
+ }}
+ >
+
+ {isSearching ? (
+
+ ) : (
+
+ )}
+
+
setSearchQuery(e.target.value)}
+ displayValue={() => ''}
+ placeholder="Movie or TV show…"
+ />
+
+ {results.length > 0 && (
+
+ {results.map((item) => (
+
+ clsx(
+ 'flex items-center gap-3 px-3 py-2.5 cursor-pointer transition-colors',
+ active && 'bg-[rgba(196,124,46,0.08)]'
+ )
+ }
+ >
+
+
+
+ {item.title || item.name}
+
+
+
+ {(item.release_date || item.first_air_date)?.split('-')[0]}
+
+
+ {item.media_type}
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ {/* Media type + ID */}
+
+
+
+ Media Type
+
+
updateConfig('mediaType', v as PosterConfig['mediaType'])}
+ options={[
+ { id: 'movie', label: '🎬 Movie' },
+ { id: 'tv', label: '📺 TV Series' },
+ { id: 'anime', label: '🎌 Anime' },
+ ]}
+ />
+
+
+
+ IMDb ID
+
+
{
+ const val = e.target.value.trim();
+ if (val.startsWith('tt')) {
+ setConfig((prev) => ({ ...prev, imdbId: val }));
+ } else {
+ setConfig((prev) => ({ ...prev, tmdbId: val, imdbId: undefined }));
+ }
+ }}
+ className="w-full h-9 px-2 rounded-lg mono-font text-center focus:outline-none transition-colors"
+ style={{
+ background: 'rgba(255,255,255,0.04)',
+ border: '1px solid rgba(255,255,255,0.1)',
+ fontSize: 11,
+ color: 'var(--film-pale)',
+ }}
+ onMouseEnter={(e) => {
+ (e.currentTarget as HTMLElement).style.borderColor = 'rgba(196,124,46,0.4)';
+ }}
+ onMouseLeave={(e) => {
+ if (document.activeElement !== e.currentTarget) {
+ (e.currentTarget as HTMLElement).style.borderColor = 'rgba(255,255,255,0.1)';
+ }
+ }}
+ onFocus={(e) => {
+ (e.currentTarget as HTMLElement).style.borderColor = 'rgba(196,124,46,0.4)';
+ }}
+ onBlur={(e) => {
+ (e.currentTarget as HTMLElement).style.borderColor = 'rgba(255,255,255,0.1)';
+ }}
+ />
+
+
+
+ {/* Source + ptype */}
+
+
+
+ Poster Source
+
+
updateConfig('source', v as PosterConfig['source'])}
+ options={sourceOptions}
+ />
+
+ {['fanart', 'tmdb', 'imdb'].includes(config.source) && (
+
+
+ Poster Type
+
+
updateConfig('ptype', v)}
+ options={ptypeOptions}
+ />
+
+ )}
+
+
+ {/* Textless toggle */}
+
updateConfig('textless', v)}
+ disabled={['metahub', 'imdb'].includes(config.source)}
+ />
+
+
+
+ setConfig((prev) => ({
+ ...prev,
+ ratings: v
+ ? prev.ratings.includes('title')
+ ? prev.ratings
+ : [...prev.ratings, 'title']
+ : prev.ratings.filter((r) => r !== 'title'),
+ }))
+ }
+ />
+
+
+
+ Badges
+
+
+ Show/hide all layers with badge behavior
+
+
+
{
+ if (v) enableBadges();
+ else disableBadges({ persistPreference: true });
+ }}
+ className={clsx(
+ 'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#C47C2E]',
+ badgesVisible ? 'bg-[#C47C2E]' : 'bg-zinc-700/80'
+ )}
+ >
+
+
+
+
+
+ {/* Logo overlay */}
+
+
+
+
+
+
+ Logo Overlay
+
+
+ {config.logo
+ ? 'Enabled · Customizable in the Badges/Logo tab'
+ : 'Transparent title art overlay'}
+
+
+
+
{
+ updateConfig('logo', v);
+ if (v) {
+ handleLogoSelection(false);
+ setActiveTab('selection');
+ }
+ }}
+ className={clsx(
+ 'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#C47C2E]',
+ config.logo ? 'bg-[#C47C2E]' : 'bg-zinc-700/80'
+ )}
+ >
+
+
+
+
+
+ updateConfig(
+ 'logoSource',
+ v === 'auto' ? null : (v as PosterConfig['logoSource'])
+ )
+ }
+ options={logoSourceOptions}
+ />
+
+
+
+
+ {isAdvanced && (
+ }
+ defaultOpen={false}
+ >
+
+
+ )}
+
+ )}
+
+ {/* ── Poster/Canvas Tab ─────────────────────────────────────────────── */}
+ {localMode === 'poster' && (
+
+
+
+ Poster / Canvas
+
+
+ 500×750 canvas · central controls for poster effects, overlays, and export format.
+
+
+
+
} defaultOpen>
+
updateConfig('posterBlur', v)}
+ />
+ updateConfig('grayscale', v)}
+ />
+
+
+ {isAdvanced && (
+ }
+ defaultOpen
+ >
+
+ toggleViewOption('showSafeArea')}
+ className={clsx(
+ 'h-8 rounded-lg text-[11px] font-medium flex items-center justify-center gap-1.5 transition-all active:scale-95 syne-font',
+ viewOptions.showSafeArea
+ ? 'bg-[rgba(196,124,46,0.1)] text-[var(--film-pale)] border border-[rgba(196,124,46,0.22)]'
+ : 'bg-[rgba(255,255,255,0.02)] text-[var(--film-text-dim)] border border-[rgba(255,255,255,0.05)]'
+ )}
+ >
+
+ Safe Area
+
+ toggleViewOption('showGrid')}
+ className={clsx(
+ 'h-8 rounded-lg text-[11px] font-medium flex items-center justify-center gap-1.5 transition-all active:scale-95 syne-font',
+ viewOptions.showGrid
+ ? 'bg-[rgba(196,124,46,0.1)] text-[var(--film-pale)] border border-[rgba(196,124,46,0.22)]'
+ : 'bg-[rgba(255,255,255,0.02)] text-[var(--film-text-dim)] border border-[rgba(255,255,255,0.05)]'
+ )}
+ >
+
+ Grid
+
+ toggleViewOption('snapToGrid')}
+ className={clsx(
+ 'h-8 rounded-lg text-[11px] font-medium col-span-2 flex items-center justify-center gap-1.5 transition-all active:scale-95 syne-font',
+ viewOptions.snapToGrid
+ ? 'bg-[rgba(196,124,46,0.1)] text-[var(--film-pale)] border border-[rgba(196,124,46,0.22)]'
+ : 'bg-[rgba(255,255,255,0.02)] text-[var(--film-text-dim)] border border-[rgba(255,255,255,0.05)]'
+ )}
+ >
+
+ Snap to Grid
+
+
+
+ )}
+
+ )}
+
+ {/* ── Layers Tab ──────────────────────────────────────────────────────── */}
+ {localMode === 'layers' && (
+
+ <>
+
+
+ Badges
+
+
+
{
+ (e.currentTarget as HTMLElement).style.color = 'var(--film-text-label)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLElement).style.color = 'var(--film-text-dim)';
+ }}
+ >
+ {allVisible ? : }
+ {allVisible ? 'Hide all' : 'Show all'}
+
+
+
handleSelectAll(!allVisibleSelected)}
+ className="flex items-center gap-1.5 transition-colors body-font"
+ style={{ fontSize: 10, color: 'var(--film-text-dim)' }}
+ onMouseEnter={(e) => {
+ (e.currentTarget as HTMLElement).style.color = 'var(--film-text-label)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLElement).style.color = 'var(--film-text-dim)';
+ }}
+ >
+
+ {allVisibleSelected && }
+
+ Select all
+
+
+
+
+
+ {activeLayers.length > 0 ? (
+
+ {(provided) => (
+
+ {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}
+
+ )}
+
+ ) : (
+
+
+
+ No active badges
+
+
+ Enable some from the list below
+
+
+ )}
+
+ {inactiveBadges.length > 0 && (
+ <>
+
+
+ Available
+
+ {/* Fallback toggle */}
+
+
+ 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'
+ )}
+ >
+
+
+
+
+
+ {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)}
+
+ )}
+
+ >
+
+ )}
+
+ );
+};
+
+export default LayerPanel;
diff --git a/src/components/builder/legacy/components/PropertyPanel.tsx.bak b/src/components/builder/legacy/components/PropertyPanel.tsx.bak
new file mode 100644
index 00000000..bd113c0c
--- /dev/null
+++ b/src/components/builder/legacy/components/PropertyPanel.tsx.bak
@@ -0,0 +1,1566 @@
+// src/components/builder/components/PropertyPanel.tsx
+import React, { memo, useRef, useEffect } from 'react';
+import type { PosterConfig, RatingType, PresetType, BadgeConfig } from '../types';
+import { CANVAS_WIDTH, CANVAS_HEIGHT, DEFAULT_CONFIG } from '../types';
+import {
+ Layers,
+ Layout,
+ Palette,
+ Eye,
+ EyeOff,
+ RotateCcw,
+ Rows2,
+ Columns2,
+ Type,
+ Hash,
+ Sliders,
+ ImagePlay,
+} from 'lucide-react';
+import clsx from 'clsx';
+import SidebarLayout from './SidebarLayout';
+import { ColorRow, Section, SegmentedRow, SliderRow, TextInputRow, ToggleRow } from './ui';
+
+interface Props {
+ config: PosterConfig;
+ setConfig: React.Dispatch>;
+ selectedIds: Set;
+ selectedLogo?: boolean;
+ selectedMinimalElements?: Set;
+ viewMode?: 'global' | 'selection';
+ mode?: 'badges' | 'logo' | 'selection';
+ detailLevel?: 'simple' | 'advanced';
+}
+
+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 = {
+ imdb: 'IMDb',
+ rt: 'Rotten Tomatoes',
+ rt_popcorn: 'Audience Score',
+ tmdb: 'TMDB',
+ letterboxd: 'Letterboxd',
+ meta: 'Metacritic',
+ mal: 'MyAnimeList',
+ anilist: 'AniList',
+ age: 'Age Rating',
+ runtime: 'Runtime',
+ year: 'Year',
+ title: 'Title',
+};
+
+// ── Alignment grid ─────────────────────────────────────────────────────────────
+const GRID_POSITIONS: { id: PresetType; label: string }[] = [
+ { id: 'tl', label: 'Top left' },
+ { id: 'tc', label: 'Top centre' },
+ { id: 'tr', label: 'Top right' },
+ { id: 'lc', label: 'Middle left' },
+ { id: 'cc', label: 'Centre' },
+ { id: 'rc', label: 'Middle right' },
+ { id: 'bl', label: 'Bottom left' },
+ { id: 'bc', label: 'Bottom centre' },
+ { id: 'br', label: 'Bottom right' },
+];
+
+const AlignmentGrid: React.FC<{ value: PresetType; onChange: (v: PresetType) => void }> = ({
+ value,
+ onChange,
+}) => (
+
+ {GRID_POSITIONS.map((pos) => (
+
onChange(pos.id)}
+ title={pos.label}
+ className={clsx(
+ 'w-full aspect-square rounded transition-all active:scale-90',
+ value === pos.id
+ ? 'shadow-[0_0_8px_rgba(196,124,46,0.4)] border border-[rgba(196,124,46,0.3)]'
+ : 'border border-white/[0.06] hover:bg-white/[0.08] hover:border-[rgba(196,124,46,0.24)]'
+ )}
+ style={{
+ background: value === pos.id ? '#C47C2E' : 'rgba(255,255,255,0.03)',
+ }}
+ >
+
+
+ ))}
+
+);
+
+function resolveShadow(v: number | boolean | undefined, fallback: number): number {
+ if (v === undefined) return fallback;
+ if (typeof v === 'boolean') return v ? 6 : 0;
+ return v;
+}
+
+const makeDefaultMinimalRating = (provider: RatingType = 'imdb', x = 342, y = 688) => ({
+ provider,
+ enabled: true,
+ x,
+ y,
+ size: 26,
+ color: '#facc15',
+ opacity: 1,
+ iconMode: 'star' as const,
+ symbol: '★',
+ bgEnabled: false,
+ bgColor: '#000000',
+ bgOpacity: 0,
+ borderW: 0,
+ borderColor: '#ffffff',
+ borderOpacity: 0.7,
+ radius: 0,
+ paddingX: 0,
+ paddingY: 0,
+ shadowEnabled: false,
+ shadowX: 0,
+ shadowY: 0,
+ shadowBlur: 0,
+ shadowColor: '#000000',
+});
+
+const placeMinimalRatings = (
+ ratings: NonNullable,
+ preset: PosterConfig['preset'],
+ layout: PosterConfig['layout']
+) => {
+ const activePreset = preset === 'custom' ? 'bc' : preset;
+ const activeLayout = layout === 'custom' ? 'row' : layout;
+ const gap = 14;
+ const chipW = 120;
+ const chipH = 34;
+ const total = ratings.length;
+ if (total === 0) return ratings;
+ const groupW = activeLayout === 'row' ? total * chipW + (total - 1) * gap : chipW;
+ const groupH = activeLayout === 'col' ? total * chipH + (total - 1) * gap : chipH;
+ let startX = 0;
+ let startY = 0;
+ if (activePreset.includes('l')) startX = 20;
+ else if (activePreset.includes('r')) startX = CANVAS_WIDTH - groupW - 20;
+ else startX = Math.round((CANVAS_WIDTH - groupW) / 2);
+ if (activePreset.includes('t')) startY = 20;
+ else if (activePreset.includes('b')) startY = CANVAS_HEIGHT - groupH - 26;
+ else startY = Math.round((CANVAS_HEIGHT - groupH) / 2);
+ return ratings.map((item, index) => ({
+ ...item,
+ x: Math.round(startX + (activeLayout === 'row' ? index * (chipW + gap) : 0)),
+ y: Math.round(startY + (activeLayout === 'col' ? index * (chipH + gap) : 0)),
+ }));
+};
+
+// ── Main component ─────────────────────────────────────────────────────────────
+const PropertyPanel: React.FC = ({
+ config,
+ setConfig,
+ selectedIds,
+ selectedLogo = false,
+ selectedMinimalElements = new Set(),
+ viewMode,
+ mode,
+ detailLevel = 'simple',
+}) => {
+ const isAdvanced = detailLevel === 'advanced';
+ const badgesEnabled = config.ratings.length > 0;
+ const logoSettingsRef = useRef(null);
+
+ const updateConfig = (key: K, value: PosterConfig[K]) => {
+ setConfig((prev) => {
+ if (key === 'layout' || key === 'preset') {
+ if ((prev.uiPreset ?? 'b') === 'm') {
+ const nextPreset = (key === 'preset' ? value : prev.preset) as PosterConfig['preset'];
+ const nextLayout = (key === 'layout' ? value : prev.layout) as PosterConfig['layout'];
+ const placed = placeMinimalRatings(
+ [...(prev.minimalRatings ?? [makeDefaultMinimalRating()])].slice(0, 3),
+ nextPreset,
+ nextLayout
+ );
+ return { ...prev, [key]: value, minimalRatings: placed };
+ }
+ const newItems = { ...prev.items };
+ (Object.keys(newItems) as RatingType[]).forEach((k) => {
+ if (newItems[k]) {
+ const { x: _x, y: _y, ...rest } = newItems[k]!;
+ newItems[k] = rest;
+ }
+ });
+ return { ...prev, [key]: value, items: newItems };
+ }
+ return { ...prev, [key]: value };
+ });
+ };
+
+ const clearGlobalColor = (key: 'bg' | 'txt') => {
+ setConfig((prev) => {
+ const n = { ...prev };
+ delete n[key];
+ return n;
+ });
+ };
+
+ const updateSelectedBadges = (updates: Partial) =>
+ setConfig((prev) => {
+ const newItems = { ...prev.items };
+ selectedIds.forEach((id) => {
+ newItems[id] = { ...newItems[id], ...updates };
+ });
+ return { ...prev, items: newItems };
+ });
+
+ const clearSelectedBadgeProp = (prop: keyof BadgeConfig) =>
+ setConfig((prev) => {
+ const newItems = { ...prev.items };
+ selectedIds.forEach((id) => {
+ if (newItems[id]) {
+ const copy = { ...newItems[id] };
+ delete copy[prop];
+ newItems[id] = copy;
+ }
+ });
+ return { ...prev, items: newItems };
+ });
+
+ const updateTitleDefaults = (updates: Partial) =>
+ setConfig((prev) => ({
+ ...prev,
+ items: {
+ ...prev.items,
+ title: {
+ ...(prev.items.title ?? {}),
+ ...updates,
+ },
+ },
+ }));
+
+ const clearTitleDefaultProp = (prop: keyof BadgeConfig) =>
+ setConfig((prev) => {
+ const nextTitle = { ...(prev.items.title ?? {}) };
+ delete nextTitle[prop];
+ return {
+ ...prev,
+ items: {
+ ...prev.items,
+ title: nextTitle,
+ },
+ };
+ });
+
+ // Returns the shared value across all selected badges, or null if mixed.
+ const getCommonValue = (
+ prop: K,
+ def: BadgeConfig[K]
+ ): BadgeConfig[K] | null => {
+ const vals = Array.from(selectedIds).map(
+ (id) =>
+ config.items[id]?.[prop] ?? (config[prop as keyof PosterConfig] as BadgeConfig[K]) ?? def
+ );
+ return vals.length > 0 && vals.every((v) => v === vals[0]) ? vals[0] : null;
+ };
+
+ const panelMode = mode ?? (viewMode === 'selection' ? 'selection' : 'badges');
+ const showGlobal = panelMode !== 'selection';
+ const showBadgeSettings = panelMode === 'badges';
+ const showLogoSettings = panelMode === 'badges';
+ const showTitleDefaults = showBadgeSettings && config.ratings.includes('title');
+ const logoSectionTitle = 'Logo Overlay';
+ const LOGO_BASE_W = 320;
+ const LOGO_BASE_H = 84;
+ const LOGO_ASPECT = LOGO_BASE_W / LOGO_BASE_H;
+ useEffect(() => {
+ const handler = () => {
+ requestAnimationFrame(() => {
+ logoSettingsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ });
+ };
+ window.addEventListener('builder-scroll-logo-settings', handler);
+ return () => window.removeEventListener('builder-scroll-logo-settings', handler);
+ }, []);
+
+ // ── Global view ───────────────────────────────────────────────────────────────
+ if (showGlobal)
+ return (
+
+ {showBadgeSettings && (
+ } sectionId="global-layout">
+
+
+
+ Position preset
+
+
updateConfig('preset', v)} />
+
+
+
+ Flow direction
+
+
+ {[
+ { id: 'col' as const, label: 'Column', icon: },
+ { id: 'row' as const, label: 'Row', icon: },
+ ].map((opt) => (
+ updateConfig('layout', opt.id)}
+ className={clsx(
+ 'w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-[11px] font-medium transition-colors border syne-font',
+ config.layout === opt.id
+ ? 'bg-[rgba(196,124,46,0.1)] text-[var(--film-pale)] border-[rgba(196,124,46,0.22)]'
+ : INACTIVE_OPTION_HOVER_CLASSES
+ )}
+ >
+
+ {opt.icon}
+
+ {opt.label}
+
+ ))}
+
+
+
+
+ )}
+
+ {showBadgeSettings && badgesEnabled && (
+ <>
+ }
+ sectionId="global-badge-appearance"
+ >
+ updateConfig('icon', v)}
+ />
+ updateConfig('showText', v)}
+ />
+ updateConfig('iconType', Math.max(1, Math.min(3, Number(v) || 1)))}
+ />
+
+
+ }
+ sectionId="global-badge-shape"
+ >
+ `${v.toFixed(2)}×`}
+ onChange={(v) => updateConfig('scale', v)}
+ onReset={config.scale !== 1 ? () => updateConfig('scale', 1.0) : undefined}
+ />
+ updateConfig('blur', v)}
+ onReset={config.blur !== 0 ? () => updateConfig('blur', 0) : undefined}
+ />
+ updateConfig('radius', v)}
+ />
+ updateConfig('shadow', v)}
+ onReset={
+ resolveShadow(config.shadow as number | boolean, 6) !== 6
+ ? () => updateConfig('shadow', 6)
+ : undefined
+ }
+ />
+ updateConfig('shadowX', Math.round(v))}
+ />
+ updateConfig('shadowY', Math.round(v))}
+ />
+ updateConfig('shadowColor', v)}
+ />
+ `${Math.round(v * 100)}%`}
+ onChange={(v) => updateConfig('shadowOpacity', Number(v.toFixed(2)))}
+ />
+
+
+
+ updateConfig('bg', v)}
+ onReset={config.bg ? () => clearGlobalColor('bg') : undefined}
+ showOpacity
+ opacity={config.alpha}
+ onOpacityChange={(v) => updateConfig('alpha', v)}
+ />
+ updateConfig('txt', v)}
+ onReset={config.txt ? () => clearGlobalColor('txt') : undefined}
+ />
+ updateConfig('borderW', v)}
+ />
+ {(config.borderW ?? 0) > 0 && (
+ updateConfig('borderC', v)}
+ />
+ )}
+
+
+ {isAdvanced && (
+ }
+ defaultOpen={false}
+ sectionId="global-score"
+ >
+ updateConfig('normalize', v)}
+ />
+ 0}
+ onChange={(v) => updateConfig('outOf', v ? 10 : undefined)}
+ />
+
+ )}
+
+ {isAdvanced && (
+ }
+ defaultOpen={false}
+ sectionId="global-labels"
+ >
+ {/* Show/Hide Labels toggle */}
+ updateConfig('labelPos', config.labelPos ? undefined : 'below')}
+ className="w-full h-8 rounded-lg text-[11px] font-medium flex items-center justify-center gap-1.5 transition-all active:scale-95 syne-font mb-1"
+ style={{
+ background: config.labelPos ? 'rgba(196,124,46,0.1)' : 'rgba(255,255,255,0.02)',
+ color: config.labelPos ? 'var(--film-pale)' : 'var(--film-text-dim)',
+ border: config.labelPos
+ ? '1px solid rgba(196,124,46,0.22)'
+ : '1px solid rgba(255,255,255,0.05)',
+ }}
+ onMouseEnter={(e) => {
+ (e.currentTarget as HTMLElement).style.borderColor = 'rgba(196,124,46,0.35)';
+ (e.currentTarget as HTMLElement).style.background = config.labelPos
+ ? 'rgba(196,124,46,0.15)'
+ : 'rgba(255,255,255,0.05)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLElement).style.borderColor = config.labelPos
+ ? 'rgba(196,124,46,0.22)'
+ : 'rgba(255,255,255,0.05)';
+ (e.currentTarget as HTMLElement).style.background = config.labelPos
+ ? 'rgba(196,124,46,0.1)'
+ : 'rgba(255,255,255,0.02)';
+ }}
+ >
+ {config.labelPos ? (
+
+ ) : (
+
+ )}
+ {config.labelPos ? 'Labels Visible' : 'Labels Hidden'}
+
+
+ updateConfig(
+ 'labelPos',
+ (v === 'none'
+ ? undefined
+ : (v as PosterConfig['labelPos'])) as PosterConfig['labelPos']
+ )
+ }
+ />
+ updateConfig('labelText', v || undefined)}
+ onClear={() => updateConfig('labelText', undefined)}
+ />
+ updateConfig('labelSize', v)}
+ />
+ updateConfig('labelColor', v)}
+ onReset={
+ config.labelColor ? () => updateConfig('labelColor', undefined) : undefined
+ }
+ />
+
+ )}
+ >
+ )}
+
+ {showTitleDefaults && (
+ } sectionId="global-title-layer">
+ updateTitleDefaults({ textCharWidth: Math.round(v) })}
+ onReset={() =>
+ updateTitleDefaults({
+ textCharWidth: DEFAULT_CONFIG.items.title?.textCharWidth ?? 24,
+ })
+ }
+ />
+ updateTitleDefaults({ textCharHeight: Math.round(v) })}
+ onReset={() =>
+ updateTitleDefaults({
+ textCharHeight: DEFAULT_CONFIG.items.title?.textCharHeight ?? 1,
+ })
+ }
+ />
+ updateTitleDefaults({ textAlign: v as BadgeConfig['textAlign'] })}
+ />
+ (v <= 0 ? 'Full' : `${Math.round(v)} ch`)}
+ onChange={(v) => updateTitleDefaults({ textMaxChars: Math.round(v) })}
+ onReset={() => clearTitleDefaultProp('textMaxChars')}
+ />
+ updateTitleDefaults({ textWrapEnabled: v })}
+ />
+
+ )}
+
+ {showLogoSettings && config.logo && (
+
+
}
+ 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)}
+ />
+ )}
+ {config.logoBgEnabled && (
+ <>
+ updateConfig('logoBgColor', v)}
+ showOpacity
+ opacity={config.logoBgOpacity}
+ onOpacityChange={(v) => updateConfig('logoBgOpacity', v)}
+ />
+ updateConfig('logoBgPadding', v)}
+ />
+ updateConfig('logoBgShadow', v)}
+ />
+
+ setConfig((prev) => ({
+ ...prev,
+ logoBgEnabled: true,
+ logoBgColor: prev.bg ?? '#000000',
+ logoBgOpacity: prev.alpha,
+ logoBgRadius: prev.radius,
+ logoBgPadding: 10,
+ logoBgBorderW: prev.borderW ?? 0,
+ logoBgBorderC: prev.borderC ?? '#ffffff',
+ logoBgShadow: resolveShadow(prev.shadow, 6),
+ }))
+ }
+ className="w-full h-8 rounded-lg text-[11px] font-medium transition-all active:scale-[0.98] flex items-center justify-center gap-2 cursor-pointer syne-font"
+ style={{
+ border: '1px solid rgba(196,124,46,0.16)',
+ background: 'rgba(196,124,46,0.08)',
+ color: 'var(--film-pale)',
+ letterSpacing: '0.04em',
+ }}
+ >
+ Apply Badge Style to Logo Background
+
+ >
+ )}
+ updateConfig('logoBgRadius', v)}
+ />
+
+ Drag the logo on the canvas to reposition it.
+
+
+
+ )}
+
+ );
+
+ // ── No-selection placeholder ──────────────────────────────────────────────
+ if (
+ panelMode === 'selection' &&
+ selectedIds.size === 0 &&
+ !selectedLogo &&
+ selectedMinimalElements.size === 0
+ )
+ return (
+
+
+
+
+
+
+
+ Nothing selected
+
+
+ Click a layer on the canvas to edit
+
+
+
+
+ );
+
+ // ── Per-badge / selection view ─────────────────────────────────────────────
+
+ const selectionCount = selectedIds.size + (selectedLogo ? 1 : 0) + selectedMinimalElements.size;
+ const isAgeSelected = selectedIds.has('age');
+ const isOnlyTitleSelected = selectedIds.size === 1 && selectedIds.has('title');
+ const isOnlyYearSelected = selectedIds.size === 1 && selectedIds.has('year');
+ const isTitleOrYearOnlySelection = isOnlyTitleSelected || isOnlyYearSelected;
+ const multi = selectionCount > 1;
+ const selectedBadgeLabel = (() => {
+ 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];
+ if (only === 'minimal-title') return 'Title';
+ 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('-').pop() ?? 0) + 1}`;
+ }
+ const first = Array.from(selectedIds)[0];
+ if (!first) return 'Selection';
+ return BADGE_DISPLAY_NAMES[first] ?? first.toUpperCase();
+ })();
+
+ // Resolve common values across the selection (null = mixed)
+ const commonBlur = getCommonValue('blur', config.blur) ?? config.blur;
+ const commonAlpha = getCommonValue('alpha', config.alpha) ?? config.alpha;
+ const commonRadius = getCommonValue('radius', config.radius) ?? config.radius;
+ const commonShadow = resolveShadow(
+ (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 commonShadowOpacity = (getCommonValue('shadowOpacity', config.shadowOpacity ?? 0.35) ??
+ config.shadowOpacity ??
+ 0.35) as number;
+ const commonShadowColor = (() => {
+ const v = getCommonValue('shadowColor', config.shadowColor ?? '#000000');
+ return (v === null ? (config.shadowColor ?? '#000000') : v) as string;
+ })();
+ const commonScale = (getCommonValue('scale', config.scale ?? 1.0) ??
+ config.scale ??
+ 1.0) as number;
+ const commonBorderW = (getCommonValue('borderW', config.borderW ?? 0) ??
+ config.borderW ??
+ 0) as number;
+ const commonShowText = (getCommonValue('showText', config.showText ?? true) ??
+ config.showText ??
+ true) as boolean;
+ const commonNorm = (getCommonValue('normalize', config.normalize ?? false) ??
+ config.normalize ??
+ false) as boolean;
+ const commonOutOf = (getCommonValue('outOf', config.outOf ?? 0) ?? config.outOf ?? 0) as number;
+ const commonIconType = (getCommonValue('iconType', config.iconType ?? 1) ??
+ config.iconType ??
+ 1) as number;
+ const commonLabelPos = getCommonValue('labelPos', config.labelPos ?? 'below') as string | null;
+ const commonLabelTxt = getCommonValue('labelText', config.labelText ?? '') as string | null;
+ const commonLabelSz = (getCommonValue('labelSize', config.labelSize ?? 11) ??
+ config.labelSize ??
+ 11) as number;
+
+ const commonBg = (() => {
+ const v = getCommonValue('bg', config.bg ?? '#000000');
+ return (v === null ? (config.bg ?? '#000000') : v) as string;
+ })();
+ const commonTxt = (() => {
+ const v = getCommonValue('txt', config.txt ?? '#ffffff');
+ return (v === null ? (config.txt ?? '#ffffff') : v) as string;
+ })();
+ const commonLabelColor = (() => {
+ const v = getCommonValue('labelColor', config.labelColor ?? '#ffffff');
+ return (v === null ? (config.labelColor ?? '#ffffff') : v) as string;
+ })();
+ const commonBorderC = (() => {
+ const v = getCommonValue('borderC', config.borderC ?? '#ffffff');
+ return (v === null ? (config.borderC ?? '#ffffff') : v) as string;
+ })();
+ const minimalTitleSelected = selectedMinimalElements.has('minimal-title');
+ const minimalYearSelected = selectedMinimalElements.has('minimal-year');
+ const minimalDurationSelected = selectedMinimalElements.has('minimal-duration');
+ const commonTextSize = (getCommonValue('textSize', isOnlyYearSelected ? 42 : 36) ??
+ (isOnlyYearSelected ? 42 : 36)) as number;
+ const commonTextWeight = (getCommonValue('textWeight', 700) ?? 700) as number;
+ const commonTextLetterSpacing = (getCommonValue('textLetterSpacing', 0) ?? 0) as number;
+ const commonTextLineHeight = (getCommonValue('textLineHeight', 1.1) ?? 1.1) as number;
+ const commonTextAlign = (getCommonValue('textAlign', 'left') ?? 'left') as
+ | 'left'
+ | 'center'
+ | 'right';
+ const commonTextCharWidth = (getCommonValue(
+ 'textCharWidth',
+ DEFAULT_CONFIG.items.title?.textCharWidth ?? 24
+ ) ??
+ DEFAULT_CONFIG.items.title?.textCharWidth ??
+ 24) as number;
+ const commonTextCharHeight = (getCommonValue(
+ 'textCharHeight',
+ DEFAULT_CONFIG.items.title?.textCharHeight ?? 1
+ ) ??
+ DEFAULT_CONFIG.items.title?.textCharHeight ??
+ 1) as number;
+ const commonTextWrapEnabled = (getCommonValue('textWrapEnabled', true) ?? true) as boolean;
+ const commonTextMaxChars = (getCommonValue('textMaxChars', 0) ?? 0) as number;
+ const commonTextShadowEnabled = (getCommonValue('textShadowEnabled', false) ?? false) as boolean;
+ 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;
+
+ return (
+
+ {/* Selection header */}
+
+
+ {multi ? `${selectionCount} items selected` : selectedBadgeLabel}
+
+
+ {multi ? 'Changes apply to all selected layers' : 'Layer-specific configuration'}
+
+
+
+ {selectedMinimalElements.size > 0 && (
+ <>
+ {(minimalTitleSelected || minimalYearSelected || minimalDurationSelected) && (
+
+ {minimalTitleSelected && (
+ <>
+ updateConfig('minimalTextSize', Math.round(v))}
+ />
+ updateConfig('minimalTitleColor', v)}
+ showOpacity
+ opacity={config.minimalTitleOpacity ?? 1}
+ onOpacityChange={(v) =>
+ updateConfig('minimalTitleOpacity', Number(v.toFixed(2)))
+ }
+ />
+ >
+ )}
+ {(minimalYearSelected || minimalDurationSelected) && (
+ <>
+ updateConfig('minimalMetaSize', Math.round(v))}
+ />
+ updateConfig('minimalMetaColor', v)}
+ showOpacity
+ opacity={config.minimalMetaOpacity ?? 0.92}
+ onOpacityChange={(v) =>
+ updateConfig('minimalMetaOpacity', Number(v.toFixed(2)))
+ }
+ />
+ >
+ )}
+
+ )}
+ >
+ )}
+
+ {selectedIds.size > 0 && (
+ <>
+ {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({ 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 */}
+ {isAdvanced && !isTitleOrYearOnlySelection && (
+ }
+ defaultOpen={false}
+ sectionId="badge-score"
+ >
+ updateSelectedBadges({ normalize: v })}
+ />
+ 0}
+ onChange={(v) => updateSelectedBadges({ outOf: v ? 10 : undefined })}
+ />
+
+ )}
+
+ {/* Labels ── position, custom text, size, color */}
+ {isAdvanced && !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"
+ >
+ {
+ 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', Math.round(v))}
+ />
+ {config.logoBgBorderW > 0 && (
+ updateConfig('logoBgBorderC', v)}
+ />
+ )}
+ {config.logoBgEnabled && (
+ <>
+ updateConfig('logoBgColor', v)}
+ onOpacityChange={(v) => updateConfig('logoBgOpacity', Number(v.toFixed(2)))}
+ />
+ updateConfig('logoBgPadding', v)}
+ />
+ updateConfig('logoBgShadow', v)}
+ />
+
+ setConfig((prev) => ({
+ ...prev,
+ logoBgEnabled: true,
+ logoBgColor: prev.bg ?? '#000000',
+ logoBgOpacity: prev.alpha,
+ logoBgRadius: prev.radius,
+ logoBgPadding: 10,
+ logoBgBorderW: prev.borderW ?? 0,
+ logoBgBorderC: prev.borderC ?? '#ffffff',
+ logoBgShadow: resolveShadow(prev.shadow, 6),
+ }))
+ }
+ className="w-full h-8 rounded-lg text-[11px] font-medium transition-all active:scale-[0.98] flex items-center justify-center gap-2 cursor-pointer syne-font"
+ style={{
+ border: '1px solid rgba(196,124,46,0.16)',
+ background: 'rgba(196,124,46,0.08)',
+ color: 'var(--film-pale)',
+ letterSpacing: '0.04em',
+ }}
+ >
+ Apply Badge Style to Logo Background
+
+ >
+ )}
+ updateConfig('logoBgRadius', v)}
+ />
+
+ )}
+
+ {/* Reset */}
+ {(selectedIds.size > 0 || selectedLogo) && (
+
+
+ setConfig((prev) => {
+ const ni = { ...prev.items };
+ selectedIds.forEach((id) => delete ni[id]);
+ if (!selectedLogo) return { ...prev, items: ni };
+ return {
+ ...prev,
+ items: ni,
+ logoX: DEFAULT_CONFIG.logoX,
+ logoY: DEFAULT_CONFIG.logoY,
+ logoW: DEFAULT_CONFIG.logoW,
+ logoH: DEFAULT_CONFIG.logoH,
+ logoOpacity: DEFAULT_CONFIG.logoOpacity,
+ logoZ: DEFAULT_CONFIG.logoZ,
+ logoShadow: DEFAULT_CONFIG.logoShadow,
+ logoBgEnabled: DEFAULT_CONFIG.logoBgEnabled,
+ logoBgColor: DEFAULT_CONFIG.logoBgColor,
+ logoBgOpacity: DEFAULT_CONFIG.logoBgOpacity,
+ logoBgRadius: DEFAULT_CONFIG.logoBgRadius,
+ logoBgPadding: DEFAULT_CONFIG.logoBgPadding,
+ logoBgBorderW: DEFAULT_CONFIG.logoBgBorderW,
+ logoBgBorderC: DEFAULT_CONFIG.logoBgBorderC,
+ logoBgShadow: DEFAULT_CONFIG.logoBgShadow,
+ };
+ })
+ }
+ className="w-full h-8 rounded-lg text-[11px] font-medium transition-all active:scale-[0.98] flex items-center justify-center gap-2 cursor-pointer syne-font"
+ style={{
+ border: '1px solid rgba(248,113,113,0.12)',
+ background: 'rgba(248,113,113,0.04)',
+ color: 'rgba(248,113,113,0.6)',
+ letterSpacing: '0.04em',
+ }}
+ onMouseEnter={(e) => {
+ (e.currentTarget as HTMLElement).style.background = 'rgba(248,113,113,0.08)';
+ (e.currentTarget as HTMLElement).style.color = 'rgba(248,113,113,0.85)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLElement).style.background = 'rgba(248,113,113,0.04)';
+ (e.currentTarget as HTMLElement).style.color = 'rgba(248,113,113,0.6)';
+ }}
+ >
+ Reset selected to defaults
+
+
+ )}
+
+ );
+};
+
+export default memo(PropertyPanel);
diff --git a/src/components/builder/legacy/components/layout/Inspector.tsx.bak b/src/components/builder/legacy/components/layout/Inspector.tsx.bak
new file mode 100644
index 00000000..b64e91c7
--- /dev/null
+++ b/src/components/builder/legacy/components/layout/Inspector.tsx.bak
@@ -0,0 +1,98 @@
+import React, { memo } from 'react';
+import { useEditor } from '../../context/EditorContext';
+import { BadgesPanel, SelectionPanel } from '../panels';
+import type { PosterConfig } from '../../types';
+import { Badge, MousePointer2 } from 'lucide-react';
+import SidebarLayout from '../SidebarLayout';
+import PanelTabs from '../navigation/PanelTabs';
+
+interface Props {
+ config: PosterConfig;
+ setConfig: React.Dispatch>;
+ detailLevel?: 'simple' | 'advanced';
+}
+
+type InspectorTab = 'badges' | 'selection';
+const isInspectorTab = (value: string): value is InspectorTab =>
+ value === 'badges' || value === 'selection';
+
+const Inspector: React.FC = memo(({ config, setConfig, detailLevel = 'simple' }) => {
+ 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;
+ const hasLogo = config.logo;
+ const primaryTabLabel = isMinimalPreset
+ ? hasLogo
+ ? 'Title / Logo'
+ : 'Title'
+ : hasBadges && hasLogo
+ ? 'Badges / Logo'
+ : hasLogo
+ ? 'Logo'
+ : 'Badges';
+
+ const tabs: { id: InspectorTab; label: string; Icon: React.ElementType; visible: boolean }[] = [
+ {
+ id: 'badges',
+ label: primaryTabLabel,
+ Icon: Badge,
+ visible: hasBadges || hasLogo || isMinimalPreset,
+ },
+ {
+ id: 'selection',
+ label: selectedCount > 0 ? `${selectedCount} selected` : 'Selection',
+ Icon: MousePointer2,
+ visible: true,
+ },
+ ];
+
+ const visibleTabs = tabs.filter((tab) => tab.visible);
+ const activeInspectorTab = isInspectorTab(activeTab) ? activeTab : undefined;
+ const currentTab = visibleTabs.some((tab) => tab.id === activeInspectorTab)
+ ? activeInspectorTab
+ : visibleTabs[0]?.id;
+
+ if (!currentTab) return null;
+
+ return (
+ setActiveTab(id)}
+ tabs={visibleTabs.map(({ id, label, Icon }) => ({
+ id,
+ label,
+ icon: ,
+ }))}
+ />
+ }
+ >
+ {currentTab === 'badges' ? (
+
+ ) : (
+
+ )}
+
+ );
+});
+
+Inspector.displayName = 'Inspector';
+export default Inspector;
From 00e28d57065184bf78a9a566ae0314d1268b0d35 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 25 May 2026 16:25:41 +0000
Subject: [PATCH 3/7] fix: use shared export menu in walkthrough
Agent-Logs-Url: https://github.com/SpicyDevs/posterium-frontend/sessions/980be9bd-aaee-4015-bd83-a440361f5e95
Co-authored-by: ikrdikhit <155148519+ikrdikhit@users.noreply.github.com>
---
src/components/builder/components/simple/WalkthroughMode.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/components/builder/components/simple/WalkthroughMode.tsx b/src/components/builder/components/simple/WalkthroughMode.tsx
index b7747545..73fbfdad 100644
--- a/src/components/builder/components/simple/WalkthroughMode.tsx
+++ b/src/components/builder/components/simple/WalkthroughMode.tsx
@@ -3,8 +3,8 @@ import { ArrowLeft, ArrowRight, Sparkles, Download } from 'lucide-react';
import type { ExtensionType, PosterConfig, PresetType, RatingType } from '../../types';
import { ALL_BADGES } from '../../types';
import PreviewCanvas from '../PreviewCanvas';
-import ExportPopover from '../ExportPopover';
import { Button, Card } from '@/components/ui';
+import ExportMenu from '@/components/shared/ExportMenu';
import MediaPicker from './MediaPicker';
import type { BuilderMode } from '../navigation/ModeToggle';
@@ -207,7 +207,7 @@ const WalkthroughMode: React.FC = ({
Export Options
{exportOpen && (
-
Date: Mon, 25 May 2026 16:31:11 +0000
Subject: [PATCH 4/7] chore: polish walkthrough and simple mode UX details
Agent-Logs-Url: https://github.com/SpicyDevs/posterium-frontend/sessions/980be9bd-aaee-4015-bd83-a440361f5e95
Co-authored-by: ikrdikhit <155148519+ikrdikhit@users.noreply.github.com>
---
package-lock.json | 20 +++++++++++++++-
.../builder/components/simple/MediaPicker.tsx | 11 +++++++++
.../components/simple/SimpleModePanel.tsx | 23 ++++++-------------
.../components/simple/WalkthroughMode.tsx | 12 +++++-----
.../builder/components/simple/constants.ts | 13 +++++++++++
src/components/builder/index.tsx | 6 ++---
6 files changed, 59 insertions(+), 26 deletions(-)
create mode 100644 src/components/builder/components/simple/constants.ts
diff --git a/package-lock.json b/package-lock.json
index 7c98a976..9f62847a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -207,6 +207,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -4296,6 +4297,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -4306,6 +4308,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -4528,6 +4531,7 @@
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -4737,6 +4741,7 @@
"integrity": "sha512-m4VWilWZ+Xt6NPoYzC4CgGZim/zQUO7WFL0RHCH0AiEavF1153iC3+me2atDvXpf/yX4PyGUeD8wZLq1cirT3g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@astrojs/compiler": "^2.13.0",
"@astrojs/internal-helpers": "0.7.6",
@@ -4860,6 +4865,7 @@
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -5129,6 +5135,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -9775,6 +9782,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -9923,6 +9931,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -9932,6 +9941,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -10054,7 +10064,8 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -10470,6 +10481,7 @@
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -11612,6 +11624,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -11683,6 +11696,7 @@
"integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"pathe": "^2.0.3"
}
@@ -12162,6 +12176,7 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -13215,6 +13230,7 @@
"integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},
@@ -13383,6 +13399,7 @@
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"workerd": "bin/workerd"
},
@@ -14053,6 +14070,7 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/src/components/builder/components/simple/MediaPicker.tsx b/src/components/builder/components/simple/MediaPicker.tsx
index e5fa4d63..3fd034a5 100644
--- a/src/components/builder/components/simple/MediaPicker.tsx
+++ b/src/components/builder/components/simple/MediaPicker.tsx
@@ -24,16 +24,19 @@ const MediaPicker: React.FC = ({ config, setConfig, title = 'Media select
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [selectedTitle, setSelectedTitle] = useState('');
+ const [error, setError] = useState('');
useEffect(() => {
const ctrl = new AbortController();
const t = setTimeout(async () => {
if (query.trim().length < 2) {
setResults([]);
+ setError('');
return;
}
setLoading(true);
+ setError('');
try {
const response = await fetch(`${DEFAULT_API_BASE}/search?q=${encodeURIComponent(query)}`, {
signal: ctrl.signal,
@@ -48,6 +51,7 @@ const MediaPicker: React.FC = ({ config, setConfig, title = 'Media select
setResults(next);
} catch (error: unknown) {
if (error instanceof Error && error.name === 'AbortError') return;
+ setError('Search failed. Check your connection and try again.');
} finally {
setLoading(false);
}
@@ -86,6 +90,7 @@ const MediaPicker: React.FC = ({ config, setConfig, title = 'Media select
{loading ? : }
setQuery(e.target.value)}
placeholder="Search a movie or show"
@@ -127,6 +132,12 @@ const MediaPicker: React.FC = ({ config, setConfig, title = 'Media select
Selected: {selectedTitle || `TMDB #${config.tmdbId}`}
)}
+
+ {error && (
+
+ {error}
+
+ )}
);
};
diff --git a/src/components/builder/components/simple/SimpleModePanel.tsx b/src/components/builder/components/simple/SimpleModePanel.tsx
index 23df6c63..e4fac5e1 100644
--- a/src/components/builder/components/simple/SimpleModePanel.tsx
+++ b/src/components/builder/components/simple/SimpleModePanel.tsx
@@ -4,25 +4,15 @@ import { ALL_BADGES } from '../../types';
import SidebarLayout from '../SidebarLayout';
import { Section, SelectBox, ToggleRow } from '../ui';
import MediaPicker from './MediaPicker';
+import { SIMPLE_BADGE_OPTIONS } from './constants';
interface Props {
config: PosterConfig;
setConfig: React.Dispatch>;
}
-const SIMPLE_BADGE_IDS: RatingType[] = [
- 'imdb',
- 'rt',
- 'rt_popcorn',
- 'meta',
- 'tmdb',
- 'age',
- 'runtime',
- 'year',
- 'title',
-];
-
const PRESET_OPTIONS: { id: PresetType; label: string }[] = [
+ { id: 'custom', label: 'Custom (Keep current)' },
{ id: 'bl', label: 'Bottom Left' },
{ id: 'br', label: 'Bottom Right' },
{ id: 'tl', label: 'Top Left' },
@@ -67,17 +57,18 @@ const SimpleModePanel: React.FC = ({ config, setConfig }) => {
{
- update('preset', value as PresetType);
- update('layout', 'row');
+ const nextPreset = value as PresetType;
+ update('preset', nextPreset);
+ if (nextPreset !== 'custom') update('layout', 'row');
}}
options={PRESET_OPTIONS}
/>
- {SIMPLE_BADGE_IDS.map((id) => {
+ {SIMPLE_BADGE_OPTIONS.map((id) => {
const label = ALL_BADGES.find((badge) => badge.id === id)?.label ?? id;
return (
void;
}
-const BADGE_OPTIONS: RatingType[] = ['imdb', 'rt', 'rt_popcorn', 'meta', 'tmdb', 'age', 'runtime', 'year'];
const PRESET_OPTIONS: { id: PresetType; label: string }[] = [
{ id: 'bl', label: 'Bottom Left' },
{ id: 'br', label: 'Bottom Right' },
@@ -44,8 +44,6 @@ const WalkthroughMode: React.FC = ({
return config.theme;
}, [config.theme, config.uiPreset]);
- const selectedSet = useMemo(() => new Set(), []);
-
const applyStyle = (next: WalkthroughStyle) => {
setConfig((prev) => {
if (next === 'minimal') {
@@ -63,6 +61,8 @@ const WalkthroughMode: React.FC = ({
});
};
+ const canAdvance = step === 1 ? !!config.tmdbId || !!config.imdbId : step < 4;
+
return (
@@ -151,7 +151,7 @@ const WalkthroughMode: React.FC
= ({
Step 3 · Choose badges
- {BADGE_OPTIONS.map((id) => {
+ {SIMPLE_BADGE_OPTIONS.filter((id) => id !== 'title').map((id) => {
const label = ALL_BADGES.find((badge) => badge.id === id)?.label ?? id;
const active = config.ratings.includes(id);
return (
@@ -184,7 +184,7 @@ const WalkthroughMode: React.FC
= ({
()}
onSelect={() => undefined}
onContextMenu={() => undefined}
onLogoContextMenu={() => undefined}
@@ -245,7 +245,7 @@ const WalkthroughMode: React.FC = ({
size="sm"
rightIcon={ }
onClick={() => setStep((value) => Math.min(4, value + 1))}
- disabled={(step === 1 && !config.tmdbId) || step === 4}
+ disabled={!canAdvance}
>
Next
diff --git a/src/components/builder/components/simple/constants.ts b/src/components/builder/components/simple/constants.ts
new file mode 100644
index 00000000..7ced95d6
--- /dev/null
+++ b/src/components/builder/components/simple/constants.ts
@@ -0,0 +1,13 @@
+import type { RatingType } from '../../types';
+
+export const SIMPLE_BADGE_OPTIONS: RatingType[] = [
+ 'imdb',
+ 'rt',
+ 'rt_popcorn',
+ 'meta',
+ 'tmdb',
+ 'age',
+ 'runtime',
+ 'year',
+ 'title',
+];
diff --git a/src/components/builder/index.tsx b/src/components/builder/index.tsx
index 56abc570..4c7740e8 100644
--- a/src/components/builder/index.tsx
+++ b/src/components/builder/index.tsx
@@ -72,7 +72,7 @@ const ImportDialog = lazy(() => import('./components/ImportDialogue'));
const ExportPopover = lazy(() => import('./components/ExportPopover'));
const ContextMenu = lazy(() => import('./components/ContextMenu'));
const CommandPalette = lazy(() => import('./components/CommandPalette'));
-const WALKTHROUGH_COMPLETED_KEY = 'posterium_builder_walkthrough_completed_v1';
+const WALKTHROUGH_SEEN_KEY = 'posterium_builder_walkthrough_seen_v1';
// ── Studio layout ─────────────────────────────────────────────────────────────
const StudioLayout: React.FC<{
@@ -130,7 +130,7 @@ const StudioLayout: React.FC<{
setBuilderMode(mode);
if (mode !== 'walkthrough') {
try {
- localStorage.setItem(WALKTHROUGH_COMPLETED_KEY, '1');
+ localStorage.setItem(WALKTHROUGH_SEEN_KEY, '1');
} catch {
// ignore
}
@@ -140,7 +140,7 @@ const StudioLayout: React.FC<{
useEffect(() => {
if (initialMode !== 'simple') return;
try {
- const seen = localStorage.getItem(WALKTHROUGH_COMPLETED_KEY);
+ const seen = localStorage.getItem(WALKTHROUGH_SEEN_KEY);
if (!seen) setBuilderMode('walkthrough');
} catch {
// ignore
From c6648a2d74d3281407defdff6c8c3389d72f4885 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 26 May 2026 15:48:18 +0000
Subject: [PATCH 5/7] feat: align walkthrough flow with cinematic branching UX
---
.../builder/components/CommandPalette.tsx | 17 +-
.../builder/components/LayerPanel.tsx | 3 +-
.../builder/components/ResetDialogue.tsx | 2 +-
.../components/navigation/ModeToggle.tsx | 18 +-
.../components/simple/WalkthroughMode.tsx | 1041 ++++++++++++++---
.../builder/hooks/usePosterHistory.ts | 28 +-
src/components/builder/index.tsx | 354 +++---
.../builder/systems/storage/builderStorage.ts | 112 +-
src/pages/builder/legacy.astro | 18 +
src/pages/privacy.astro | 40 +-
10 files changed, 1279 insertions(+), 354 deletions(-)
create mode 100644 src/pages/builder/legacy.astro
diff --git a/src/components/builder/components/CommandPalette.tsx b/src/components/builder/components/CommandPalette.tsx
index 92790298..f0b275f4 100644
--- a/src/components/builder/components/CommandPalette.tsx
+++ b/src/components/builder/components/CommandPalette.tsx
@@ -1,6 +1,10 @@
// src/components/builder/components/CommandPalette.tsx
import React, { useState, useEffect, useRef, useCallback, memo } from 'react';
import { Command, Search, X } from 'lucide-react';
+import {
+ loadRecentCommandIds,
+ saveRecentCommandIds,
+} from '../systems/storage/builderStorage';
export interface PaletteCommand {
id: string;
@@ -61,22 +65,13 @@ const CommandPalette: React.FC = memo(({ isOpen, onClose, commands }) =>
}, [isOpen]);
useEffect(() => {
- try {
- const r = JSON.parse(sessionStorage.getItem('posterium_recent_cmds') || '[]');
- setRecentIds(r);
- } catch {
- /* ignore */
- }
+ setRecentIds(loadRecentCommandIds());
}, []);
const recordRecent = useCallback((id: string) => {
setRecentIds((prev) => {
const next = [id, ...prev.filter((x) => x !== id)].slice(0, 5);
- try {
- sessionStorage.setItem('posterium_recent_cmds', JSON.stringify(next));
- } catch {
- /* ignore */
- }
+ saveRecentCommandIds(next);
return next;
});
}, []);
diff --git a/src/components/builder/components/LayerPanel.tsx b/src/components/builder/components/LayerPanel.tsx
index 01ef33ea..7431c0cb 100644
--- a/src/components/builder/components/LayerPanel.tsx
+++ b/src/components/builder/components/LayerPanel.tsx
@@ -103,7 +103,8 @@ const ApiKeysPanel: React.FC<{
className="body-font leading-relaxed"
style={{ fontSize: 9, color: 'var(--film-text-dim)' }}
>
- Override the default API keys used to fetch ratings and posters. Stored in a browser cookie.
+ Override the default API keys used to fetch ratings and posters. Stored in browser
+ localStorage on this device only.
{[
diff --git a/src/components/builder/components/ResetDialogue.tsx b/src/components/builder/components/ResetDialogue.tsx
index ee976694..c1b77670 100644
--- a/src/components/builder/components/ResetDialogue.tsx
+++ b/src/components/builder/components/ResetDialogue.tsx
@@ -136,7 +136,7 @@ const ResetDialog = memo(({ isOpen, onClose, onConfirm }) => (
}}
>
Badge and layout settings will be restored to defaults. Your current poster source,
- media IDs, and saved API keys will be kept.
+ media IDs, and saved API keys in local storage will be kept.
= memo(({ mode, onChange }) => (
style={{ background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(196,124,46,0.16)' }}
aria-label="Builder mode"
>
- {(['simple', 'advanced'] as const).map((item) => {
- const active = mode === item;
+ {(
+ [
+ { id: 'walkthrough', label: 'Walkthrough' },
+ { id: 'simple', label: 'Simple' },
+ { id: 'advanced', label: 'Advanced' },
+ ] as const
+ ).map((item) => {
+ const active = mode === item.id;
return (
onChange(item)}
+ onClick={() => onChange(item.id)}
aria-pressed={active}
- className="h-7 px-2.5 rounded-md syne-font text-[10px] font-bold uppercase tracking-wider transition-all"
+ className="h-7 px-2.5 rounded-md syne-font text-[10px] font-bold uppercase tracking-[0.16em] transition-all"
style={{
color: active ? '#070706' : 'var(--film-text-dim)',
background: active ? 'var(--film-amber)' : 'transparent',
}}
>
- {item}
+ {item.label}
);
})}
diff --git a/src/components/builder/components/simple/WalkthroughMode.tsx b/src/components/builder/components/simple/WalkthroughMode.tsx
index a293e265..1cdd07c1 100644
--- a/src/components/builder/components/simple/WalkthroughMode.tsx
+++ b/src/components/builder/components/simple/WalkthroughMode.tsx
@@ -1,15 +1,38 @@
import React, { useMemo, useRef, useState } from 'react';
-import { ArrowLeft, ArrowRight, Sparkles, Download } from 'lucide-react';
+import {
+ ArrowLeft,
+ ArrowRight,
+ Compass,
+ Download,
+ ExternalLink,
+ ImagePlus,
+ Layers3,
+ Sparkles,
+ Wand2,
+} from 'lucide-react';
import type { ExtensionType, PosterConfig, PresetType, RatingType } from '../../types';
-import { ALL_BADGES } from '../../types';
+import { ALL_BADGES, DEFAULT_CONFIG } from '../../types';
import PreviewCanvas from '../PreviewCanvas';
import { Button, Card } from '@/components/ui';
import ExportMenu from '@/components/shared/ExportMenu';
import MediaPicker from './MediaPicker';
import type { BuilderMode } from '../navigation/ModeToggle';
import { SIMPLE_BADGE_OPTIONS } from './constants';
+import { generateApiUrl } from '../../utils';
-type WalkthroughStyle = 'glass' | 'solid' | 'minimal';
+type WalkthroughTheme = 'glass' | 'solid' | 'minimal' | 'text-only';
+type WalkthroughStage =
+ | 'media'
+ | 'path'
+ | 'presets'
+ | 'community'
+ | 'theme'
+ | 'icon-style'
+ | 'ratings'
+ | 'misc'
+ | 'layout'
+ | 'export';
+type WalkthroughBranch = 'preset' | 'community' | 'guided' | null;
interface Props {
config: PosterConfig;
@@ -20,13 +43,179 @@ interface Props {
onExit: (mode: BuilderMode) => void;
}
+interface QuickPresetOption {
+ id: string;
+ label: string;
+ description: string;
+ config: Partial
;
+}
+
const PRESET_OPTIONS: { id: PresetType; label: string }[] = [
{ id: 'bl', label: 'Bottom Left' },
{ id: 'br', label: 'Bottom Right' },
{ id: 'tl', label: 'Top Left' },
{ id: 'tr', label: 'Top Right' },
+ { id: 'bc', label: 'Bottom Center' },
+ { id: 'tc', label: 'Top Center' },
+];
+
+const SOURCE_OPTIONS = [
+ { id: 'tmdb', label: 'TMDB' },
+ { id: 'fanart', label: 'Fanart.tv' },
+ { id: 'metahub', label: 'Metahub' },
+ { id: 'imdb', label: 'IMDb' },
+] as const;
+
+const PTYPE_OPTIONS = [
+ { id: 'auto', label: 'Auto' },
+ { id: 'top1', label: 'Top 1' },
+ { id: 'top2', label: 'Top 2' },
+ { id: 'top3', label: 'Top 3' },
+ { id: 'best', label: 'Best' },
+ { id: 'latest', label: 'Latest' },
+ { id: 'oldest', label: 'Oldest' },
+ { id: 'random', label: 'Random' },
+];
+
+const LOGO_SOURCE_OPTIONS = [
+ { id: 'auto', label: 'Auto' },
+ { id: 'fanart', label: 'Fanart' },
+ { id: 'tmdb', label: 'TMDB' },
+ { id: 'metahub', label: 'Hub' },
+];
+
+const QUICK_PRESET_OPTIONS: QuickPresetOption[] = [
+ {
+ id: 'glass-stack',
+ label: 'Glass Stack',
+ description: 'Polished glass badges in a lower-left festival style stack.',
+ config: {
+ theme: 'glass',
+ uiPreset: 'b',
+ layout: 'row',
+ preset: 'bl',
+ ratings: ['imdb', 'rt', 'tmdb', 'letterboxd'],
+ },
+ },
+ {
+ id: 'spotlight-solid',
+ label: 'Spotlight Solid',
+ description: 'Crisp solid badges pinned to the upper-right for a modern marquee look.',
+ config: {
+ theme: 'solid',
+ uiPreset: 'b',
+ layout: 'row',
+ preset: 'tr',
+ ratings: ['imdb', 'rt_popcorn', 'meta'],
+ },
+ },
+ {
+ id: 'minimal-marquee',
+ label: 'Minimal Marquee',
+ description: 'Minimal typography-first composition with title-led metadata.',
+ config: {
+ theme: 'solid',
+ uiPreset: 'm',
+ layout: 'row',
+ preset: 'tc',
+ ratings: ['title', 'year', 'imdb'],
+ icon: true,
+ showText: true,
+ },
+ },
];
+const STAGE_COPY: Record<
+ WalkthroughStage,
+ { label: string; title: string; subtitle: string }
+> = {
+ media: {
+ label: 'Main Preview Poster',
+ title: 'Pick the poster source',
+ subtitle: 'Search the title, then optionally fine-tune source, poster choice, and textless mode.',
+ },
+ path: {
+ label: 'Path Selection',
+ title: 'Choose a workflow',
+ subtitle: 'Jump to presets, open community ideas, or continue through the guided setup.',
+ },
+ presets: {
+ label: 'Show Presets',
+ title: 'Choose a ready-made preset',
+ subtitle: 'Selecting one applies it immediately and jumps straight to export.',
+ },
+ community: {
+ label: 'Community Presets',
+ title: 'Community presets placeholder',
+ subtitle: 'Browse the shared gallery on /examples for community-made poster styles.',
+ },
+ theme: {
+ label: 'Theme',
+ title: 'Set the visual treatment',
+ subtitle: 'Match the diagram flow with solid, glass, minimal, or text-only styles.',
+ },
+ 'icon-style': {
+ label: 'Icon Style',
+ title: 'Choose the icon style',
+ subtitle: 'Solid and glass routes surface all icon style variants before ratings.',
+ },
+ ratings: {
+ label: 'Ratings',
+ title: 'Curate the applicable ratings',
+ subtitle: 'Toggle the ratings you want on the poster while the preview stays live.',
+ },
+ misc: {
+ label: 'Misc',
+ title: 'Optional extras',
+ subtitle: 'Logo, title text, rating text, and source preferences are all optional here.',
+ },
+ layout: {
+ label: 'Layout',
+ title: 'Choose the layout',
+ subtitle: 'Pick the final badge arrangement before exporting or moving the work.',
+ },
+ export: {
+ label: 'Export',
+ title: 'Export or move the work',
+ subtitle: 'Download now, or jump into simple or advanced mode for more tuning.',
+ },
+};
+
+const mergePosterConfig = (base: PosterConfig, patch: Partial): PosterConfig => ({
+ ...base,
+ ...patch,
+ items: patch.items ? { ...base.items, ...patch.items } : base.items,
+ ratings: patch.ratings ? [...patch.ratings] : base.ratings,
+ fallbackPool: patch.fallbackPool ? [...patch.fallbackPool] : base.fallbackPool,
+ minimalRatings: patch.minimalRatings ? [...patch.minimalRatings] : base.minimalRatings,
+});
+
+const buildPosterUrl = (config: PosterConfig, baseUrl: string): string => {
+ if (!config.tmdbId && !config.imdbId) return '';
+ try {
+ return generateApiUrl(config, baseUrl);
+ } catch {
+ return '';
+ }
+};
+
+const createCarouselVariant = (config: PosterConfig, side: 'left' | 'right'): PosterConfig => {
+ const ratings =
+ config.ratings.length > 0
+ ? side === 'left'
+ ? config.ratings.slice(0, Math.min(3, config.ratings.length))
+ : [...config.ratings].reverse().slice(0, Math.min(4, config.ratings.length)).reverse()
+ : DEFAULT_CONFIG.ratings.slice(0, 3);
+
+ return mergePosterConfig(config, {
+ theme: side === 'left' ? (config.theme === 'glass' ? 'solid' : 'glass') : config.theme,
+ layout: 'row',
+ uiPreset: side === 'left' ? 'b' : config.uiPreset ?? 'b',
+ preset: side === 'left' ? 'tl' : 'br',
+ ratings,
+ });
+};
+
const WalkthroughMode: React.FC = ({
config,
setConfig,
@@ -35,24 +224,87 @@ const WalkthroughMode: React.FC = ({
onExtensionChange,
onExit,
}) => {
- const [step, setStep] = useState(1);
+ const [stage, setStage] = useState('media');
+ const [branch, setBranch] = useState(null);
const [exportOpen, setExportOpen] = useState(false);
const exportBtnRef = useRef(null);
- const style: WalkthroughStyle = useMemo(() => {
+ const selectedTheme: WalkthroughTheme = useMemo(() => {
if ((config.uiPreset ?? 'b') === 'm') return 'minimal';
+ if (config.icon === false && config.showText !== false) return 'text-only';
return config.theme;
- }, [config.theme, config.uiPreset]);
+ }, [config.icon, config.showText, config.theme, config.uiPreset]);
+
+ const stageOrder = useMemo(() => {
+ if (branch === 'preset') return ['media', 'path', 'presets', 'export'];
+ if (branch === 'community') return ['media', 'path', 'community'];
+ if (branch === 'guided') {
+ return [
+ 'media',
+ 'path',
+ 'theme',
+ ...(selectedTheme === 'glass' || selectedTheme === 'solid' ? (['icon-style'] as const) : []),
+ 'ratings',
+ 'misc',
+ 'layout',
+ 'export',
+ ];
+ }
+ return ['media', 'path'];
+ }, [branch, selectedTheme]);
+
+ const currentStep = Math.max(1, stageOrder.indexOf(stage) + 1);
+ const totalSteps = stageOrder.length;
+ const hasMedia = !!config.tmdbId || !!config.imdbId;
+ const stageCopy = STAGE_COPY[stage];
+ const leftPosterUrl = useMemo(
+ () => buildPosterUrl(createCarouselVariant(config, 'left'), baseUrl),
+ [baseUrl, config]
+ );
+ const rightPosterUrl = useMemo(
+ () => buildPosterUrl(createCarouselVariant(config, 'right'), baseUrl),
+ [baseUrl, config]
+ );
- const applyStyle = (next: WalkthroughStyle) => {
+ const applyTheme = (theme: WalkthroughTheme) => {
setConfig((prev) => {
- if (next === 'minimal') {
- return { ...prev, theme: 'solid', uiPreset: 'm' };
+ if (theme === 'minimal') {
+ return {
+ ...prev,
+ theme: 'solid',
+ uiPreset: 'm',
+ icon: true,
+ showText: true,
+ minimalRatingsEnabled: true,
+ };
+ }
+ if (theme === 'text-only') {
+ return {
+ ...prev,
+ theme: 'solid',
+ uiPreset: 'b',
+ icon: false,
+ showText: true,
+ minimalRatingsEnabled: false,
+ ratings: prev.ratings.length > 0 ? prev.ratings : ['title', 'year'],
+ };
}
- return { ...prev, theme: next, uiPreset: 'b' };
+ return {
+ ...prev,
+ theme,
+ uiPreset: 'b',
+ icon: true,
+ showText: true,
+ minimalRatingsEnabled: false,
+ };
});
};
+ const applyQuickPreset = (option: QuickPresetOption) => {
+ setConfig((prev) => mergePosterConfig(prev, option.config));
+ setStage('export');
+ };
+
const toggleBadge = (id: RatingType, enabled: boolean) => {
setConfig((prev) => {
if (enabled && !prev.ratings.includes(id)) return { ...prev, ratings: [...prev.ratings, id] };
@@ -61,194 +313,665 @@ const WalkthroughMode: React.FC = ({
});
};
- const canAdvance = step === 1 ? !!config.tmdbId || !!config.imdbId : step < 4;
+ const goBack = () => {
+ const currentIndex = stageOrder.indexOf(stage);
+ if (currentIndex <= 0) return;
+ setStage(stageOrder[currentIndex - 1]);
+ };
+
+ const goNext = () => {
+ if (stage === 'media' && hasMedia) {
+ setStage('path');
+ return;
+ }
+ if (stage === 'theme') {
+ setStage(selectedTheme === 'glass' || selectedTheme === 'solid' ? 'icon-style' : 'ratings');
+ return;
+ }
+ const currentIndex = stageOrder.indexOf(stage);
+ if (currentIndex >= 0 && currentIndex < stageOrder.length - 1) {
+ setStage(stageOrder[currentIndex + 1]);
+ }
+ };
+
+ const canAdvance =
+ (stage === 'media' && hasMedia) ||
+ stage === 'theme' ||
+ stage === 'icon-style' ||
+ stage === 'ratings' ||
+ stage === 'misc' ||
+ stage === 'layout';
+
+ const renderSegmented = (
+ value: string,
+ options: { id: string; label: string }[],
+ onChange: (value: string) => void
+ ) => (
+
+ {options.map((option) => {
+ const active = value === option.id;
+ return (
+ onChange(option.id)}
+ className="rounded-full px-3 py-2 syne-font text-[11px] uppercase tracking-[0.18em]"
+ style={{
+ border: active
+ ? '1px solid rgba(196,124,46,0.45)'
+ : '1px solid rgba(255,255,255,0.08)',
+ background: active ? 'rgba(196,124,46,0.12)' : 'rgba(255,255,255,0.03)',
+ color: active ? 'var(--film-amber)' : 'var(--film-text-label)',
+ }}
+ >
+ {option.label}
+
+ );
+ })}
+
+ );
return (
-
-
-
-
-
- Walkthrough
-
-
- Step {step} of 4
-
+
+
+
+
+
+
+
+
+ Walkthrough
+
+
+ {stageCopy.title}
+
+
+ {stageCopy.subtitle}
+
+
+
+
+ Progress
+
+
+ {currentStep} / {totalSteps}
+
+
-
onExit('simple')}>
- Skip to Simple
-
-
- {step === 1 && (
-
-
+
+
+
+
+
+ Persistent preview
+
+
+ 3-poster cover flow
+
+
+
+
+
+
+
+ {leftPosterUrl ? (
+
+ ) : (
+
+
+ Alt A
+
+
+ )}
+
+
+
+
+
+
+
+
()}
+ onSelect={() => undefined}
+ onContextMenu={() => undefined}
+ onLogoContextMenu={() => undefined}
+ />
+
+
+
+
+
+
+
+ {rightPosterUrl ? (
+
+ ) : (
+
+
+ Alt B
+
+
+ )}
+
+
+
+
+
- )}
-
- {step === 2 && (
-
-
- Step 2 · Choose the vibe
-
-
- {([
- { id: 'glass', label: 'Glass' },
- { id: 'solid', label: 'Solid' },
- { id: 'minimal', label: 'Minimal' },
- ] as const).map((option) => {
- const active = style === option.id;
- return (
+
+
6 ? 'grid-cols-4 md:grid-cols-8' : 'grid-cols-4'}`}>
+ {stageOrder.map((flowStage, index) => {
+ const active = flowStage === stage;
+ const complete = index + 1 < currentStep;
+ return (
+
+
+ {STAGE_COPY[flowStage].label}
+
+
+ );
+ })}
+
+
+
+
+
+
+
+ {stageCopy.label}
+
+
+ {stageCopy.title}
+
+
+
+ {stage === 'media' && (
+
+
+
+
+
+ Poster source
+
+ {renderSegmented(config.source, [...SOURCE_OPTIONS], (value) =>
+ setConfig((prev) => ({ ...prev, source: value as PosterConfig['source'] }))
+ )}
+
+
+
+
+ Poster choice
+
+ {renderSegmented(config.ptype || 'auto', PTYPE_OPTIONS, (value) =>
+ setConfig((prev) => ({ ...prev, ptype: value }))
+ )}
+
+
+
+
+
+ Textless poster
+
+
+ Prefer textless artwork when the chosen source supports it.
+
+
+
+ setConfig((prev) => ({ ...prev, textless: event.target.checked }))
+ }
+ />
+
+
+ )}
+
+ {stage === 'path' && (
+
+
{
+ setBranch('preset');
+ setStage('presets');
+ }}
+ className="rounded-2xl p-4 text-left transition-all"
+ style={{
+ border: '1px solid rgba(255,255,255,0.08)',
+ background: 'rgba(255,255,255,0.03)',
+ }}
+ >
+
+
+ Preset
+
+
+ Show curated presets and skip the rest of the walkthrough.
+
+
+
+
{
+ setBranch('community');
+ setStage('community');
+ }}
+ className="rounded-2xl p-4 text-left transition-all"
+ style={{
+ border: '1px solid rgba(255,255,255,0.08)',
+ background: 'rgba(255,255,255,0.03)',
+ }}
+ >
+
+
+ Community Presets
+
+
+ Open the examples gallery for community-created poster ideas.
+
+
+
+
{
+ setBranch('guided');
+ setStage('theme');
+ }}
+ className="rounded-2xl p-4 text-left transition-all"
+ style={{
+ border: '1px solid rgba(196,124,46,0.18)',
+ background: 'rgba(196,124,46,0.07)',
+ }}
+ >
+
+
+ Guided Setup
+
+
+ Continue through theme, icon style, ratings, misc, layout, and export.
+
+
+
+ )}
+
+ {stage === 'presets' && (
+
+ {QUICK_PRESET_OPTIONS.map((option) => (
applyStyle(option.id)}
- className="rounded-xl p-4 text-left transition-colors"
+ onClick={() => applyQuickPreset(option)}
+ className="w-full rounded-2xl px-4 py-4 text-left transition-all"
style={{
- border: active ? '1px solid rgba(196,124,46,0.5)' : '1px solid rgba(255,255,255,0.1)',
- background: active ? 'rgba(196,124,46,0.12)' : 'rgba(255,255,255,0.03)',
- color: 'var(--film-cream)',
+ border: '1px solid rgba(255,255,255,0.08)',
+ background: 'rgba(255,255,255,0.03)',
}}
>
- {option.label}
+
+
+
+ {option.label}
+
+
+ {option.description}
+
+
+
+
- );
- })}
-
-
-
- Preset layout
-
-
- {PRESET_OPTIONS.map((option) => {
- const active = config.preset === option.id;
+ ))}
+
+ )}
+
+ {stage === 'community' && (
+
+
+ Community presets live in the examples gallery. This placeholder follows the diagram while
+ still giving you the redirect path.
+
+
}
+ onClick={() => window.location.assign('/examples')}
+ >
+ Go to /examples
+
+
+ )}
+
+ {stage === 'theme' && (
+
+ {(
+ [
+ { id: 'solid', label: 'Solid' },
+ { id: 'glass', label: 'Glass' },
+ { id: 'minimal', label: 'Minimal' },
+ { id: 'text-only', label: 'Text Only' },
+ ] as const
+ ).map((option) => {
+ const active = selectedTheme === option.id;
+ return (
+
applyTheme(option.id)}
+ className="rounded-2xl p-4 text-left transition-all"
+ style={{
+ border: active
+ ? '1px solid rgba(196,124,46,0.45)'
+ : '1px solid rgba(255,255,255,0.08)',
+ background: active ? 'rgba(196,124,46,0.12)' : 'rgba(255,255,255,0.03)',
+ color: 'var(--film-cream)',
+ }}
+ >
+ {option.label}
+
+ );
+ })}
+
+ )}
+
+ {stage === 'icon-style' && (
+
+ {[
+ { id: 1, label: 'Default' },
+ { id: 2, label: 'Alt' },
+ { id: 3, label: 'Mono' },
+ ].map((option) => {
+ const active = (config.iconType ?? 1) === option.id;
return (
- setConfig((prev) => ({ ...prev, preset: option.id, layout: 'row' }))
+ setConfig((prev) => ({ ...prev, iconType: option.id }))
}
- className="px-3 py-2 rounded-lg syne-font text-[11px]"
+ className="rounded-2xl p-4 text-left transition-all"
+ style={{
+ border: active
+ ? '1px solid rgba(196,124,46,0.45)'
+ : '1px solid rgba(255,255,255,0.08)',
+ background: active ? 'rgba(196,124,46,0.12)' : 'rgba(255,255,255,0.03)',
+ color: 'var(--film-cream)',
+ }}
+ >
+ {option.label}
+
+ );
+ })}
+
+ )}
+
+ {stage === 'ratings' && (
+
+ {SIMPLE_BADGE_OPTIONS.map((id) => {
+ const label = ALL_BADGES.find((badge) => badge.id === id)?.label ?? id;
+ const active = config.ratings.includes(id);
+ return (
+ toggleBadge(id, !active)}
+ className="w-full rounded-2xl px-3 py-3 text-left syne-font text-[11px] uppercase tracking-[0.16em]"
style={{
- border: active ? '1px solid rgba(196,124,46,0.5)' : '1px solid rgba(255,255,255,0.1)',
+ border: active
+ ? '1px solid rgba(196,124,46,0.45)'
+ : '1px solid rgba(255,255,255,0.08)',
background: active ? 'rgba(196,124,46,0.12)' : 'rgba(255,255,255,0.03)',
color: active ? 'var(--film-amber)' : 'var(--film-text-label)',
}}
>
- {option.label}
+ {label}
);
})}
-
-
- )}
-
- {step === 3 && (
-
-
- Step 3 · Choose badges
-
-
- {SIMPLE_BADGE_OPTIONS.filter((id) => id !== 'title').map((id) => {
- const label = ALL_BADGES.find((badge) => badge.id === id)?.label ?? id;
- const active = config.ratings.includes(id);
- return (
-
toggleBadge(id, !active)}
- className="w-full px-3 py-2 rounded-lg text-left syne-font text-[11px]"
+ )}
+
+ {stage === 'misc' && (
+
+ {[
+ {
+ label: 'Show logo',
+ checked: config.logo,
+ onChange: (checked: boolean) =>
+ setConfig((prev) => ({ ...prev, logo: checked })),
+ },
+ {
+ label: 'Show title text',
+ checked: config.ratings.includes('title'),
+ onChange: (checked: boolean) => toggleBadge('title', checked),
+ },
+ {
+ label: 'Show rating text',
+ checked: config.showText !== false,
+ onChange: (checked: boolean) =>
+ setConfig((prev) => ({ ...prev, showText: checked })),
+ },
+ ].map((item) => (
+
- {label}
-
- );
- })}
-
-
- )}
+
+ {item.label}
+
+ item.onChange(event.target.checked)}
+ />
+
+ ))}
- {step === 4 && (
-
-
-
-
Preview
+
+
+ Logo source
+
+ {renderSegmented(String(config.logoSource ?? 'auto'), LOGO_SOURCE_OPTIONS, (value) =>
+ setConfig((prev) => ({
+ ...prev,
+ logoSource: value === 'auto' ? null : (value as PosterConfig['logoSource']),
+ }))
+ )}
+
-
-
()}
- onSelect={() => undefined}
- onContextMenu={() => undefined}
- onLogoContextMenu={() => undefined}
- />
+ )}
+
+ {stage === 'layout' && (
+
+
+
+ Layout direction
+
+ {renderSegmented(config.layout, [
+ { id: 'row', label: 'Row' },
+ { id: 'col', label: 'Column' },
+ { id: 'custom', label: 'Custom' },
+ ], (value) =>
+ setConfig((prev) => ({ ...prev, layout: value as PosterConfig['layout'] }))
+ )}
+
+
+
+
+ Preset position
+
+ {renderSegmented(config.preset, PRESET_OPTIONS, (value) =>
+ setConfig((prev) => ({ ...prev, preset: value as PresetType, layout: 'row' }))
+ )}
+
-
+ )}
-
-
- Step 4 · Generate & Export
-
+ {stage === 'export' && (
+
+
+
+ Final enlarged preview
+
+
+ Export now or move the work into simple or advanced mode.
+
+
+
+
}
+ onClick={() => setExportOpen((open) => !open)}
+ >
+ Export Options
+
+ {exportOpen && (
+
setExportOpen(false)}
+ anchorRef={exportBtnRef}
+ />
+ )}
+
+
+ onExit('advanced')}>
+ Advanced Mode
+
+ onExit('simple')}>
+ Simple Mode
+
+
+
+ )}
+
+
+
+
}
+ onClick={goBack}
+ disabled={stageOrder.indexOf(stage) <= 0}
+ >
+ Back
+
+ {stage !== 'path' && stage !== 'presets' && stage !== 'community' && stage !== 'export' && (
}
- onClick={() => setExportOpen((open) => !open)}
+ size="sm"
+ rightIcon={
}
+ onClick={goNext}
+ disabled={!canAdvance}
>
- Export Options
+ Next
- {exportOpen && (
-
setExportOpen(false)}
- anchorRef={exportBtnRef}
- />
- )}
-
-
- onExit('simple')}>
- Tweak in Simple Mode
-
- onExit('advanced')}>
- Fine-tune in Advanced Mode
-
-
-
+ )}
- )}
-
-
- }
- onClick={() => setStep((value) => Math.max(1, value - 1))}
- disabled={step === 1}
- >
- Back
-
- }
- onClick={() => setStep((value) => Math.min(4, value + 1))}
- disabled={!canAdvance}
- >
- Next
-
diff --git a/src/components/builder/hooks/usePosterHistory.ts b/src/components/builder/hooks/usePosterHistory.ts
index 8e51ea5c..17b59a61 100644
--- a/src/components/builder/hooks/usePosterHistory.ts
+++ b/src/components/builder/hooks/usePosterHistory.ts
@@ -1,14 +1,35 @@
-import { useState, useCallback } from 'react';
+import { useState, useCallback, useEffect } from 'react';
import type { PosterConfig } from '../types';
+import {
+ loadBuilderHistory,
+ saveBuilderHistory,
+ type PosterHistorySnapshot,
+} from '../systems/storage/builderStorage';
-export const usePosterHistory = (initialState: PosterConfig | (() => PosterConfig)) => {
+interface Options {
+ persist?: boolean;
+}
+
+export const usePosterHistory = (
+ initialState: PosterConfig | (() => PosterConfig),
+ options: Options = {}
+) => {
const [stateObj, setStateObj] = useState(() => {
+ if (options.persist) {
+ const stored = loadBuilderHistory();
+ if (stored) return stored;
+ }
const state = typeof initialState === 'function' ? initialState() : initialState;
- return { history: [state], currentIndex: 0 };
+ return { history: [state], currentIndex: 0 } satisfies PosterHistorySnapshot;
});
const state = stateObj.history[stateObj.currentIndex];
+ useEffect(() => {
+ if (!options.persist) return;
+ saveBuilderHistory(stateObj);
+ }, [options.persist, stateObj]);
+
const setState = useCallback((action: React.SetStateAction) => {
setStateObj((prev) => {
const current = prev.history[prev.currentIndex];
@@ -35,6 +56,7 @@ export const usePosterHistory = (initialState: PosterConfig | (() => PosterConfi
return {
state,
+ historyState: stateObj,
setState,
undo,
redo,
diff --git a/src/components/builder/index.tsx b/src/components/builder/index.tsx
index 4c7740e8..dd8cc8f9 100644
--- a/src/components/builder/index.tsx
+++ b/src/components/builder/index.tsx
@@ -14,8 +14,13 @@ import { parseUrlToConfig, DEFAULT_API_BASE, calculateAutoPosition, getScale } f
import {
BUILDER_STORAGE_KEY,
MAX_QUERY_CONFIG_LENGTH,
- loadKeysFromCookie,
- saveKeysToCookie,
+ hasVisitedBuilder,
+ loadKeysFromStorage,
+ loadLastMode,
+ markBuilderVisited,
+ saveKeysToStorage,
+ saveLastMode,
+ writeStoredJson,
} from './systems/storage/builderStorage';
import PreviewCanvas from './components/PreviewCanvas';
import LayerPanel from './components/LayerPanel';
@@ -72,7 +77,6 @@ const ImportDialog = lazy(() => import('./components/ImportDialogue'));
const ExportPopover = lazy(() => import('./components/ExportPopover'));
const ContextMenu = lazy(() => import('./components/ContextMenu'));
const CommandPalette = lazy(() => import('./components/CommandPalette'));
-const WALKTHROUGH_SEEN_KEY = 'posterium_builder_walkthrough_seen_v1';
// ── Studio layout ─────────────────────────────────────────────────────────────
const StudioLayout: React.FC<{
@@ -86,6 +90,8 @@ const StudioLayout: React.FC<{
canUndo: boolean;
canRedo: boolean;
initialMode?: BuilderMode;
+ persistMode?: boolean;
+ showLegacyBanner?: boolean;
}> = ({
config,
setConfig,
@@ -97,6 +103,8 @@ const StudioLayout: React.FC<{
canUndo,
canRedo,
initialMode = 'simple',
+ persistMode = true,
+ showLegacyBanner = false,
}) => {
const {
activeTab,
@@ -114,7 +122,11 @@ const StudioLayout: React.FC<{
toggleViewOption,
} = useEditor();
- const [builderMode, setBuilderMode] = useState(initialMode);
+ const [builderMode, setBuilderMode] = useState(() => {
+ if (!persistMode || typeof window === 'undefined') return initialMode;
+ if (!hasVisitedBuilder()) return 'walkthrough';
+ return loadLastMode() ?? initialMode;
+ });
const [advancedPanel, setAdvancedPanel] = useState('source');
const [isResetOpen, setIsResetOpen] = useState(false);
const [isImportOpen, setIsImportOpen] = useState(false);
@@ -128,24 +140,13 @@ const StudioLayout: React.FC<{
const toggleFullscreen = useCallback(() => setIsFullscreen((v) => !v), []);
const handleModeChange = useCallback((mode: BuilderMode) => {
setBuilderMode(mode);
- if (mode !== 'walkthrough') {
- try {
- localStorage.setItem(WALKTHROUGH_SEEN_KEY, '1');
- } catch {
- // ignore
- }
- }
}, []);
useEffect(() => {
- if (initialMode !== 'simple') return;
- try {
- const seen = localStorage.getItem(WALKTHROUGH_SEEN_KEY);
- if (!seen) setBuilderMode('walkthrough');
- } catch {
- // ignore
- }
- }, [initialMode]);
+ if (!persistMode) return;
+ markBuilderVisited();
+ saveLastMode(builderMode);
+ }, [builderMode, persistMode]);
useEffect(() => {
if (['source', 'layers', 'poster', 'badges', 'selection'].includes(activeTab)) {
@@ -184,6 +185,17 @@ const StudioLayout: React.FC<{
const closeCtxMenu = useCallback(() => setCtxMenu((s) => ({ ...s, visible: false })), []);
const [paletteOpen, setPaletteOpen] = useState(false);
+ useEffect(() => {
+ if (builderMode !== 'walkthrough') return;
+ setPaletteOpen(false);
+ setShortcutsOpen(false);
+ setExportOpen(false);
+ setIsImportOpen(false);
+ closeCtxMenu();
+ clearSelection();
+ setMobileSheetMode('hidden');
+ }, [builderMode, clearSelection, closeCtxMenu, setMobileSheetMode]);
+
const selectedIdsRef = useRef(selectedIds);
const selectedLogoRef = useRef(selectedLogo);
const selectedMinimalElementsRef = useRef(selectedMinimalElements);
@@ -419,8 +431,8 @@ const StudioLayout: React.FC<{
// ── Keyboard shortcuts ──────────────────────────────────────────────────
useEffect(() => {
+ if (builderMode === 'walkthrough') return;
const onKey = (e: KeyboardEvent) => {
- if (builderMode === 'walkthrough') return;
const t = e.target as HTMLElement;
const inInput = t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable;
const mod = e.ctrlKey || e.metaKey;
@@ -1044,7 +1056,7 @@ const StudioLayout: React.FC<{
)}
{/* ── HEADER ── */}
- {!isFullscreen && builderMode !== 'walkthrough' && (
+ {!isFullscreen && (
- setPaletteOpen(true)}
- title="Search commands (⌘K)"
- className="hidden max-[750px]:flex items-center gap-2 h-8 w-[250px] max-[600px]:w-[100px] px-3 rounded-md transition-all pointer-events-auto"
- style={{
- color: 'var(--film-text-dim)',
- border: '1px solid rgba(255,255,255,0.08)',
- background: 'rgba(255,255,255,0.03)',
- }}
- onMouseEnter={(e) => {
- (e.currentTarget as HTMLElement).style.borderColor = 'rgba(196,124,46,0.3)';
- }}
- onMouseLeave={(e) => {
- (e.currentTarget as HTMLElement).style.borderColor = 'rgba(255,255,255,0.08)';
- }}
- >
-
- Search…
-
- setShortcutsOpen((v) => !v)}
- label="Keyboard Shortcuts (⌘/)"
- active={shortcutsOpen}
- hideOnMobile
- >
-
-
+ {builderMode !== 'walkthrough' ? (
+ <>
+ setPaletteOpen(true)}
+ title="Search commands (⌘K)"
+ className="hidden max-[750px]:flex items-center gap-2 h-8 w-[250px] max-[600px]:w-[100px] px-3 rounded-md transition-all pointer-events-auto"
+ style={{
+ color: 'var(--film-text-dim)',
+ border: '1px solid rgba(255,255,255,0.08)',
+ background: 'rgba(255,255,255,0.03)',
+ }}
+ onMouseEnter={(e) => {
+ (e.currentTarget as HTMLElement).style.borderColor = 'rgba(196,124,46,0.3)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLElement).style.borderColor = 'rgba(255,255,255,0.08)';
+ }}
+ >
+
+ Search…
+
+ setShortcutsOpen((v) => !v)}
+ label="Keyboard Shortcuts (⌘/)"
+ active={shortcutsOpen}
+ hideOnMobile
+ >
+
+
+ >
+ ) : (
+
+ Guided build
+
+ )}
{/* Central area: sidebar toggles flank the command palette search */}
-
- {/* Left sidebar toggle - desktop only */}
-
setLeftVisible(!leftVisible)}
+ {builderMode !== 'walkthrough' && (
+
+ {/* Left sidebar toggle - desktop only */}
+
setLeftVisible(!leftVisible)}
title={`${leftVisible ? 'Hide' : 'Show'} Layers ([)`}
className="shrink-0 w-8 h-8 rounded-lg items-center justify-center transition-all hidden lg:flex pointer-events-auto"
style={{
@@ -1196,107 +1224,132 @@ const StudioLayout: React.FC<{
>
-
+
+ )}
{/* Right Header Area */}
-
+ {builderMode !== 'walkthrough' ? (
+ <>
+
- {/* History */}
-
-
-
-
-
-
+
+
+
+
+
+
-
+
- {/* Import */}
-
setIsImportOpen(true)}
- className="flex items-center gap-1.5 h-8 px-2.5 rounded-md transition-colors syne-font hidden sm:flex"
- style={{ color: 'var(--film-text-dim)' }}
- onMouseEnter={(e) => {
- (e.currentTarget as HTMLElement).style.background = 'rgba(255,255,255,0.05)';
- (e.currentTarget as HTMLElement).style.color = 'var(--film-cream)';
- }}
- onMouseLeave={(e) => {
- (e.currentTarget as HTMLElement).style.background = 'transparent';
- (e.currentTarget as HTMLElement).style.color = 'var(--film-text-dim)';
- }}
- >
-
-
- Import
-
-
+
setIsImportOpen(true)}
+ className="flex items-center gap-1.5 h-8 px-2.5 rounded-md transition-colors syne-font hidden sm:flex"
+ style={{ color: 'var(--film-text-dim)' }}
+ onMouseEnter={(e) => {
+ (e.currentTarget as HTMLElement).style.background = 'rgba(255,255,255,0.05)';
+ (e.currentTarget as HTMLElement).style.color = 'var(--film-cream)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLElement).style.background = 'transparent';
+ (e.currentTarget as HTMLElement).style.color = 'var(--film-text-dim)';
+ }}
+ >
+
+
+ Import
+
+
- {/* Export CTA */}
-
setExportOpen((v) => !v)}
- className="flex items-center gap-1.5 h-8 px-2 sm:px-3 rounded-lg ml-1 syne-font transition-all active:scale-95"
- style={{
- background: exportOpen ? 'rgba(196,124,46,0.9)' : 'var(--film-amber)',
- color: '#070706',
- fontSize: 11,
- fontWeight: 700,
- letterSpacing: '0.08em',
- textTransform: 'uppercase',
- boxShadow: exportOpen ? 'none' : '0 0 16px rgba(196,124,46,0.2)',
- border: 'none',
- cursor: 'pointer',
- }}
- onMouseEnter={(e) => {
- if (!exportOpen) (e.currentTarget as HTMLElement).style.background = '#d4a245';
- }}
- onMouseLeave={(e) => {
- if (!exportOpen)
- (e.currentTarget as HTMLElement).style.background = 'var(--film-amber)';
- }}
- >
-
- Export
-
-
+
setExportOpen((v) => !v)}
+ className="flex items-center gap-1.5 h-8 px-2 sm:px-3 rounded-lg ml-1 syne-font transition-all active:scale-95"
+ style={{
+ background: exportOpen ? 'rgba(196,124,46,0.9)' : 'var(--film-amber)',
+ color: '#070706',
+ fontSize: 11,
+ fontWeight: 700,
+ letterSpacing: '0.08em',
+ textTransform: 'uppercase',
+ boxShadow: exportOpen ? 'none' : '0 0 16px rgba(196,124,46,0.2)',
+ border: 'none',
+ cursor: 'pointer',
+ }}
+ onMouseEnter={(e) => {
+ if (!exportOpen) (e.currentTarget as HTMLElement).style.background = '#d4a245';
+ }}
+ onMouseLeave={(e) => {
+ if (!exportOpen)
+ (e.currentTarget as HTMLElement).style.background = 'var(--film-amber)';
+ }}
+ >
+
+ Export
+
+
-
+
- {/* Reset - permanently placed at top right */}
-
setIsResetOpen(true)}
- className="flex items-center gap-1.5 h-8 px-2 sm:px-2.5 rounded-md transition-colors syne-font text-red-400/80 hover:text-red-300 hover:bg-red-500/10"
- >
-
-
- Reset
-
-
+
setIsResetOpen(true)}
+ className="flex items-center gap-1.5 h-8 px-2 sm:px-2.5 rounded-md transition-colors syne-font text-red-400/80 hover:text-red-300 hover:bg-red-500/10"
+ >
+
+
+ Reset
+
+
+ >
+ ) : (
+
+ Live preview stays in sync
+
+ )}
)}
+ {showLegacyBanner && (
+
+ Legacy builder route: direct access to the classic simple editor entry point.
+
+ )}
+
{/* ── BODY ── */}
{/* Left sidebar */}
@@ -1504,7 +1557,11 @@ const StudioLayout: React.FC<{
};
// ── Root app ──────────────────────────────────────────────────────────────────
-const BuilderApp: React.FC<{ initialMode?: BuilderMode }> = ({ initialMode = 'simple' }) => {
+const BuilderApp: React.FC<{
+ initialMode?: BuilderMode;
+ persistMode?: boolean;
+ showLegacyBanner?: boolean;
+}> = ({ initialMode = 'simple', persistMode = true, showLegacyBanner = false }) => {
const {
state: config,
setState: setConfig,
@@ -1516,26 +1573,25 @@ const BuilderApp: React.FC<{ initialMode?: BuilderMode }> = ({ initialMode = 'si
try {
const saved = localStorage.getItem(BUILDER_STORAGE_KEY);
const cfg = saved ? (JSON.parse(saved) as PosterConfig) : DEFAULT_CONFIG;
- const cookieKeys = loadKeysFromCookie();
- if (cookieKeys && Object.keys(cookieKeys).some((k) => cookieKeys[k as keyof ApiKeys])) {
- return { ...cfg, keys: { ...cookieKeys, ...cfg.keys } };
+ const storedKeys = loadKeysFromStorage();
+ if (storedKeys && Object.keys(storedKeys).some((k) => storedKeys[k as keyof ApiKeys])) {
+ return { ...cfg, keys: { ...storedKeys, ...cfg.keys } };
}
return cfg;
} catch {
return DEFAULT_CONFIG;
}
- });
+ }, { persist: true });
const [baseUrl, setBaseUrl] = useState(DEFAULT_API_BASE);
useEffect(() => {
- localStorage.setItem(BUILDER_STORAGE_KEY, JSON.stringify(config));
+ writeStoredJson(BUILDER_STORAGE_KEY, config);
}, [config]);
useEffect(() => {
if (config.keys) {
- const hasAnyKey = Object.values(config.keys).some((v) => v && v.trim());
- if (hasAnyKey) saveKeysToCookie(config.keys);
+ saveKeysToStorage(config.keys);
}
}, [config.keys]);
@@ -1605,6 +1661,8 @@ const BuilderApp: React.FC<{ initialMode?: BuilderMode }> = ({ initialMode = 'si
canUndo={canUndo}
canRedo={canRedo}
initialMode={initialMode}
+ persistMode={persistMode}
+ showLegacyBanner={showLegacyBanner}
/>
);
diff --git a/src/components/builder/systems/storage/builderStorage.ts b/src/components/builder/systems/storage/builderStorage.ts
index 77323fca..063333c4 100644
--- a/src/components/builder/systems/storage/builderStorage.ts
+++ b/src/components/builder/systems/storage/builderStorage.ts
@@ -1,25 +1,115 @@
-import type { ApiKeys } from '../../types';
+import type { ApiKeys, PosterConfig } from '../../types';
export const BUILDER_STORAGE_KEY = 'posterium_config_v2';
+export const BUILDER_HISTORY_STORAGE_KEY = 'posterium_history_v1';
+export const BUILDER_API_KEYS_STORAGE_KEY = 'posterium_apikeys_v2';
+export const BUILDER_LAST_MODE_STORAGE_KEY = 'posterium_last_mode';
+export const BUILDER_VISITED_STORAGE_KEY = 'posterium_builder_has_visited_v1';
+export const BUILDER_RECENT_COMMANDS_STORAGE_KEY = 'posterium_recent_cmds';
export const MAX_QUERY_CONFIG_LENGTH = 12000;
-const COOKIE_KEY = 'posterium_apikeys_v1';
-export const saveKeysToCookie = (keys: ApiKeys) => {
+type PersistedBuilderMode = 'simple' | 'advanced' | 'walkthrough';
+
+export interface PosterHistorySnapshot {
+ history: PosterConfig[];
+ currentIndex: number;
+}
+
+const VALID_MODES: PersistedBuilderMode[] = ['simple', 'advanced', 'walkthrough'];
+
+const getStorage = () => {
+ if (typeof window === 'undefined') return null;
+ return window.localStorage;
+};
+
+export const readStoredString = (key: string): string | null => {
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`;
+ return getStorage()?.getItem(key) ?? null;
+ } catch {
+ return null;
+ }
+};
+
+export const writeStoredString = (key: string, value: string) => {
+ try {
+ getStorage()?.setItem(key, value);
} catch {
/* ignore */
}
};
-export const loadKeysFromCookie = (): ApiKeys => {
+export const removeStoredValue = (key: string) => {
try {
- const match = document.cookie.match(new RegExp(`(?:^|; )${COOKIE_KEY}=([^;]*)`));
- if (!match) return {};
- return JSON.parse(decodeURIComponent(match[1])) || {};
+ getStorage()?.removeItem(key);
} catch {
- return {};
+ /* ignore */
}
};
+
+export const readStoredJson =
(key: string): T | null => {
+ const raw = readStoredString(key);
+ if (!raw) return null;
+ try {
+ return JSON.parse(raw) as T;
+ } catch {
+ return null;
+ }
+};
+
+export const writeStoredJson = (key: string, value: unknown) => {
+ try {
+ getStorage()?.setItem(key, JSON.stringify(value));
+ } catch {
+ /* ignore */
+ }
+};
+
+export const saveKeysToStorage = (keys: ApiKeys) => {
+ const hasAnyKey = Object.values(keys ?? {}).some((value) => value && value.trim());
+ if (!hasAnyKey) {
+ removeStoredValue(BUILDER_API_KEYS_STORAGE_KEY);
+ return;
+ }
+ writeStoredJson(BUILDER_API_KEYS_STORAGE_KEY, keys);
+};
+
+export const loadKeysFromStorage = (): ApiKeys =>
+ readStoredJson(BUILDER_API_KEYS_STORAGE_KEY) ?? {};
+
+export const loadBuilderHistory = (): PosterHistorySnapshot | null => {
+ const stored = readStoredJson(BUILDER_HISTORY_STORAGE_KEY);
+ if (!stored || !Array.isArray(stored.history) || stored.history.length === 0) return null;
+ if (typeof stored.currentIndex !== 'number') return null;
+ const currentIndex = Math.max(0, Math.min(stored.currentIndex, stored.history.length - 1));
+ return { history: stored.history, currentIndex };
+};
+
+export const saveBuilderHistory = (snapshot: PosterHistorySnapshot) => {
+ writeStoredJson(BUILDER_HISTORY_STORAGE_KEY, snapshot);
+};
+
+export const hasVisitedBuilder = (): boolean => readStoredString(BUILDER_VISITED_STORAGE_KEY) === '1';
+
+export const markBuilderVisited = () => {
+ writeStoredString(BUILDER_VISITED_STORAGE_KEY, '1');
+};
+
+export const loadLastMode = (): PersistedBuilderMode | null => {
+ const stored = readStoredString(BUILDER_LAST_MODE_STORAGE_KEY);
+ return stored && VALID_MODES.includes(stored as PersistedBuilderMode)
+ ? (stored as PersistedBuilderMode)
+ : null;
+};
+
+export const saveLastMode = (mode: PersistedBuilderMode) => {
+ writeStoredString(BUILDER_LAST_MODE_STORAGE_KEY, mode);
+};
+
+export const loadRecentCommandIds = (): string[] => {
+ const stored = readStoredJson(BUILDER_RECENT_COMMANDS_STORAGE_KEY);
+ return Array.isArray(stored) ? stored.filter((value) => typeof value === 'string') : [];
+};
+
+export const saveRecentCommandIds = (ids: string[]) => {
+ writeStoredJson(BUILDER_RECENT_COMMANDS_STORAGE_KEY, ids.slice(0, 5));
+};
diff --git a/src/pages/builder/legacy.astro b/src/pages/builder/legacy.astro
new file mode 100644
index 00000000..a1d207e9
--- /dev/null
+++ b/src/pages/builder/legacy.astro
@@ -0,0 +1,18 @@
+---
+import BaseLayout from '@/layouts/BaseLayout.astro';
+import BuilderApp from '@/components/builder/index.tsx';
+import '@/styles/global.css';
+---
+
+
+
+
+
+
diff --git a/src/pages/privacy.astro b/src/pages/privacy.astro
index 31229ed5..3061d361 100644
--- a/src/pages/privacy.astro
+++ b/src/pages/privacy.astro
@@ -68,7 +68,7 @@ const seo: PageSEOMetadata = {
Overview
Data We Collect
How We Use Data
- Cookies & Local Storage
+ Local Storage
Third-Party Services
Data Retention
Your Rights
@@ -142,7 +142,7 @@ const seo: PageSEOMetadata = {
override the default shared keys. If you enter these values:
- They are stored in a browser cookie on your device only (posterium_apikeys_v1).
+ They are stored in browser localStorage on your device only (posterium_apikeys_v2).
They are appended as query parameters to API URLs you generate.
They are never transmitted to our servers independently of the URL you construct.
We never store, log, or process third-party API keys on our servers.
@@ -190,14 +190,14 @@ const seo: PageSEOMetadata = {
-
+
04
- Cookies & Local Storage
+ Local Storage
- Posterium uses the browser storage mechanisms listed below. No third-party
- tracking cookies are set.
+ Posterium stores builder data locally in your browser. No builder cookies or
+ third-party tracking cookies are set.
@@ -212,10 +212,10 @@ const seo: PageSEOMetadata = {
- posterium_apikeys_v1
- Cookie
+ posterium_apikeys_v2
+ localStorage
Stores optional TMDB / Fanart.tv API key overrides you supply
- 1 year
+ Persistent (until cleared)
posterium_config_v2
@@ -237,9 +237,21 @@ const seo: PageSEOMetadata = {
posterium_recent_cmds
- sessionStorage
- Tracks recently used command palette commands this session
- Session
+ localStorage
+ Tracks recently used command palette commands across visits
+ Persistent (until cleared)
+
+
+ posterium_history_v1
+ localStorage
+ Stores undo/redo history for the builder locally on your device
+ Persistent (until cleared)
+
+
+ posterium_last_mode
+ localStorage
+ Remembers the last builder mode you were working in
+ Persistent (until cleared)
@@ -247,7 +259,7 @@ const seo: PageSEOMetadata = {
You can clear all of the above at any time by clearing your browser's
- cookies and site data for posterium.xyz. Doing so
+ site data for posterium.xyz. Doing so
will reset the builder to default settings.
@@ -321,7 +333,7 @@ const seo: PageSEOMetadata = {
this period.
- Data stored in your browser (localStorage, sessionStorage, cookies) is
+ Data stored in your browser localStorage is
retained until you clear it. There is no server-side copy of this data.
From aec6eec003c25c5d73d8089b81e2441883058d49 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 26 May 2026 15:50:44 +0000
Subject: [PATCH 6/7] fix: address walkthrough review feedback
---
.../builder/components/simple/MediaPicker.tsx | 6 +++++-
.../components/simple/WalkthroughMode.tsx | 19 +++++++++++--------
src/components/builder/index.tsx | 2 +-
.../builder/systems/storage/builderStorage.ts | 1 +
src/pages/builder/legacy.astro | 12 ++++++++++++
5 files changed, 30 insertions(+), 10 deletions(-)
diff --git a/src/components/builder/components/simple/MediaPicker.tsx b/src/components/builder/components/simple/MediaPicker.tsx
index 3fd034a5..03fb9193 100644
--- a/src/components/builder/components/simple/MediaPicker.tsx
+++ b/src/components/builder/components/simple/MediaPicker.tsx
@@ -51,7 +51,11 @@ const MediaPicker: React.FC = ({ config, setConfig, title = 'Media select
setResults(next);
} catch (error: unknown) {
if (error instanceof Error && error.name === 'AbortError') return;
- setError('Search failed. Check your connection and try again.');
+ setError(
+ error instanceof Error && error.message
+ ? error.message
+ : 'Search failed. Check your connection and try again.'
+ );
} finally {
setLoading(false);
}
diff --git a/src/components/builder/components/simple/WalkthroughMode.tsx b/src/components/builder/components/simple/WalkthroughMode.tsx
index 1cdd07c1..5b0b3ef3 100644
--- a/src/components/builder/components/simple/WalkthroughMode.tsx
+++ b/src/components/builder/components/simple/WalkthroughMode.tsx
@@ -199,20 +199,23 @@ const buildPosterUrl = (config: PosterConfig, baseUrl: string): string => {
}
};
-const createCarouselVariant = (config: PosterConfig, side: 'left' | 'right'): PosterConfig => {
- const ratings =
- config.ratings.length > 0
- ? side === 'left'
- ? config.ratings.slice(0, Math.min(3, config.ratings.length))
- : [...config.ratings].reverse().slice(0, Math.min(4, config.ratings.length)).reverse()
- : DEFAULT_CONFIG.ratings.slice(0, 3);
+const getCarouselRatings = (
+ config: PosterConfig,
+ side: 'left' | 'right',
+ maxCount: number
+): RatingType[] => {
+ if (config.ratings.length === 0) return DEFAULT_CONFIG.ratings.slice(0, maxCount);
+ if (side === 'left') return config.ratings.slice(0, Math.min(maxCount, config.ratings.length));
+ return [...config.ratings].reverse().slice(0, Math.min(maxCount, config.ratings.length)).reverse();
+};
+const createCarouselVariant = (config: PosterConfig, side: 'left' | 'right'): PosterConfig => {
return mergePosterConfig(config, {
theme: side === 'left' ? (config.theme === 'glass' ? 'solid' : 'glass') : config.theme,
layout: 'row',
uiPreset: side === 'left' ? 'b' : config.uiPreset ?? 'b',
preset: side === 'left' ? 'tl' : 'br',
- ratings,
+ ratings: getCarouselRatings(config, side, side === 'left' ? 3 : 4),
});
};
diff --git a/src/components/builder/index.tsx b/src/components/builder/index.tsx
index dd8cc8f9..923c370e 100644
--- a/src/components/builder/index.tsx
+++ b/src/components/builder/index.tsx
@@ -1581,7 +1581,7 @@ const BuilderApp: React.FC<{
} catch {
return DEFAULT_CONFIG;
}
- }, { persist: true });
+ }, { persist: persistMode });
const [baseUrl, setBaseUrl] = useState(DEFAULT_API_BASE);
diff --git a/src/components/builder/systems/storage/builderStorage.ts b/src/components/builder/systems/storage/builderStorage.ts
index 063333c4..baad9a7f 100644
--- a/src/components/builder/systems/storage/builderStorage.ts
+++ b/src/components/builder/systems/storage/builderStorage.ts
@@ -102,6 +102,7 @@ export const loadLastMode = (): PersistedBuilderMode | null => {
};
export const saveLastMode = (mode: PersistedBuilderMode) => {
+ if (!VALID_MODES.includes(mode)) return;
writeStoredString(BUILDER_LAST_MODE_STORAGE_KEY, mode);
};
diff --git a/src/pages/builder/legacy.astro b/src/pages/builder/legacy.astro
index a1d207e9..8b5a4a85 100644
--- a/src/pages/builder/legacy.astro
+++ b/src/pages/builder/legacy.astro
@@ -1,7 +1,10 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import BuilderApp from '@/components/builder/index.tsx';
+import { SITE_CONFIG } from '@/lib/config';
import '@/styles/global.css';
+
+const canonical = `${SITE_CONFIG.baseUrl}/builder/legacy`;
---
From 25fde4542d6d385f25756d204f06e1d71bad23ae Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 26 May 2026 15:52:05 +0000
Subject: [PATCH 7/7] refactor: simplify persisted history hook options
---
src/components/builder/hooks/usePosterHistory.ts | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/components/builder/hooks/usePosterHistory.ts b/src/components/builder/hooks/usePosterHistory.ts
index 17b59a61..ccbcb141 100644
--- a/src/components/builder/hooks/usePosterHistory.ts
+++ b/src/components/builder/hooks/usePosterHistory.ts
@@ -12,10 +12,10 @@ interface Options {
export const usePosterHistory = (
initialState: PosterConfig | (() => PosterConfig),
- options: Options = {}
+ { persist = false }: Options = {}
) => {
const [stateObj, setStateObj] = useState(() => {
- if (options.persist) {
+ if (persist) {
const stored = loadBuilderHistory();
if (stored) return stored;
}
@@ -26,9 +26,9 @@ export const usePosterHistory = (
const state = stateObj.history[stateObj.currentIndex];
useEffect(() => {
- if (!options.persist) return;
+ if (!persist) return;
saveBuilderHistory(stateObj);
- }, [options.persist, stateObj]);
+ }, [persist, stateObj]);
const setState = useCallback((action: React.SetStateAction) => {
setStateObj((prev) => {