From c2efaa873c1a400b2c3e9330cbe1ea68f1d33d91 Mon Sep 17 00:00:00 2001 From: Will Zakielarz Date: Mon, 13 Apr 2026 14:15:27 -0400 Subject: [PATCH 1/8] Default grid off --- frontend/src/components/LayoutEditor.tsx | 2 +- frontend/src/types/layout.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/LayoutEditor.tsx b/frontend/src/components/LayoutEditor.tsx index f3be8a1..a48a31c 100644 --- a/frontend/src/components/LayoutEditor.tsx +++ b/frontend/src/components/LayoutEditor.tsx @@ -601,7 +601,7 @@ export const LayoutEditor = forwardRef(fu const scale = fitScale * (zoomPercent / 100); const stageW = state.width * scale; const stageH = state.height * scale; - const showGrid = state.showGrid !== false; + const showGrid = state.showGrid ?? false; const toggleVisible = (id: string) => { commit( diff --git a/frontend/src/types/layout.ts b/frontend/src/types/layout.ts index 4c6cde8..5cdeff3 100644 --- a/frontend/src/types/layout.ts +++ b/frontend/src/types/layout.ts @@ -96,7 +96,7 @@ export function defaultLayoutState(): LayoutStateV2 { width: 250, height: 350, background: '#1e1e24', - showGrid: true, + showGrid: false, root: [ { type: 'text', @@ -126,7 +126,7 @@ function migrateV1ToV2(v1: LayoutStateV1): LayoutStateV2 { width: v1.width, height: v1.height, background: v1.background, - showGrid: true, + showGrid: false, root, }; } From 5d0db637def49a6e4f8dc0149b4520eee1b9430d Mon Sep 17 00:00:00 2001 From: Will Zakielarz Date: Mon, 13 Apr 2026 14:38:00 -0400 Subject: [PATCH 2/8] Deck preview group selector --- frontend/src/App.css | 70 +++++++- frontend/src/components/LayoutEditor.tsx | 203 ++++++++++++----------- frontend/src/pages/ProjectPage.tsx | 58 +++++-- 3 files changed, 218 insertions(+), 113 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index cf45105..cfe7e07 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -2581,6 +2581,60 @@ label.layout-editor-footer-value-strip { justify-content: center; } +.layout-editor-footer-start-cluster { + display: flex; + align-items: stretch; + flex-shrink: 0; + min-width: 0; +} + +.layout-editor-footer-popover { + position: relative; + display: flex; + align-items: stretch; +} + +.layout-editor-footer-preview-source-btn { + max-width: 200px; + padding-right: 8px; +} + +.layout-editor-footer-preview-source-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.layout-editor-footer-preview-chevron { + flex-shrink: 0; + opacity: 0.6; + transition: transform 0.18s ease; +} + +.layout-editor-footer-preview-chevron--open { + transform: rotate(180deg); +} + +.deck-preview-source-popover { + position: absolute; + bottom: calc(100% + 6px); + left: 0; + min-width: 220px; + max-width: min(320px, 86vw); + max-height: min(48vh, 280px); + overflow-y: auto; + list-style: none; + margin: 0; + padding: 4px 0; + background: rgba(28, 28, 32, 0.98); + backdrop-filter: blur(12px); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55); + z-index: 50; +} + .deck-preview-drawer { position: absolute; left: 0; @@ -2593,7 +2647,7 @@ label.layout-editor-footer-value-strip { transform: translateY(100%); transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1); pointer-events: none; - overflow: visible; + overflow: hidden; } .deck-preview-drawer--open { @@ -2685,16 +2739,15 @@ label.layout-editor-footer-value-strip { .deck-filmstrip--overlay { margin-top: 0; - height: 132px; - min-height: 132px; + min-height: 152px; + height: 152px; + max-height: min(46vh, 260px); border-radius: 10px 10px 0 0; border-bottom: none; box-shadow: 0 -12px 40px rgba(0, 0, 0, 0.55); - max-height: min(42vh, 200px); background: rgba(22, 22, 22, 0.97); backdrop-filter: blur(12px); - /* Let the data-source menu drop below the control without clipping */ - overflow: visible; + overflow: hidden; } .deck-filmstrip-head { @@ -2821,6 +2874,11 @@ label.layout-editor-footer-value-strip { align-items: center; } +.deck-filmstrip-scroll--full { + padding: 12px 14px; + align-items: center; +} + .deck-filmstrip-item { flex: 0 0 auto; } diff --git a/frontend/src/components/LayoutEditor.tsx b/frontend/src/components/LayoutEditor.tsx index a48a31c..223f072 100644 --- a/frontend/src/components/LayoutEditor.tsx +++ b/frontend/src/components/LayoutEditor.tsx @@ -22,7 +22,7 @@ import { Text, Transformer, } from 'react-konva'; -import { ChevronDown, Database, Layers, Minus, Plus } from 'lucide-react'; +import { ChevronDown, Layers, Minus, Plus, Table2 } from 'lucide-react'; import type { LayoutElement, LayoutStateV2 } from '../types/layout'; import { DEFAULT_NEW_TEXT } from '../types/layout'; import { applyTemplate } from '../lib/template'; @@ -381,29 +381,56 @@ export type LayoutEditorHandle = { zoomTo100Percent: () => void; }; -export type EditorDataSource = { +export type DeckPreviewOption = { id: string; label: string; rows: Record[]; + /** Card group’s linked layout id, or null for project dataset / sample. */ + layoutId: string | null; }; +function defaultPreviewSourceId( + activeLayoutId: string | undefined, + options: DeckPreviewOption[] +): string { + if (options.length === 0) return '__sample__'; + if (activeLayoutId) { + const linked = options.find((o) => o.layoutId === activeLayoutId); + if (linked) return linked.id; + } + const withRows = options.find((o) => o.rows.length > 0); + if (withRows) return withRows.id; + return options[0].id; +} + type LayoutEditorProps = { state: LayoutStateV2; onChange: (next: LayoutStateV2) => void; assetUrls: Record; sampleRow: Record; - deckRows?: Record[]; - dataSources?: EditorDataSource[]; + deckPreviewOptions: DeckPreviewOption[]; + activeLayoutId?: string | null; onCapabilitiesChange?: (c: { canUndo: boolean; canRedo: boolean }) => void; }; export const LayoutEditor = forwardRef(function LayoutEditor( - { state, onChange, assetUrls, sampleRow, deckRows = [], dataSources = [], onCapabilitiesChange }, + { + state, + onChange, + assetUrls, + sampleRow, + deckPreviewOptions, + activeLayoutId, + onCapabilitiesChange, + }, ref ) { const [selectedId, setSelectedId] = useState(null); - const [selectedDataSourceId, setSelectedDataSourceId] = useState(null); - const [dataSourceMenuOpen, setDataSourceMenuOpen] = useState(false); + const [previewSourceId, setPreviewSourceId] = useState(() => + defaultPreviewSourceId(activeLayoutId ?? undefined, deckPreviewOptions) + ); + const [previewSourceMenuOpen, setPreviewSourceMenuOpen] = useState(false); + const previewSourceMenuRef = useRef(null); const [canUndo, setCanUndo] = useState(false); const [canRedo, setCanRedo] = useState(false); const historyPast = useRef([]); @@ -490,6 +517,14 @@ export const LayoutEditor = forwardRef(fu onCapabilitiesChange?.({ canUndo, canRedo }); }, [canUndo, canRedo, onCapabilitiesChange]); + useEffect(() => { + setPreviewSourceId((prev) => + deckPreviewOptions.some((o) => o.id === prev) + ? prev + : defaultPreviewSourceId(activeLayoutId ?? undefined, deckPreviewOptions) + ); + }, [deckPreviewOptions, activeLayoutId]); + useEffect(() => { const onKey = (e: globalThis.KeyboardEvent) => { const el = e.target as HTMLElement | null; @@ -696,16 +731,15 @@ export const LayoutEditor = forwardRef(fu [state, selectedId, commit, undo, redo, canUndo, canRedo, showGrid] ); - const activeSource = useMemo( - () => (selectedDataSourceId ? dataSources.find((s) => s.id === selectedDataSourceId) : null), - [selectedDataSourceId, dataSources] - ); - const effectiveRows = activeSource ? activeSource.rows : deckRows; - const effectiveSampleRow = activeSource ? (activeSource.rows[0] ?? {}) : sampleRow; + const activePreviewOption = useMemo(() => { + if (deckPreviewOptions.length === 0) return undefined; + return deckPreviewOptions.find((o) => o.id === previewSourceId) ?? deckPreviewOptions[0]; + }, [deckPreviewOptions, previewSourceId]); + const effectiveRows = activePreviewOption?.rows ?? []; + const effectiveSampleRow = effectiveRows[0] ?? {}; const filmstripRows = effectiveRows.length > 0 ? effectiveRows : [{}]; const [deckPreviewOpen, setDeckPreviewOpen] = useState(false); const deckDrawerId = useId(); - const dataSourceMenuRef = useRef(null); const zoomOut = useCallback((e: MouseEvent) => { const step = e.shiftKey ? ZOOM_STEP_COARSE : ZOOM_STEP_FINE; @@ -723,15 +757,18 @@ export const LayoutEditor = forwardRef(fu const bgColorInputRef = useRef(null); useEffect(() => { - if (!dataSourceMenuOpen) return; + if (!previewSourceMenuOpen) return; const onClick = (e: Event) => { - if (dataSourceMenuRef.current && !dataSourceMenuRef.current.contains(e.target as Node)) { - setDataSourceMenuOpen(false); + if ( + previewSourceMenuRef.current && + !previewSourceMenuRef.current.contains(e.target as Node) + ) { + setPreviewSourceMenuOpen(false); } }; document.addEventListener('mousedown', onClick); return () => document.removeEventListener('mousedown', onClick); - }, [dataSourceMenuOpen]); + }, [previewSourceMenuOpen]); const commitZoomInput = useCallback(() => { const raw = zoomInputDraft.trim(); @@ -1019,74 +1056,7 @@ export const LayoutEditor = forwardRef(fu aria-hidden={!deckPreviewOpen} >
-
- Deck preview - {dataSources.length > 0 && ( -
- - {dataSourceMenuOpen && ( -
    -
  • { - setSelectedDataSourceId(null); - setDataSourceMenuOpen(false); - }} - > - Project data - - {deckRows.length} rows - -
  • - {dataSources - .filter((s) => s.id !== '__project__') - .map((s) => ( -
  • { - setSelectedDataSourceId(s.id); - setDataSourceMenuOpen(false); - }} - > - {s.label} - - {s.rows.length} rows - -
  • - ))} -
- )} -
- )} - - {effectiveRows.length === 0 - ? 'No CSV rows — sample preview' - : `${effectiveRows.length} cards`} - -
-
+
{filmstripRows.slice(0, 48).map((row, i) => { const label = row.Name || @@ -1108,15 +1078,58 @@ export const LayoutEditor = forwardRef(fu
- setDeckPreviewOpen((o) => !o)} - > - - Deck preview - +
+ setDeckPreviewOpen((o) => !o)} + > + + Deck preview + +
+ setPreviewSourceMenuOpen((o) => !o)} + > + + + {activePreviewOption?.label ?? 'Preview'} + + + + {previewSourceMenuOpen && ( +
    + {deckPreviewOptions.map((opt) => ( +
  • { + setPreviewSourceId(opt.id); + setPreviewSourceMenuOpen(false); + }} + > + {opt.label} + + {opt.rows.length === 0 ? '—' : `${opt.rows.length} cards`} + +
  • + ))} +
+ )} +
+
csvData?.rows[0] ?? {}, [csvData]); - const editorDataSources = useMemo(() => { - const sources: { id: string; label: string; rows: Record[] }[] = []; + const deckPreviewOptions = useMemo((): DeckPreviewOption[] => { + const out: DeckPreviewOption[] = []; if (csvData && csvData.rows.length > 0) { - sources.push({ id: '__project__', label: 'Project data', rows: csvData.rows }); + out.push({ + id: '__project__', + label: 'Project dataset', + rows: csvData.rows, + layoutId: null, + }); } for (const g of cardGroups) { - if (g.csvData && g.csvData.rows.length > 0) { - sources.push({ id: g.id, label: g.name, rows: g.csvData.rows }); - } + out.push({ + id: g.id, + label: g.name, + rows: g.csvData?.rows ?? [], + layoutId: g.layoutId, + }); + } + if (out.length === 0) { + out.push({ id: '__sample__', label: 'Sample', rows: [], layoutId: null }); } - return sources; + return out; }, [csvData, cardGroups]); const loadPipeline = useCallback(async () => { @@ -109,9 +125,21 @@ export function ProjectPage() { if (!token || !id) return; try { const res = await apiJson<{ - cardGroups: { id: string; name: string; csvData: CsvData | null }[]; + cardGroups: { + id: string; + name: string; + csvData: CsvData | null; + layoutId: string | null; + }[]; }>(`/api/projects/${id}/card-groups`, { token }); - setCardGroups(res.cardGroups.map((g) => ({ id: g.id, name: g.name, csvData: g.csvData }))); + setCardGroups( + res.cardGroups.map((g) => ({ + id: g.id, + name: g.name, + csvData: g.csvData, + layoutId: g.layoutId ?? null, + })) + ); } catch { // non-critical for layout editor; card groups just won't appear as data sources } @@ -157,6 +185,12 @@ export function ProjectPage() { return undefined; }, [tab]); + /** Card groups are edited on the Cards tab; keep layout editor preview options in sync. */ + useEffect(() => { + if (tab !== 'layout' || !token || !id) return; + void loadCardGroups(); + }, [tab, token, id, loadCardGroups]); + const saveLayout = useCallback(async (): Promise => { if (!token || !id || !activeLayoutId) return false; setBusy(true); @@ -718,8 +752,8 @@ export function ProjectPage() { onChange={setEditorState} assetUrls={assetUrls} sampleRow={sampleRow} - deckRows={csvData?.rows ?? []} - dataSources={editorDataSources} + deckPreviewOptions={deckPreviewOptions} + activeLayoutId={activeLayoutId ?? undefined} onCapabilitiesChange={setEditorCaps} /> )} From 4383e84f2d98b472ad48dd514807e3af11ae4b38 Mon Sep 17 00:00:00 2001 From: Will Zakielarz Date: Mon, 13 Apr 2026 14:41:20 -0400 Subject: [PATCH 3/8] Delete key remove --- frontend/src/App.css | 10 -------- frontend/src/components/LayoutEditor.tsx | 32 +++++++++++++----------- 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index cfe7e07..90440b1 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -2606,16 +2606,6 @@ label.layout-editor-footer-value-strip { white-space: nowrap; } -.layout-editor-footer-preview-chevron { - flex-shrink: 0; - opacity: 0.6; - transition: transform 0.18s ease; -} - -.layout-editor-footer-preview-chevron--open { - transform: rotate(180deg); -} - .deck-preview-source-popover { position: absolute; bottom: calc(100% + 6px); diff --git a/frontend/src/components/LayoutEditor.tsx b/frontend/src/components/LayoutEditor.tsx index 223f072..5e25766 100644 --- a/frontend/src/components/LayoutEditor.tsx +++ b/frontend/src/components/LayoutEditor.tsx @@ -22,7 +22,7 @@ import { Text, Transformer, } from 'react-konva'; -import { ChevronDown, Layers, Minus, Plus, Table2 } from 'lucide-react'; +import { Layers, Minus, Plus, Table2 } from 'lucide-react'; import type { LayoutElement, LayoutStateV2 } from '../types/layout'; import { DEFAULT_NEW_TEXT } from '../types/layout'; import { applyTemplate } from '../lib/template'; @@ -491,6 +491,12 @@ export const LayoutEditor = forwardRef(fu setZoomPercent((p) => clampZoomPercent(p - ZOOM_STEP_FINE)); }, []); + const deleteSelected = useCallback(() => { + if (!selectedId) return; + commit({ ...state, root: removeNodeById(state.root, selectedId) }); + setSelectedId(null); + }, [selectedId, state, commit]); + useImperativeHandle( ref, () => ({ @@ -529,6 +535,14 @@ export const LayoutEditor = forwardRef(fu const onKey = (e: globalThis.KeyboardEvent) => { const el = e.target as HTMLElement | null; if (el?.closest('input, textarea, select, [contenteditable="true"]')) return; + + if (e.code === 'Delete' || e.code === 'Backspace') { + if (!selectedId) return; + e.preventDefault(); + deleteSelected(); + return; + } + const meta = e.metaKey || e.ctrlKey; if (!meta) return; @@ -560,7 +574,7 @@ export const LayoutEditor = forwardRef(fu }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [undo, redo, zoomToFit, zoomTo100Percent, zoomInFromMenu, zoomOutFromMenu]); + }, [undo, redo, zoomToFit, zoomTo100Percent, zoomInFromMenu, zoomOutFromMenu, selectedId, deleteSelected]); useLayoutEffect(() => { const el = canvasFillRef.current; @@ -715,11 +729,7 @@ export const LayoutEditor = forwardRef(fu commit({ ...state, root: insertAfterSiblingDeep(state.root, selectedId, dup) }); setSelectedId(dup.id); }, - onRemove: () => { - if (!selectedId) return; - commit({ ...state, root: removeNodeById(state.root, selectedId) }); - setSelectedId(null); - }, + onRemove: deleteSelected, onUndo: () => undo(), onRedo: () => redo(), onToggleGrid: () => commit({ ...state, showGrid: !showGrid }), @@ -728,7 +738,7 @@ export const LayoutEditor = forwardRef(fu showGrid, hasSelection: !!selectedId, }), - [state, selectedId, commit, undo, redo, canUndo, canRedo, showGrid] + [state, selectedId, commit, undo, redo, canUndo, canRedo, showGrid, deleteSelected] ); const activePreviewOption = useMemo(() => { @@ -1100,12 +1110,6 @@ export const LayoutEditor = forwardRef(fu {activePreviewOption?.label ?? 'Preview'} - {previewSourceMenuOpen && (
    From 115ec1884a60bd18077fbd2d5cb4e1829aeb58cc Mon Sep 17 00:00:00 2001 From: Will Zakielarz Date: Mon, 13 Apr 2026 14:48:45 -0400 Subject: [PATCH 4/8] Standardize nav bar --- frontend/src/App.css | 174 +++++++++++------------ frontend/src/components/StudioAppBar.tsx | 114 ++++++++++----- frontend/src/pages/DashboardPage.tsx | 4 +- 3 files changed, 166 insertions(+), 126 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index 90440b1..536cf37 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -92,6 +92,10 @@ body.layout-editor-open .main.main-wide { margin-bottom: 16px; } +.dashboard-page-toolbar { + margin-bottom: 20px; +} + .inline-form { display: flex; gap: 8px; @@ -1901,6 +1905,8 @@ button.zone-row-body:hover { /* —— Studio shell / nav —— */ .studio-shell-header { + --studio-header-h: 56px; + --studio-header-pad-x: 20px; flex-shrink: 0; background: #0b0b0b; border-bottom: 1px solid #161616; @@ -1912,11 +1918,18 @@ button.zone-row-body:hover { flex-wrap: wrap; align-items: center; gap: 12px 16px; - padding: 0 20px; - min-height: 52px; + padding: 0 var(--studio-header-pad-x, 20px); box-sizing: border-box; } +.studio-shell-row--primary { + min-height: var(--studio-header-h, 56px); + height: var(--studio-header-h, 56px); + flex-wrap: nowrap; + gap: 12px; + align-items: center; +} + .studio-shell-row--project { justify-content: space-between; } @@ -1924,9 +1937,6 @@ button.zone-row-body:hover { .studio-shell-row--editor-main { justify-content: space-between; align-items: center; - min-height: 40px; - padding-top: 4px; - padding-bottom: 3px; } .studio-shell-row--editor-menus { @@ -1935,6 +1945,8 @@ button.zone-row-body:hover { padding-bottom: 6px; align-items: center; border-top: 1px solid rgba(255, 255, 255, 0.04); + padding-left: var(--studio-header-pad-x, 20px); + padding-right: var(--studio-header-pad-x, 20px); } .studio-shell-fill { @@ -1942,36 +1954,19 @@ button.zone-row-body:hover { min-width: 8px; } -.studio-shell-brand { +.studio-shell-logo-link { display: inline-flex; align-items: center; - gap: 10px; + justify-content: center; + flex-shrink: 0; + line-height: 0; text-decoration: none; - color: var(--text-h); - font-weight: 600; - font-size: 15px; - letter-spacing: -0.02em; -} - -.studio-shell-brand:hover { color: var(--accent); + border-radius: 6px; } -.studio-shell-brand--compact { - font-weight: 500; -} - -.studio-shell-brand--mark-only { - gap: 0; - line-height: 0; - padding: 4px 0; -} - -.studio-shell-header--dash .studio-shell-row { - min-height: 56px; - padding-left: 14px; - padding-right: 20px; - align-items: center; +.studio-shell-logo-link:hover { + color: #86efac; } .studio-shell-logo { @@ -1979,7 +1974,7 @@ button.zone-row-body:hover { align-items: center; justify-content: center; flex-shrink: 0; - color: var(--accent); + color: inherit; } .brand-logo-mark svg { @@ -1991,29 +1986,80 @@ button.zone-row-body:hover { fill: currentColor; } -.studio-shell-left { +.studio-breadcrumb { display: flex; align-items: center; - gap: 12px; + gap: 8px; min-width: 0; + flex: 1; } -.studio-shell-project-name { - font-size: 16px; - font-weight: 700; - color: #fafafa; - letter-spacing: -0.02em; +.studio-breadcrumb--project { + flex: 0 1 auto; + max-width: min(46vw, 440px); +} + +.studio-breadcrumb--editor { + flex: 1; + min-width: 0; +} + +.studio-bc-sep { + flex-shrink: 0; + color: rgba(161, 161, 170, 0.55); +} + +.studio-bc-text { + font-size: 14px; + font-weight: 500; + color: rgba(228, 228, 231, 0.92); + text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - max-width: min(40vw, 320px); + max-width: min(28vw, 220px); + letter-spacing: -0.01em; +} + +a.studio-bc-text:hover { + color: var(--accent); +} + +.studio-bc-text--current { + font-weight: 600; + color: #fafafa; + cursor: default; } -.studio-shell-project-name--muted { +.studio-bc-text--current:hover { + color: #fafafa; +} + +.studio-bc-text--muted { font-weight: 500; color: var(--text); } +.studio-bc-input { + min-width: 100px; + max-width: min(24vw, 200px); + padding: 4px 8px; + border-radius: 6px; + border: 1px solid transparent; + background: rgba(255, 255, 255, 0.04); + color: var(--text-h); + font: inherit; + font-size: 14px; + font-weight: 600; + letter-spacing: -0.01em; +} + +.studio-bc-input:hover, +.studio-bc-input:focus { + border-color: var(--border); + outline: none; +} + .studio-shell-right { flex-shrink: 0; } @@ -2161,55 +2207,7 @@ button.zone-row-body:hover { color: #6ee7b7; } -/* Editor chrome */ -.editor-breadcrumb { - display: flex; - align-items: center; - gap: 6px; - min-width: 0; - flex: 1; -} - -.editor-bc-sep { - flex-shrink: 0; - opacity: 0.35; - color: var(--text); -} - -.editor-bc-link { - font-size: 14px; - font-weight: 600; - color: var(--text-h); - text-decoration: none; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: min(28vw, 200px); -} - -.editor-bc-link:hover { - color: var(--accent); -} - -.editor-bc-input { - min-width: 120px; - max-width: 200px; - padding: 4px 8px; - border-radius: 6px; - border: 1px solid transparent; - background: rgba(255, 255, 255, 0.04); - color: var(--text-h); - font: inherit; - font-size: 14px; - font-weight: 500; -} - -.editor-bc-input:hover, -.editor-bc-input:focus { - border-color: var(--border); - outline: none; -} - +/* Editor chrome (breadcrumb uses .studio-breadcrumb / .studio-bc-*) */ .editor-bar-status-save { display: flex; align-items: center; diff --git a/frontend/src/components/StudioAppBar.tsx b/frontend/src/components/StudioAppBar.tsx index 5469091..ee55cd1 100644 --- a/frontend/src/components/StudioAppBar.tsx +++ b/frontend/src/components/StudioAppBar.tsx @@ -15,6 +15,21 @@ import { } from './ui/dropdown-menu'; import { BrandLogo } from './BrandLogo'; +/** Global shell: one logo size + chevron size for all breadcrumbs */ +const STUDIO_HEADER_LOGO_PX = 28; +const STUDIO_BC_CHEVRON_PX = 14; + +function BreadcrumbChevron() { + return ( + + ); +} + function AppMenu({ label, children, @@ -140,18 +155,24 @@ function DashboardBar() { const { layoutEditor } = useStudioChrome(); return ( -
    -
    - layoutEditor?.onNavigateHomeClick(e)} - aria-label="CardGoose home" - > - - +
    +
    +
    @@ -178,21 +199,32 @@ function ProjectTabsBar() { ]; return ( -
    -
    -
    +
    +
    +
    + + onNavigateHomeClick(e)}> + Projects + + + + {projectName} + +
    @@ -297,36 +334,41 @@ function EditorBar() { return (
    -
    -
    +
    +
    +
    diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 1748d96..51204d7 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -54,8 +54,8 @@ export function DashboardPage() { return (
    -
    -

    Projects

    +

    Projects

    +
    Date: Mon, 13 Apr 2026 16:18:40 -0400 Subject: [PATCH 5/8] Assets page overhaul --- .../migration.sql | 20 + api/prisma/schema.prisma | 19 +- api/src/lib/assetResolve.test.ts | 22 + api/src/lib/assetResolve.ts | 25 ++ api/src/lib/pdfExportPayload.test.ts | 1 + api/src/lib/pdfExportPayload.ts | 22 +- api/src/lib/s3.ts | 17 + api/src/routes/assets.test.ts | 4 + api/src/routes/assets.ts | 172 +++++++- api/src/routes/exports.test.ts | 1 + api/src/test/prisma-mock.ts | 27 +- frontend/src/App.css | 300 ++++++++++++- frontend/src/components/AssetsTabPanel.tsx | 410 ++++++++++++++++++ frontend/src/components/CardFace.tsx | 7 +- frontend/src/components/CardGroupsPanel.tsx | 24 +- frontend/src/components/ExportTabPanel.tsx | 98 +++++ frontend/src/components/LayoutEditor.tsx | 7 +- frontend/src/components/StudioAppBar.tsx | 6 +- frontend/src/contexts/studioChromeTypes.ts | 2 +- frontend/src/lib/assetResolve.ts | 40 ++ frontend/src/lib/layoutArtKeys.ts | 26 ++ frontend/src/pages/ProjectPage.tsx | 406 ++++------------- 22 files changed, 1259 insertions(+), 397 deletions(-) create mode 100644 api/prisma/migrations/20260409183000_global_assets/migration.sql create mode 100644 api/src/lib/assetResolve.test.ts create mode 100644 api/src/lib/assetResolve.ts create mode 100644 frontend/src/components/AssetsTabPanel.tsx create mode 100644 frontend/src/components/ExportTabPanel.tsx create mode 100644 frontend/src/lib/assetResolve.ts create mode 100644 frontend/src/lib/layoutArtKeys.ts diff --git a/api/prisma/migrations/20260409183000_global_assets/migration.sql b/api/prisma/migrations/20260409183000_global_assets/migration.sql new file mode 100644 index 0000000..a294a78 --- /dev/null +++ b/api/prisma/migrations/20260409183000_global_assets/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "global_assets" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "art_key" TEXT NOT NULL, + "s3_key" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "global_assets_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "global_assets_user_id_art_key_key" ON "global_assets"("user_id", "art_key"); + +-- CreateIndex +CREATE INDEX "global_assets_user_id_idx" ON "global_assets"("user_id"); + +-- AddForeignKey +ALTER TABLE "global_assets" ADD CONSTRAINT "global_assets_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index a149230..c0ef589 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -15,7 +15,24 @@ model User { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - projects Project[] + projects Project[] + globalAssets GlobalAsset[] +} + +/// User-wide images; project assets with the same normalized art key shadow these. +model GlobalAsset { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + artKey String @map("art_key") + s3Key String @map("s3_key") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, artKey]) + @@index([userId]) + @@map("global_assets") } /// Optional CSV dataset: { "headers": string[], "rows": Record[] } diff --git a/api/src/lib/assetResolve.test.ts b/api/src/lib/assetResolve.test.ts new file mode 100644 index 0000000..f6f557d --- /dev/null +++ b/api/src/lib/assetResolve.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { mergeAssetS3KeysByNormalizedKey, normalizeArtLookupKey } from './assetResolve.js'; + +describe('normalizeArtLookupKey', () => { + it('lowercases and strips extension', () => { + expect(normalizeArtLookupKey('Hero.PNG')).toBe('hero'); + }); + + it('strips mustache tokens', () => { + expect(normalizeArtLookupKey('{{ Gold }}')).toBe('gold'); + }); +}); + +describe('mergeAssetS3KeysByNormalizedKey', () => { + it('project shadows global', () => { + const m = mergeAssetS3KeysByNormalizedKey( + [{ artKey: 'hero', s3Key: 'p/hero' }], + [{ artKey: 'Hero', s3Key: 'g/hero' }] + ); + expect(m.get('hero')).toBe('p/hero'); + }); +}); diff --git a/api/src/lib/assetResolve.ts b/api/src/lib/assetResolve.ts new file mode 100644 index 0000000..a481f5e --- /dev/null +++ b/api/src/lib/assetResolve.ts @@ -0,0 +1,25 @@ +/** Normalize for case-insensitive matching; strips common image extensions. */ +export function normalizeArtLookupKey(raw: string): string { + let s = raw.trim().toLowerCase(); + s = s.replace(/\{\{|\}\}/g, '').trim(); + s = s.replace(/\.(png|jpe?g|gif|webp|svg|bmp)$/i, ''); + return s; +} + +export type AssetKeyRow = { artKey: string; s3Key: string }; + +/** Build map normalizedKey -> s3Key (project rows win over global when both match). */ +export function mergeAssetS3KeysByNormalizedKey( + projectAssets: AssetKeyRow[], + globalAssets: AssetKeyRow[] +): Map { + const m = new Map(); + for (const a of globalAssets) { + const k = normalizeArtLookupKey(a.artKey); + if (!m.has(k)) m.set(k, a.s3Key); + } + for (const a of projectAssets) { + m.set(normalizeArtLookupKey(a.artKey), a.s3Key); + } + return m; +} diff --git a/api/src/lib/pdfExportPayload.test.ts b/api/src/lib/pdfExportPayload.test.ts index 68e1fb2..388ee41 100644 --- a/api/src/lib/pdfExportPayload.test.ts +++ b/api/src/lib/pdfExportPayload.test.ts @@ -91,6 +91,7 @@ describe('buildPdfExportPayload', () => { }, ]); prisma.asset.findMany.mockResolvedValueOnce([{ artKey: 'art1', s3Key: 'k1' }]); + prisma.globalAsset.findMany.mockResolvedValueOnce([]); const r = await buildPdfExportPayload('p', 'u', { dpi: 200 }); if ('error' in r) throw new Error(String(r.error)); diff --git a/api/src/lib/pdfExportPayload.ts b/api/src/lib/pdfExportPayload.ts index 939dd22..f9d4942 100644 --- a/api/src/lib/pdfExportPayload.ts +++ b/api/src/lib/pdfExportPayload.ts @@ -1,3 +1,4 @@ +import { mergeAssetS3KeysByNormalizedKey, normalizeArtLookupKey } from './assetResolve.js'; import { prisma } from './prisma.js'; import { collectArtKeysFromLayoutState } from './layoutArtKeys.js'; import { getAssetsBucket, getSignedGetUrl } from './s3.js'; @@ -80,15 +81,24 @@ export async function buildPdfExportPayload( for (const k of collectArtKeysFromLayoutState(eg.layout)) artKeys.add(k); } - const assets = await prisma.asset.findMany({ - where: { projectId, artKey: { in: [...artKeys] } }, - select: { artKey: true, s3Key: true }, - }); + const [projectAssets, globalAssets] = await Promise.all([ + prisma.asset.findMany({ + where: { projectId }, + select: { artKey: true, s3Key: true }, + }), + prisma.globalAsset.findMany({ + where: { userId }, + select: { artKey: true, s3Key: true }, + }), + ]); + const merged = mergeAssetS3KeysByNormalizedKey(projectAssets, globalAssets); const assetsBucket = getAssetsBucket(); const assetUrls: Record = {}; - for (const a of assets) { - assetUrls[a.artKey] = await getSignedGetUrl(assetsBucket, a.s3Key, 3600); + for (const rk of artKeys) { + const sk = merged.get(normalizeArtLookupKey(rk)); + if (!sk) continue; + assetUrls[rk.trim()] = await getSignedGetUrl(assetsBucket, sk, 3600); } const timestamp = new Date().toISOString(); diff --git a/api/src/lib/s3.ts b/api/src/lib/s3.ts index 5e74b45..a792096 100644 --- a/api/src/lib/s3.ts +++ b/api/src/lib/s3.ts @@ -1,4 +1,5 @@ import { + CopyObjectCommand, CreateBucketCommand, GetObjectCommand, HeadBucketCommand, @@ -80,6 +81,22 @@ export async function putObject( ); } +/** Server-side copy within the same bucket (e.g. promote project asset → global). */ +export async function copyObjectSameBucket( + bucket: string, + sourceKey: string, + destKey: string +): Promise { + const copySource = `${bucket}/${sourceKey.split('/').map(encodeURIComponent).join('/')}`; + await s3Client.send( + new CopyObjectCommand({ + Bucket: bucket, + CopySource: copySource, + Key: destKey, + }) + ); +} + export async function listObjectKeys(bucket: string, prefix: string): Promise { const keys: string[] = []; let continuationToken: string | undefined; diff --git a/api/src/routes/assets.test.ts b/api/src/routes/assets.test.ts index 027f368..e393438 100644 --- a/api/src/routes/assets.test.ts +++ b/api/src/routes/assets.test.ts @@ -16,6 +16,7 @@ vi.mock('../lib/s3.js', () => ({ getAssetsBucket: () => 'assets-bucket', putObject: s3Mocks.putObject, getSignedGetUrl: s3Mocks.getSignedGetUrl, + copyObjectSameBucket: vi.fn(async () => {}), })); import { prisma } from '../test/prisma-mock.js'; @@ -33,6 +34,8 @@ describe('assets routes', () => { beforeEach(() => { s3Mocks.putObject.mockClear(); s3Mocks.putObject.mockResolvedValue(undefined); + prisma.globalAsset.findMany.mockReset(); + prisma.globalAsset.findMany.mockResolvedValue([]); }); it('404 when project missing', async () => { @@ -49,6 +52,7 @@ describe('assets routes', () => { const res = await request(app).get('/api/projects/p1/assets').set(authed()); expect(res.status).toBe(200); expect(res.body.assets[0].url).toBeUndefined(); + expect(Array.isArray(res.body.globalAssets)).toBe(true); }); it('includes signed URLs when requested', async () => { diff --git a/api/src/routes/assets.ts b/api/src/routes/assets.ts index 7e55eea..5626654 100644 --- a/api/src/routes/assets.ts +++ b/api/src/routes/assets.ts @@ -2,7 +2,12 @@ import { Router, type IRouter } from 'express'; import multer from 'multer'; import { prisma } from '../lib/prisma.js'; import { requireAuth } from '../middleware/auth.js'; -import { getAssetsBucket, getSignedGetUrl, putObject } from '../lib/s3.js'; +import { + copyObjectSameBucket, + getAssetsBucket, + getSignedGetUrl, + putObject, +} from '../lib/s3.js'; export const assetsRouter: IRouter = Router(); assetsRouter.use(requireAuth); @@ -12,6 +17,22 @@ const upload = multer({ limits: { fileSize: 25 * 1024 * 1024 }, }); +/** Canonical art key stored in DB (lowercase slug, no extension). */ +export function normalizeStoredArtKey(raw: string): string { + let s = raw.trim().toLowerCase(); + s = s.replace(/\.(png|jpe?g|gif|webp|svg|bmp)$/i, ''); + s = s.replace(/[^a-z0-9_-]+/g, '_').replace(/^_+|_+$/g, ''); + return s || 'asset'; +} + +function artKeyFromUpload(bodyArtKey: unknown, originalname: string): string { + if (typeof bodyArtKey === 'string' && bodyArtKey.trim()) { + return normalizeStoredArtKey(bodyArtKey); + } + const base = originalname.replace(/\.[^.]+$/, ''); + return normalizeStoredArtKey(base || originalname); +} + assetsRouter.get('/projects/:projectId/assets', async (req, res) => { const userId = req.user!.id; const projectId = String(req.params.projectId); @@ -22,25 +43,35 @@ assetsRouter.get('/projects/:projectId/assets', async (req, res) => { return; } - const assets = await prisma.asset.findMany({ - where: { projectId }, - orderBy: { createdAt: 'desc' }, - select: { id: true, artKey: true, s3Key: true, createdAt: true, updatedAt: true }, - }); + const [assets, globalAssets] = await Promise.all([ + prisma.asset.findMany({ + where: { projectId }, + orderBy: { createdAt: 'desc' }, + select: { id: true, artKey: true, s3Key: true, createdAt: true, updatedAt: true }, + }), + prisma.globalAsset.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + select: { id: true, artKey: true, s3Key: true, createdAt: true, updatedAt: true }, + }), + ]); + const includeUrls = String(req.query.includeUrls) === '1' || String(req.query.includeUrls) === 'true'; if (!includeUrls) { - res.json({ assets }); + res.json({ assets, globalAssets }); return; } const bucket = getAssetsBucket(); - const withUrls = await Promise.all( - assets.map(async (a) => ({ - ...a, - url: await getSignedGetUrl(bucket, a.s3Key), - })) - ); - res.json({ assets: withUrls }); + const signRow = async (row: T) => ({ + ...row, + url: await getSignedGetUrl(bucket, row.s3Key), + }); + const [withProjectUrls, withGlobalUrls] = await Promise.all([ + Promise.all(assets.map(signRow)), + Promise.all(globalAssets.map(signRow)), + ]); + res.json({ assets: withProjectUrls, globalAssets: withGlobalUrls }); }); assetsRouter.post('/projects/:projectId/assets', upload.single('file'), async (req, res) => { @@ -60,10 +91,7 @@ assetsRouter.post('/projects/:projectId/assets', upload.single('file'), async (r } const bodyArtKey = (req.body as { artKey?: string }).artKey; - const artKey = - typeof bodyArtKey === 'string' && bodyArtKey.trim() - ? bodyArtKey.trim() - : file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_') || 'upload'; + const artKey = artKeyFromUpload(bodyArtKey, file.originalname); const bucket = getAssetsBucket(); const s3Key = `${projectId}/${artKey}`; @@ -91,3 +119,111 @@ assetsRouter.post('/projects/:projectId/assets', upload.single('file'), async (r res.status(201).json({ asset }); }); + +/** Upload into the signed-in user's global library. */ +assetsRouter.post('/user/global-assets', upload.single('file'), async (req, res) => { + const userId = req.user!.id; + const file = req.file; + if (!file) { + res.status(400).json({ error: 'file field is required' }); + return; + } + + const bodyArtKey = (req.body as { artKey?: string }).artKey; + const artKey = artKeyFromUpload(bodyArtKey, file.originalname); + + const bucket = getAssetsBucket(); + const s3Key = `global/${userId}/${artKey}`; + + await putObject(bucket, s3Key, file.buffer, file.mimetype || 'application/octet-stream'); + + const row = await prisma.globalAsset.upsert({ + where: { userId_artKey: { userId, artKey } }, + create: { userId, artKey, s3Key }, + update: { s3Key }, + select: { id: true, artKey: true, s3Key: true, createdAt: true, updatedAt: true }, + }); + + res.status(201).json({ asset: row }); +}); + +/** Copy S3 object to global library and remove the project row (content moves to global). */ +assetsRouter.post('/projects/:projectId/assets/:assetId/promote-global', async (req, res) => { + const userId = req.user!.id; + const projectId = String(req.params.projectId); + const assetId = String(req.params.assetId); + + const project = await prisma.project.findFirst({ where: { id: projectId, userId } }); + if (!project) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + const existing = await prisma.asset.findFirst({ + where: { id: assetId, projectId }, + }); + if (!existing) { + res.status(404).json({ error: 'Asset not found' }); + return; + } + + const bucket = getAssetsBucket(); + const destKey = `global/${userId}/${normalizeStoredArtKey(existing.artKey)}`; + + await copyObjectSameBucket(bucket, existing.s3Key, destKey); + + const global = await prisma.$transaction(async (tx) => { + await tx.asset.delete({ where: { id: existing.id } }); + return tx.globalAsset.upsert({ + where: { userId_artKey: { userId, artKey: normalizeStoredArtKey(existing.artKey) } }, + create: { + userId, + artKey: normalizeStoredArtKey(existing.artKey), + s3Key: destKey, + }, + update: { s3Key: destKey }, + select: { id: true, artKey: true, s3Key: true, createdAt: true, updatedAt: true }, + }); + }); + + res.json({ asset: global }); +}); + +assetsRouter.delete('/projects/:projectId/assets/:assetId', async (req, res) => { + const userId = req.user!.id; + const projectId = String(req.params.projectId); + const assetId = String(req.params.assetId); + + const project = await prisma.project.findFirst({ where: { id: projectId, userId } }); + if (!project) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + const existing = await prisma.asset.findFirst({ + where: { id: assetId, projectId }, + }); + if (!existing) { + res.status(404).json({ error: 'Asset not found' }); + return; + } + + await prisma.asset.delete({ where: { id: existing.id } }); + res.status(204).end(); +}); + +assetsRouter.delete('/user/global-assets/:assetId', async (req, res) => { + const userId = req.user!.id; + const assetId = String(req.params.assetId); + + const existing = await prisma.globalAsset.findFirst({ + where: { id: assetId, userId }, + }); + if (!existing) { + res.status(404).json({ error: 'Asset not found' }); + return; + } + + await prisma.globalAsset.delete({ where: { id: existing.id } }); + res.status(204).end(); +}); diff --git a/api/src/routes/exports.test.ts b/api/src/routes/exports.test.ts index 7c4ec40..26206e6 100644 --- a/api/src/routes/exports.test.ts +++ b/api/src/routes/exports.test.ts @@ -50,6 +50,7 @@ function authed() { describe('exports routes', () => { beforeEach(() => { vi.clearAllMocks(); + prisma.globalAsset.findMany.mockResolvedValue([]); }); it('POST /export enqueues', async () => { diff --git a/api/src/test/prisma-mock.ts b/api/src/test/prisma-mock.ts index a75bc91..b76426c 100644 --- a/api/src/test/prisma-mock.ts +++ b/api/src/test/prisma-mock.ts @@ -1,7 +1,8 @@ import { vi } from 'vitest'; -function createMock() { - return { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function createMock(): any { + const client: Record = { user: { findUnique: vi.fn(), create: vi.fn(), @@ -30,13 +31,27 @@ function createMock() { }, asset: { findMany: vi.fn(), + findFirst: vi.fn(), upsert: vi.fn(), + delete: vi.fn(), + }, + globalAsset: { + findMany: vi.fn(), + findFirst: vi.fn(), + upsert: vi.fn(), + delete: vi.fn(), }, - $transaction: vi.fn((ops: unknown) => { - if (Array.isArray(ops)) return Promise.all(ops as Promise[]); - return Promise.resolve(undefined); - }), }; + + client.$transaction = vi.fn(async (ops: unknown) => { + if (typeof ops === 'function') { + return (ops as (tx: typeof client) => Promise)(client); + } + if (Array.isArray(ops)) return Promise.all(ops as Promise[]); + return Promise.resolve(undefined); + }); + + return client; } export const prisma = createMock(); diff --git a/frontend/src/App.css b/frontend/src/App.css index 536cf37..817c501 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -80,7 +80,8 @@ body.layout-editor-open .main.main-wide { /* Cards tab: sit closer to the studio nav */ .main-wide:has(.cards-tab-section), -.main-wide:has(.layouts-tab-section) { +.main-wide:has(.layouts-tab-section), +.main-wide:has(.assets-dam) { padding-top: 10px; } @@ -584,7 +585,7 @@ button[type='submit']:not(.auth-primary), .card-group-meta-chip ):not(.card-group-icon-btn):not(.card-group-add-slot):not(.card-group-title-hit):not( .card-group-url-drawer-save - ):not(.card-group-url-drawer-cancel):not(.card-group-url-drawer-shortcut):not( + ):not(.card-group-url-drawer-cancel):not( .layout-list-row-hit ) { padding: 10px 14px; @@ -1815,17 +1816,6 @@ button.zone-row-body:hover { text-align: left; } -.card-group-url-drawer-shortcut { - align-self: flex-start; - padding: 4px 10px; - border-radius: 6px; - border: 1px solid rgba(16, 185, 129, 0.35); - background: rgba(16, 185, 129, 0.08); - color: #a7f3d0; - font-size: 12px; - cursor: pointer; -} - .card-group-url-drawer-actions { display: flex; flex-wrap: wrap; @@ -2897,6 +2887,290 @@ button.zone-tool--icon:hover:not(:disabled) { color: var(--accent); } +/* —— Project dataset (Cards tab) —— */ +.project-dataset-panel { + margin-bottom: 8px; +} + +.project-dataset-title { + margin-top: 0; +} + +/* —— Assets DAM tab —— */ +.assets-dam { + display: grid; + grid-template-columns: 220px minmax(0, 1fr) 280px; + gap: 0; + min-height: min(72vh, 640px); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; + background: var(--panel); +} + +.assets-dam-sidebar { + border-right: 1px solid var(--border); + padding: 12px 10px; + display: flex; + flex-direction: column; + gap: 4px; + background: rgba(0, 0, 0, 0.15); +} + +.assets-dam-sidebar-head { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text); + opacity: 0.75; + margin-bottom: 6px; +} + +.assets-dam-tree-item { + text-align: left; + width: 100%; + padding: 8px 10px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-h); + font: inherit; + font-size: 13px; + cursor: pointer; +} + +.assets-dam-tree-item:hover { + background: rgba(255, 255, 255, 0.05); +} + +.assets-dam-tree-item--active { + background: rgba(16, 185, 129, 0.12); + color: #a7f3d0; +} + +.assets-dam-tree-muted { + font-size: 11px; + color: var(--text); + opacity: 0.55; + padding: 4px 10px 0; +} + +.assets-dam-sidebar-actions { + margin-top: auto; + padding-top: 12px; + display: flex; + flex-direction: column; + gap: 8px; + font-size: 12px; +} + +.assets-dam-toggle { + display: flex; + align-items: center; + gap: 8px; + color: var(--text); + cursor: pointer; +} + +.assets-dam-gallery { + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} + +.assets-dam-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + justify-content: space-between; +} + +.assets-dam-toolbar-right { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.assets-dam-search { + flex: 1; + min-width: 160px; + max-width: 420px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.2); + color: var(--text-h); + font: inherit; + font-size: 13px; +} + +.assets-dam-chip { + padding: 4px 10px; + border-radius: 999px; + border: 1px solid var(--border); + background: transparent; + color: var(--text); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.assets-dam-chip--on { + border-color: var(--accent); + color: #a7f3d0; + background: rgba(16, 185, 129, 0.1); +} + +.assets-dam-drop-hint { + margin: 0; + font-size: 12px; +} + +.assets-dam-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(108px, 1fr)); + gap: 10px; + align-content: start; +} + +.assets-dam-cell { + display: flex; + flex-direction: column; + gap: 6px; + padding: 0; + border: 1px solid var(--border); + border-radius: 8px; + background: rgba(0, 0, 0, 0.2); + cursor: pointer; + text-align: left; + overflow: hidden; +} + +.assets-dam-cell--selected { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.assets-dam-thumb { + width: 100%; + background: #121218; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.assets-dam-thumb--square { + aspect-ratio: 1 / 1; +} + +.assets-dam-thumb--card { + aspect-ratio: 63 / 88; +} + +.assets-dam-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.assets-dam-cell-label { + font-size: 11px; + padding: 0 8px 8px; + color: var(--text-h); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.assets-dam-inspector { + border-left: 1px solid var(--border); + padding: 12px 12px 16px; + display: flex; + flex-direction: column; + gap: 10px; + background: rgba(0, 0, 0, 0.12); + min-width: 0; +} + +.assets-dam-inspector-head { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text); + opacity: 0.75; +} + +.assets-dam-preview { + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--border); + background: #0f0f12; + min-height: 120px; + display: flex; + align-items: center; + justify-content: center; +} + +.assets-dam-preview-img { + width: 100%; + max-height: 200px; + object-fit: contain; +} + +.assets-dam-promote { + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--accent); + background: rgba(16, 185, 129, 0.12); + color: #a7f3d0; + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.assets-dam-promote:hover:not(:disabled) { + background: rgba(16, 185, 129, 0.2); +} + +.assets-dam-usage-title { + margin: 8px 0 0; + font-size: 12px; + font-weight: 600; + color: var(--text-h); +} + +.assets-dam-usage-list { + margin: 0; + padding-left: 18px; + font-size: 13px; + color: var(--text); +} + +.mono.small { + font-size: 11px; + word-break: break-all; +} + +@media (max-width: 960px) { + .assets-dam { + grid-template-columns: 1fr; + grid-template-rows: auto auto auto; + } + + .assets-dam-sidebar, + .assets-dam-inspector { + border: none; + border-bottom: 1px solid var(--border); + } +} + /* —— Global toasts (bottom of viewport) —— */ .toast-viewport { position: fixed; diff --git a/frontend/src/components/AssetsTabPanel.tsx b/frontend/src/components/AssetsTabPanel.tsx new file mode 100644 index 0000000..e20893d --- /dev/null +++ b/frontend/src/components/AssetsTabPanel.tsx @@ -0,0 +1,410 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { apiBase } from '../lib/api'; +import { normalizeArtLookupKey } from '../lib/assetResolve'; +import { collectArtKeysFromLayoutState } from '../lib/layoutArtKeys'; + +export type StudioAssetRow = { + id: string; + artKey: string; + s3Key: string; + url?: string; +}; + +type LayoutLite = { id: string; name: string; state: unknown }; + +type Props = { + projectId: string; + token: string | null; + busy: boolean; + projectAssets: StudioAssetRow[]; + globalAssets: StudioAssetRow[]; + layoutsFull: LayoutLite[]; + onRefresh: () => void; + onError: (msg: string) => void; +}; + +type Scope = 'project' | 'global'; + +const GLOBAL_VISIBILITY_KEY = 'cardgoose.assets.showGlobal'; + +export function AssetsTabPanel({ + projectId, + token, + busy, + projectAssets, + globalAssets, + layoutsFull, + onRefresh, + onError, +}: Props) { + const [search, setSearch] = useState(''); + const [scope, setScope] = useState('project'); + const [selection, setSelection] = useState>(new Set()); + const [lastClicked, setLastClicked] = useState(null); + const [thumbRatio, setThumbRatio] = useState<'square' | 'card'>('square'); + const [showGlobal, setShowGlobal] = useState(() => { + try { + return localStorage.getItem(GLOBAL_VISIBILITY_KEY) !== '0'; + } catch { + return true; + } + }); + + const setShowGlobalPersist = useCallback((v: boolean) => { + setShowGlobal(v); + try { + localStorage.setItem(GLOBAL_VISIBILITY_KEY, v ? '1' : '0'); + } catch { + /* ignore */ + } + }, []); + + useEffect(() => { + if (!showGlobal && scope === 'global') setScope('project'); + }, [showGlobal, scope]); + + const filter = useCallback( + (rows: StudioAssetRow[]) => { + const q = search.trim().toLowerCase(); + if (!q) return rows; + return rows.filter( + (r) => + r.artKey.toLowerCase().includes(q) || + r.s3Key.toLowerCase().includes(q) || + normalizeArtLookupKey(r.artKey).includes(q) + ); + }, + [search] + ); + + const visibleProject = useMemo(() => filter(projectAssets), [filter, projectAssets]); + const visibleGlobal = useMemo(() => filter(globalAssets), [filter, globalAssets]); + + const usedNormalizedKeys = useMemo(() => { + const s = new Set(); + for (const L of layoutsFull) { + for (const k of collectArtKeysFromLayoutState(L.state)) { + s.add(normalizeArtLookupKey(k)); + } + } + return s; + }, [layoutsFull]); + + const unusedProject = useMemo( + () => visibleProject.filter((a) => !usedNormalizedKeys.has(normalizeArtLookupKey(a.artKey))), + [visibleProject, usedNormalizedKeys] + ); + + const [projectFolder, setProjectFolder] = useState<'all' | 'unused'>('all'); + + const gridItems = + scope === 'project' + ? projectFolder === 'unused' + ? unusedProject + : visibleProject + : visibleGlobal; + + const toggleSelect = (asset: StudioAssetRow, e: React.MouseEvent, list: StudioAssetRow[]) => { + e.preventDefault(); + const prefix = scope === 'project' ? 'p' : 'g'; + const idKey = `${prefix}:${asset.id}`; + if (e.shiftKey && lastClicked) { + const i0 = list.findIndex((x) => `${prefix}:${x.id}` === lastClicked); + const i1 = list.findIndex((x) => x.id === asset.id); + if (i0 >= 0 && i1 >= 0) { + const [lo, hi] = i0 < i1 ? [i0, i1] : [i1, i0]; + const next = new Set(selection); + for (let i = lo; i <= hi; i++) next.add(`${prefix}:${list[i].id}`); + setSelection(next); + setLastClicked(idKey); + return; + } + } + setLastClicked(idKey); + setSelection((prev) => { + const next = new Set(prev); + if (next.has(idKey)) next.delete(idKey); + else next.add(idKey); + return next; + }); + }; + + const selectedProjectAsset = useMemo(() => { + for (const id of selection) { + if (!id.startsWith('p:')) continue; + const rid = id.slice(2); + const a = projectAssets.find((x) => x.id === rid); + if (a) return a; + } + return null; + }, [selection, projectAssets]); + + const selectedGlobalAsset = useMemo(() => { + for (const id of selection) { + if (!id.startsWith('g:')) continue; + const rid = id.slice(2); + const a = globalAssets.find((x) => x.id === rid); + if (a) return a; + } + return null; + }, [selection, globalAssets]); + + const usageLayouts = useMemo(() => { + const sel = selectedProjectAsset ?? selectedGlobalAsset; + if (!sel) return []; + const nk = normalizeArtLookupKey(sel.artKey); + const out: { id: string; name: string }[] = []; + for (const L of layoutsFull) { + const keys = collectArtKeysFromLayoutState(L.state); + if (keys.some((k) => normalizeArtLookupKey(k) === nk)) { + out.push({ id: L.id, name: L.name }); + } + } + return out; + }, [selectedProjectAsset, selectedGlobalAsset, layoutsFull]); + + async function uploadFiles(files: FileList | null, target: Scope) { + if (!token || !files?.length) return; + for (const file of Array.from(files)) { + const fd = new FormData(); + fd.append('file', file); + const path = + target === 'project' + ? `${apiBase()}/api/projects/${projectId}/assets` + : `${apiBase()}/api/user/global-assets`; + const res = await fetch(path, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: fd, + }); + const text = await res.text(); + const data = text ? JSON.parse(text) : null; + if (!res.ok) { + onError((data as { error?: string })?.error ?? res.statusText); + return; + } + } + onRefresh(); + } + + async function promoteSelected() { + if (!token || !selectedProjectAsset) return; + const res = await fetch( + `${apiBase()}/api/projects/${projectId}/assets/${selectedProjectAsset.id}/promote-global`, + { method: 'POST', headers: { Authorization: `Bearer ${token}` } } + ); + const text = await res.text(); + const data = text ? JSON.parse(text) : null; + if (!res.ok) { + onError((data as { error?: string })?.error ?? res.statusText); + return; + } + setSelection(new Set()); + onRefresh(); + } + + async function deleteSelected() { + if (!token || selection.size === 0) return; + if (!window.confirm(`Delete ${selection.size} asset(s)?`)) return; + for (const id of selection) { + if (id.startsWith('p:')) { + const res = await fetch(`${apiBase()}/api/projects/${projectId}/assets/${id.slice(2)}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + const t = await res.text(); + let err = res.statusText; + try { + err = JSON.parse(t).error ?? err; + } catch { + /* */ + } + onError(err); + return; + } + } else if (id.startsWith('g:')) { + const res = await fetch(`${apiBase()}/api/user/global-assets/${id.slice(2)}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + onError('Delete global asset failed'); + return; + } + } + } + setSelection(new Set()); + onRefresh(); + } + + const onGridDrop = (e: React.DragEvent) => { + e.preventDefault(); + void uploadFiles(e.dataTransfer.files, scope); + }; + + const thumbClass = + thumbRatio === 'square' ? 'assets-dam-thumb assets-dam-thumb--square' : 'assets-dam-thumb assets-dam-thumb--card'; + + return ( +
    + + +
    e.preventDefault()} + onDrop={onGridDrop} + aria-label="Asset gallery" + > +
    + setSearch(e.target.value)} + /> +
    + Thumbnails + + + +
    +
    +

    Drop files here to upload to the {scope} library.

    +
    + {gridItems.map((a) => { + const idKey = `${scope === 'project' ? 'p' : 'g'}:${a.id}`; + const selected = selection.has(idKey); + return ( + + ); + })} +
    + {gridItems.length === 0 &&

    No assets match this filter.

    } +
    + + +
    + ); +} diff --git a/frontend/src/components/CardFace.tsx b/frontend/src/components/CardFace.tsx index 7e63af6..f6187a4 100644 --- a/frontend/src/components/CardFace.tsx +++ b/frontend/src/components/CardFace.tsx @@ -2,6 +2,7 @@ import { memo, type Ref } from 'react'; import type { Layer as KonvaLayer } from 'konva/lib/Layer'; import { Group as KonvaGroup, Image as KonvaImage, Layer, Rect, Stage, Text } from 'react-konva'; import type { LayoutElement, LayoutStateV2 } from '../types/layout'; +import { resolveImageUrlFromLookup } from '../lib/assetResolve'; import { applyTemplate } from '../lib/template'; import { isVisible } from '../lib/layoutTree'; import { useImageElement } from './useImageElement'; @@ -47,12 +48,14 @@ function TextEl({ function ImageEl({ el, + row, assetUrls, }: { el: Extract; + row: Record; assetUrls: Record; }) { - const url = assetUrls[el.artKey]; + const url = resolveImageUrlFromLookup(el.artKey, row, assetUrls); const img = useImageElement(url); if (!img) { return ( @@ -92,7 +95,7 @@ function CardNode({ if (!isVisible(node)) return null; if (node.type === 'rect') return ; if (node.type === 'text') return ; - return ; + return ; } function rowDataEqual(a: Record, b: Record): boolean { diff --git a/frontend/src/components/CardGroupsPanel.tsx b/frontend/src/components/CardGroupsPanel.tsx index 083456a..b05a5aa 100644 --- a/frontend/src/components/CardGroupsPanel.tsx +++ b/frontend/src/components/CardGroupsPanel.tsx @@ -88,9 +88,10 @@ export function CardGroupsPanel(props: { token: string | null; layoutsFull: LayoutFull[]; assetUrls: Record; - projectCsvSourceUrl: string | null; /** Project-wide busy (e.g. layout save); card-group mutations use internal state so previews don’t thrash. */ busy: boolean; + /** Fired when group list implies at least one published CSV URL (for studio chrome). */ + onAnyPublishedUrlChange?: (hasAny: boolean) => void; onError: (msg: string | null) => void; onOpenLayoutInEditor: (layoutId: string) => void; }) { @@ -99,8 +100,8 @@ export function CardGroupsPanel(props: { token, layoutsFull, assetUrls, - projectCsvSourceUrl, busy, + onAnyPublishedUrlChange, onError, onOpenLayoutInEditor, } = props; @@ -140,6 +141,10 @@ export function CardGroupsPanel(props: { void loadGroups(); }, [loadGroups]); + useEffect(() => { + onAnyPublishedUrlChange?.(groups.some((g) => Boolean(g.csvSourceUrl?.trim()))); + }, [groups, onAnyPublishedUrlChange]); + useEffect(() => { if (editingTitleId && titleInputRef.current) { titleInputRef.current.focus(); @@ -482,21 +487,6 @@ export function CardGroupsPanel(props: { }} /> - {projectCsvSourceUrl ? ( - - ) : null}
    + {exportPdfLoading && ( + + Sending to queue… + + )} +
    + {exportPdfStatus && !exportPdfLoading && ( +

    + {exportPdfStatus} +

    + )} + + +
    +

    Completed exports

    + + {exports.length === 0 && ( +

    + No exports yet — run the worker with RENDER_URL set, or deploy to ECS. +

    + )} +
    +
    + ); +} diff --git a/frontend/src/components/LayoutEditor.tsx b/frontend/src/components/LayoutEditor.tsx index 5e25766..95a6d95 100644 --- a/frontend/src/components/LayoutEditor.tsx +++ b/frontend/src/components/LayoutEditor.tsx @@ -25,6 +25,7 @@ import { import { Layers, Minus, Plus, Table2 } from 'lucide-react'; import type { LayoutElement, LayoutStateV2 } from '../types/layout'; import { DEFAULT_NEW_TEXT } from '../types/layout'; +import { resolveImageUrlFromLookup } from '../lib/assetResolve'; import { applyTemplate } from '../lib/template'; import { CardFace } from './CardFace'; import { useImageElement } from './useImageElement'; @@ -153,6 +154,7 @@ function TextEditorBlock({ function ImageShape({ el, + sampleRow, assetUrls, selected, setNodeRef, @@ -161,6 +163,7 @@ function ImageShape({ onTransformEnd, }: { el: Extract; + sampleRow: Record; assetUrls: Record; selected: boolean; setNodeRef: (id: string, node: Konva.Node | null) => void; @@ -168,7 +171,7 @@ function ImageShape({ onDragEnd: (e: KonvaEventObject) => void; onTransformEnd: (e: KonvaEventObject) => void; }) { - const url = assetUrls[el.artKey]; + const url = resolveImageUrlFromLookup(el.artKey, sampleRow, assetUrls); const img = useImageElement(url); const common = { id: el.id, @@ -309,6 +312,7 @@ function EditorNode({ return ( (fu Art key updateSelected({ artKey: e.target.value.trim() || 'art' })} /> diff --git a/frontend/src/components/StudioAppBar.tsx b/frontend/src/components/StudioAppBar.tsx index ee55cd1..44f592c 100644 --- a/frontend/src/components/StudioAppBar.tsx +++ b/frontend/src/components/StudioAppBar.tsx @@ -135,7 +135,7 @@ function AccountMenuProject({ navigate(`/projects/${projectId}?tab=pipeline`)} + onSelect={() => navigate(`/projects/${projectId}?tab=export`)} > Project settings @@ -194,8 +194,8 @@ function ProjectTabsBar() { const items: { id: ProjectTab; label: string }[] = [ { id: 'cards', label: 'Cards' }, { id: 'layouts', label: 'Layouts' }, - { id: 'data', label: 'Data' }, - { id: 'pipeline', label: 'Assets & export' }, + { id: 'assets', label: 'Assets' }, + { id: 'export', label: 'Export' }, ]; return ( diff --git a/frontend/src/contexts/studioChromeTypes.ts b/frontend/src/contexts/studioChromeTypes.ts index c69a042..2436493 100644 --- a/frontend/src/contexts/studioChromeTypes.ts +++ b/frontend/src/contexts/studioChromeTypes.ts @@ -28,7 +28,7 @@ export type LayoutEditorChrome = { onSaveAndExit: () => void; }; -export type ProjectTab = 'data' | 'layout' | 'layouts' | 'cards' | 'pipeline'; +export type ProjectTab = 'layout' | 'layouts' | 'cards' | 'assets' | 'export'; /** Navbar for /projects/:id when not in layout editor */ export type ProjectViewNavState = { diff --git a/frontend/src/lib/assetResolve.ts b/frontend/src/lib/assetResolve.ts new file mode 100644 index 0000000..2ce5c8a --- /dev/null +++ b/frontend/src/lib/assetResolve.ts @@ -0,0 +1,40 @@ +import { applyTemplate } from './template'; + +/** Normalize for case-insensitive matching; strips common image extensions and {{}}. */ +export function normalizeArtLookupKey(raw: string): string { + let s = raw.trim().toLowerCase(); + s = s.replace(/\{\{|\}\}/g, '').trim(); + s = s.replace(/\.(png|jpe?g|gif|webp|svg|bmp)$/i, ''); + return s; +} + +type AssetWithUrl = { artKey: string; url?: string }; + +/** + * Flat lookup: project assets override global for the same normalized key. + * Keys include both raw `artKey` and normalized form so lookups succeed without re-normalizing callers. + */ +export function buildMergedAssetUrlRecord( + project: AssetWithUrl[], + global: AssetWithUrl[] +): Record { + const out: Record = {}; + const add = (artKey: string, url: string | undefined) => { + if (!url) return; + out[artKey] = url; + out[normalizeArtLookupKey(artKey)] = url; + }; + for (const g of global) add(g.artKey, g.url); + for (const p of project) add(p.artKey, p.url); + return out; +} + +export function resolveImageUrlFromLookup( + artKeyTemplate: string, + row: Record, + urls: Record +): string | undefined { + const resolved = applyTemplate(artKeyTemplate, row).trim(); + if (urls[resolved]) return urls[resolved]; + return urls[normalizeArtLookupKey(resolved)]; +} diff --git a/frontend/src/lib/layoutArtKeys.ts b/frontend/src/lib/layoutArtKeys.ts new file mode 100644 index 0000000..92acbce --- /dev/null +++ b/frontend/src/lib/layoutArtKeys.ts @@ -0,0 +1,26 @@ +/** + * Collect art_key values from stored layout JSON (v1/v2, optional nested groups). + */ +export function collectArtKeysFromLayoutState(state: unknown): string[] { + const keys = new Set(); + + function visitNode(n: unknown): void { + if (!n || typeof n !== 'object') return; + const o = n as Record; + if (o.type === 'image' && typeof o.artKey === 'string' && o.artKey.trim()) { + keys.add(o.artKey.trim()); + } + if (o.type === 'group' && Array.isArray(o.children)) { + for (const c of o.children) visitNode(c); + } + } + + function visitState(s: Record): void { + if (Array.isArray(s.root)) for (const n of s.root) visitNode(n); + if (Array.isArray(s.elements)) for (const n of s.elements) visitNode(n); + } + + if (!state || typeof state !== 'object') return []; + visitState(state as Record); + return [...keys]; +} diff --git a/frontend/src/pages/ProjectPage.tsx b/frontend/src/pages/ProjectPage.tsx index cb1aab0..060e64f 100644 --- a/frontend/src/pages/ProjectPage.tsx +++ b/frontend/src/pages/ProjectPage.tsx @@ -1,10 +1,12 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import { apiBase, apiJson } from '../lib/api'; +import { apiJson } from '../lib/api'; import { useAuth } from '../contexts/useAuth'; import { useToast } from '../contexts/useToast'; -import { parseCsvText } from '../lib/csv'; +import { buildMergedAssetUrlRecord } from '../lib/assetResolve'; +import { AssetsTabPanel } from '../components/AssetsTabPanel'; import { CardGroupsPanel } from '../components/CardGroupsPanel'; +import { ExportTabPanel } from '../components/ExportTabPanel'; import { LayoutsListPanel } from '../components/LayoutsListPanel'; import { LayoutEditor, @@ -56,15 +58,11 @@ export function ProjectPage() { const [activeLayoutId, setActiveLayoutId] = useState(null); const [editorState, setEditorState] = useState(defaultLayoutState()); const [layoutName, setLayoutName] = useState('Default'); - const [assetUrls, setAssetUrls] = useState>({}); const [assets, setAssets] = useState([]); + const [globalAssets, setGlobalAssets] = useState([]); const [exports, setExports] = useState([]); - const [artKey, setArtKey] = useState(''); - const [file, setFile] = useState(null); - const [csvFile, setCsvFile] = useState(null); - const [csvUrlDraft, setCsvUrlDraft] = useState(''); const [busy, setBusy] = useState(false); - /** Pipeline tab: PDF export bypasses SQS and can take a while */ + /** Export tab: PDF export bypasses SQS and can take a while */ const [exportPdfLoading, setExportPdfLoading] = useState(false); const [exportPdfStatus, setExportPdfStatus] = useState(null); /** Raster DPI for PDF cards (API clamps 150–300) */ @@ -78,10 +76,17 @@ export function ProjectPage() { const [lastSavedAt, setLastSavedAt] = useState(null); const [cardGroups, setCardGroups] = useState([]); + /** Kept in sync from CardGroupsPanel so app bar “Linked” reflects group URLs, not only project.csvSourceUrl */ + const [anyCardGroupPublishedUrl, setAnyCardGroupPublishedUrl] = useState(false); const csvData = project?.csvData ?? null; const sampleRow = useMemo(() => csvData?.rows[0] ?? {}, [csvData]); + const mergedAssetUrls = useMemo( + () => buildMergedAssetUrlRecord(assets, globalAssets), + [assets, globalAssets] + ); + const deckPreviewOptions = useMemo((): DeckPreviewOption[] => { const out: DeckPreviewOption[] = []; if (csvData && csvData.rows.length > 0) { @@ -109,15 +114,13 @@ export function ProjectPage() { const loadPipeline = useCallback(async () => { if (!token || !id) return; const [a, e] = await Promise.all([ - apiJson<{ assets: Asset[] }>(`/api/projects/${id}/assets?includeUrls=1`, { token }), + apiJson<{ assets: Asset[]; globalAssets: Asset[] }>(`/api/projects/${id}/assets?includeUrls=1`, { + token, + }), apiJson<{ exports: ExportRow[] }>(`/api/projects/${id}/exports`, { token }), ]); setAssets(a.assets); - const map: Record = {}; - for (const x of a.assets) { - if (x.url) map[x.artKey] = x.url; - } - setAssetUrls(map); + setGlobalAssets(a.globalAssets ?? []); setExports(e.exports); }, [token, id]); @@ -130,6 +133,7 @@ export function ProjectPage() { name: string; csvData: CsvData | null; layoutId: string | null; + csvSourceUrl?: string | null; }[]; }>(`/api/projects/${id}/card-groups`, { token }); setCardGroups( @@ -140,6 +144,9 @@ export function ProjectPage() { layoutId: g.layoutId ?? null, })) ); + setAnyCardGroupPublishedUrl( + res.cardGroups.some((g) => Boolean(g.csvSourceUrl?.trim())) + ); } catch { // non-critical for layout editor; card groups just won't appear as data sources } @@ -152,7 +159,6 @@ export function ProjectPage() { apiJson<{ layouts: LayoutFull[] }>(`/api/projects/${id}/layouts`, { token }), ]); setProject(proj.project); - setCsvUrlDraft(proj.project.csvSourceUrl ?? ''); let list = lays.layouts; if (list.length === 0) { const created = await apiJson<{ layout: LayoutFull }>(`/api/projects/${id}/layouts`, { @@ -177,6 +183,10 @@ export function ProjectPage() { void loadCore().catch((err) => showError(err instanceof Error ? err.message : 'Load failed')); }, [loadCore, showError]); + useEffect(() => { + setAnyCardGroupPublishedUrl(false); + }, [id]); + useEffect(() => { if (tab === 'layout') { document.body.classList.add('layout-editor-open'); @@ -389,8 +399,10 @@ export function ProjectPage() { ); useEffect(() => { - const t = searchParams.get('tab'); - if (t === 'cards' || t === 'layout' || t === 'layouts' || t === 'data' || t === 'pipeline') { + let t = searchParams.get('tab'); + if (t === 'pipeline') t = 'export'; + if (t === 'data') t = 'cards'; + if (t === 'cards' || t === 'layout' || t === 'layouts' || t === 'assets' || t === 'export') { setTab(t); } }, [searchParams]); @@ -404,12 +416,21 @@ export function ProjectPage() { projectId: id, projectName: project.name, tab, - hasPublishedSheet: Boolean(project.csvSourceUrl?.trim()), + hasPublishedSheet: + Boolean(project.csvSourceUrl?.trim()) || anyCardGroupPublishedUrl, navigateTab, onNavigateHomeClick, }); return () => setProjectViewNav(null); - }, [id, project, tab, navigateTab, onNavigateHomeClick, setProjectViewNav]); + }, [ + id, + project, + anyCardGroupPublishedUrl, + tab, + navigateTab, + onNavigateHomeClick, + setProjectViewNav, + ]); const onNavigateToProjectCardsClick = useCallback( (e: React.MouseEvent) => { @@ -487,118 +508,6 @@ export function ProjectPage() { if (ok) navigateTab('cards'); }, [layoutIsDirty, saveLayout, navigateTab]); - async function importCsv(e: FormEvent) { - e.preventDefault(); - if (!token || !id || !csvFile) return; - setBusy(true); - try { - const text = await csvFile.text(); - const parsed = parseCsvText(text); - if (parsed.headers.length === 0) { - throw new Error('CSV must include a header row'); - } - const res = await apiJson<{ csvData: CsvData; csvSourceUrl?: string | null }>( - `/api/projects/${id}/data`, - { - method: 'PUT', - token, - body: JSON.stringify({ ...parsed, sourceUrl: null }), - } - ); - if (project) { - setProject({ - ...project, - csvData: res.csvData, - csvSourceUrl: res.csvSourceUrl ?? null, - }); - setCsvUrlDraft(res.csvSourceUrl ?? ''); - } - setCsvFile(null); - } catch (err) { - showError(err instanceof Error ? err.message : 'Import failed'); - } finally { - setBusy(false); - } - } - - async function onUpload(e: FormEvent) { - e.preventDefault(); - if (!token || !id || !file) return; - setBusy(true); - try { - const fd = new FormData(); - fd.append('file', file); - if (artKey.trim()) fd.append('artKey', artKey.trim()); - const full = `${apiBase()}/api/projects/${id}/assets`; - const res = await fetch(full, { - method: 'POST', - headers: { Authorization: `Bearer ${token}` }, - body: fd, - }); - const text = await res.text(); - const data = text ? JSON.parse(text) : null; - if (!res.ok) throw new Error((data as { error?: string })?.error ?? res.statusText); - setFile(null); - setArtKey(''); - await loadPipeline(); - } catch (err) { - showError(err instanceof Error ? err.message : 'Upload failed'); - } finally { - setBusy(false); - } - } - - async function saveCsvLink() { - if (!token || !id) return; - setBusy(true); - try { - const trimmed = csvUrlDraft.trim(); - const { csvSourceUrl } = await apiJson<{ csvSourceUrl: string | null }>( - `/api/projects/${id}/csv-link`, - { - method: 'PUT', - token, - body: JSON.stringify({ url: trimmed || null }), - } - ); - if (project) setProject({ ...project, csvSourceUrl }); - setCsvUrlDraft(csvSourceUrl ?? ''); - } catch (err) { - showError(err instanceof Error ? err.message : 'Save link failed'); - } finally { - setBusy(false); - } - } - - async function refreshCsvFromUrl() { - if (!token || !id) return; - setBusy(true); - try { - const res = await apiJson<{ csvData: CsvData; csvSourceUrl: string }>( - `/api/projects/${id}/csv/refresh`, - { - method: 'POST', - token, - body: JSON.stringify({ - url: csvUrlDraft.trim() || undefined, - }), - } - ); - if (project) { - setProject({ - ...project, - csvData: res.csvData, - csvSourceUrl: res.csvSourceUrl, - }); - } - setCsvUrlDraft(res.csvSourceUrl); - } catch (err) { - showError(err instanceof Error ? err.message : 'Refresh failed'); - } finally { - setBusy(false); - } - } - const onExport = useCallback(async () => { if (!token || !id) return; setExportPdfLoading(true); @@ -711,18 +620,20 @@ export function ProjectPage() { className={`page project-dashboard${tab === 'layout' ? ' project-dashboard--layout-tab' : ''}`} > {tab === 'cards' && ( -
    - msg && showError(msg)} - onOpenLayoutInEditor={openLayoutInEditor} - /> -
    + <> +
    + msg && showError(msg)} + onOpenLayoutInEditor={openLayoutInEditor} + /> +
    + )} {tab === 'layouts' && ( @@ -750,7 +661,7 @@ export function ProjectPage() { key={`${activeLayoutId ?? 'x'}-${layoutMountNonce}`} state={editorState} onChange={setEditorState} - assetUrls={assetUrls} + assetUrls={mergedAssetUrls} sampleRow={sampleRow} deckPreviewOptions={deckPreviewOptions} activeLayoutId={activeLayoutId ?? undefined} @@ -760,192 +671,29 @@ export function ProjectPage() { )} - {tab === 'data' && ( -
    -

    CSV data

    -

    - Paste a published CSV link from Google Sheets (File → Share → Publish - to web → CSV). The API fetches it server-side so browser CORS is not an issue. Save the - link, then use Refresh to pull the latest rows. -

    -
    - -
    - - -
    -
    -

    Or upload a file

    - - - - - {csvData && csvData.headers.length > 0 && ( - <> -

    Preview ({csvData.rows.length} rows)

    -
    - - - - {csvData.headers.map((h) => ( - - ))} - - - - {csvData.rows.slice(0, 12).map((row, ri) => ( - - {csvData.headers.map((h) => ( - - ))} - - ))} - -
    {h}
    {row[h] ?? ''}
    -
    - {csvData.rows.length > 12 && ( -

    - Showing first 12 rows. All rows are stored for card rendering. -

    - )} - - )} -
    + {tab === 'assets' && id && ( + void loadPipeline()} + onError={(msg) => showError(msg)} + /> )} - {tab === 'pipeline' && ( - <> -
    -

    Upload asset

    -
    - - - -
    -
    - -
    -

    Assets

    -
      - {assets.map((a) => ( -
    • - {a.artKey}{a.s3Key} -
    • - ))} -
    - {assets.length === 0 &&

    No assets yet.

    } -
    - -
    -

    Export PDF

    -

    - Enqueues on SQS — the request finishes quickly while a worker renders the PDF. Run{' '} - python -m baker.main (or your ECS worker) with RENDER_URL{' '} - set to this dev server. -

    - -
    - - {exportPdfLoading && ( - - Sending to queue… - - )} -
    - {exportPdfStatus && !exportPdfLoading && ( -

    - {exportPdfStatus} -

    - )} -

    Exports

    - - {exports.length === 0 && ( -

    - No exports yet — run the worker with RENDER_URL set, or deploy to ECS. -

    - )} -
    - + {tab === 'export' && ( + void onExport()} + exports={exports} + /> )}
    ); From 23f0edb3ab3eb083a3f08f4d718af5a4a8f05c68 Mon Sep 17 00:00:00 2001 From: Will Zakielarz Date: Mon, 13 Apr 2026 18:45:33 -0400 Subject: [PATCH 6/8] Make assets page better --- frontend/src/App.css | 659 +++++++++++++++++---- frontend/src/components/AssetsTabPanel.tsx | 635 +++++++++++++++----- frontend/src/lib/assetFolderStorage.ts | 49 ++ frontend/src/pages/ProjectPage.tsx | 10 +- 4 files changed, 1071 insertions(+), 282 deletions(-) create mode 100644 frontend/src/lib/assetFolderStorage.ts diff --git a/frontend/src/App.css b/frontend/src/App.css index 817c501..ddfce7d 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -33,6 +33,31 @@ body.layout-editor-open .main.main-wide { box-sizing: border-box; } +body.assets-tab-open { + overflow: hidden; +} + +body.assets-tab-open .shell.shell--studio { + height: 100svh; + max-height: 100svh; + min-height: 0; + overflow: hidden; +} + +body.assets-tab-open .main.main-wide { + flex: 1; + min-height: 0; + overflow: hidden; + padding: 0; + padding-bottom: env(safe-area-inset-bottom, 0px); + margin: 0; + max-width: none; + width: 100%; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + .top-nav { display: flex; align-items: center; @@ -80,11 +105,14 @@ body.layout-editor-open .main.main-wide { /* Cards tab: sit closer to the studio nav */ .main-wide:has(.cards-tab-section), -.main-wide:has(.layouts-tab-section), -.main-wide:has(.assets-dam) { +.main-wide:has(.layouts-tab-section) { padding-top: 10px; } +.main-wide:has(.assets-shell) { + padding-top: 0; +} + .page-header { display: flex; flex-wrap: wrap; @@ -585,7 +613,7 @@ button[type='submit']:not(.auth-primary), .card-group-meta-chip ):not(.card-group-icon-btn):not(.card-group-add-slot):not(.card-group-title-hit):not( .card-group-url-drawer-save - ):not(.card-group-url-drawer-cancel):not( + ):not(.card-group-url-drawer-cancel):not(.assets-shell-ctrl):not( .layout-list-row-hit ) { padding: 10px 14px; @@ -651,6 +679,15 @@ button:disabled { flex-direction: column; } +.project-dashboard--assets-tab { + flex: 1; + min-height: 0; + width: 100%; + overflow: hidden; + display: flex; + flex-direction: column; +} + .layout-fullscreen { flex: 1; min-height: 0; @@ -2896,261 +2933,619 @@ button.zone-tool--icon:hover:not(:disabled) { margin-top: 0; } -/* —— Assets DAM tab —— */ -.assets-dam { - display: grid; - grid-template-columns: 220px minmax(0, 1fr) 280px; - gap: 0; - min-height: min(72vh, 640px); - border: 1px solid var(--border); - border-radius: 10px; - overflow: hidden; - background: var(--panel); +/* —— Assets tab: full-viewport app shell —— */ +@keyframes assets-shell-spin { + to { + transform: rotate(360deg); + } } -.assets-dam-sidebar { +.assets-shell-spin { + animation: assets-shell-spin 0.7s linear infinite; +} + +.assets-shell { + display: flex; + flex: 1; + min-height: 0; + width: 100%; + max-width: none; + background: var(--bg); + border-top: 1px solid var(--border); +} + +.assets-shell-sidebar { + flex: 0 0 clamp(250px, 26vw, 300px); + width: clamp(250px, 26vw, 300px); + min-width: 220px; + max-width: 300px; border-right: 1px solid var(--border); - padding: 12px 10px; display: flex; flex-direction: column; - gap: 4px; - background: rgba(0, 0, 0, 0.15); + min-height: 0; + background: #0c0c10; + color: rgba(244, 244, 245, 0.92); } -.assets-dam-sidebar-head { - font-size: 11px; +.assets-shell-sidebar-brand { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 8px; + padding: 14px 14px 12px; + font-size: 12px; font-weight: 600; + letter-spacing: 0.04em; text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--text); - opacity: 0.75; - margin-bottom: 6px; + color: rgba(161, 161, 170, 0.95); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.assets-shell-tree { + flex: 1; + min-height: 0; + overflow: auto; + padding: 8px 0 12px; +} + +.assets-shell-tree-block + .assets-shell-tree-block { + margin-top: 4px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.assets-shell-tree-row { + display: flex; + align-items: center; + gap: 2px; + min-height: 32px; + border-radius: 6px; } -.assets-dam-tree-item { +.assets-shell-tree-row--root:hover { + background: rgba(255, 255, 255, 0.04); +} + +.assets-shell-tree-row--scope-on { + background: rgba(255, 255, 255, 0.03); +} + +.assets-shell-tree-chev, +.assets-shell-tree-chev-spacer { + flex: 0 0 26px; + width: 26px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + color: rgba(161, 161, 170, 0.9); + border-radius: 4px; +} + +.assets-shell-tree-chev:hover { + background: rgba(255, 255, 255, 0.08); + color: var(--text-h); +} + +.assets-shell-tree-chev-spacer { + pointer-events: none; +} + +.assets-shell-tree-label { + flex: 1; + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 6px 8px 6px 0; text-align: left; - width: 100%; - padding: 8px 10px; - border: none; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + color: rgba(244, 244, 245, 0.95); +} + +.assets-shell-tree-label:hover { + background: rgba(255, 255, 255, 0.05); +} + +.assets-shell-tree-ico { + flex-shrink: 0; + opacity: 0.85; +} + +.assets-shell-tree-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.assets-shell-tree-nested { + padding-bottom: 4px; +} + +.assets-shell-tree-leaf { + display: flex; + align-items: center; + gap: 8px; + width: calc(100% - 8px); + margin: 1px 4px; + padding: 7px 10px; border-radius: 6px; + border: none; background: transparent; - color: var(--text-h); font: inherit; font-size: 13px; + color: rgba(228, 228, 231, 0.9); cursor: pointer; + text-align: left; } -.assets-dam-tree-item:hover { - background: rgba(255, 255, 255, 0.05); +.assets-shell-tree-leaf:hover { + background: rgba(255, 255, 255, 0.06); } -.assets-dam-tree-item--active { - background: rgba(16, 185, 129, 0.12); - color: #a7f3d0; +.assets-shell-tree-row--active, +.assets-shell-tree-leaf.assets-shell-tree-row--active { + background: rgba(16, 185, 129, 0.14); + color: #d1fae5; } -.assets-dam-tree-muted { +.assets-shell-tree-count { + margin-left: auto; font-size: 11px; - color: var(--text); + font-weight: 600; opacity: 0.55; - padding: 4px 10px 0; + font-variant-numeric: tabular-nums; } -.assets-dam-sidebar-actions { - margin-top: auto; - padding-top: 12px; +.assets-shell-sidebar-foot { + flex: 0 0 auto; + padding: 10px 12px 14px; + border-top: 1px solid rgba(255, 255, 255, 0.06); display: flex; flex-direction: column; - gap: 8px; - font-size: 12px; + gap: 10px; } -.assets-dam-toggle { - display: flex; +.assets-shell-foot-btn { + display: inline-flex; align-items: center; + justify-content: center; gap: 8px; - color: var(--text); + padding: 8px 10px; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.04); + color: var(--text-h); + font-size: 12px; + font-weight: 500; cursor: pointer; } -.assets-dam-gallery { - padding: 12px 14px; +.assets-shell-foot-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.08); +} + +.assets-shell-center { + flex: 1; + min-width: 0; + min-height: 0; display: flex; flex-direction: column; - gap: 10px; - min-width: 0; } -.assets-dam-toolbar { +.assets-shell-toolbar { + flex: 0 0 auto; display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; align-items: center; - gap: 10px; - justify-content: space-between; + gap: 16px; + padding: 10px 16px; + border-bottom: 1px solid var(--border); + background: rgba(0, 0, 0, 0.12); } -.assets-dam-toolbar-right { +.assets-shell-toolbar-search { + flex: 1; + min-width: 0; + max-width: min(560px, 55vw); display: flex; - flex-wrap: wrap; align-items: center; - gap: 8px; + gap: 10px; + padding: 0 12px; + height: 36px; + border-radius: 8px; + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.25); +} + +.assets-shell-toolbar-search-ico { + flex-shrink: 0; + opacity: 0.45; + color: var(--text); } -.assets-dam-search { +.assets-shell-search-input.cf-input { flex: 1; - min-width: 160px; - max-width: 420px; - padding: 8px 10px; - border-radius: 8px; - border: 1px solid var(--border); - background: rgba(0, 0, 0, 0.2); - color: var(--text-h); - font: inherit; + min-width: 0; + border: none; + background: transparent; + padding: 0; + height: 100%; font-size: 13px; + box-shadow: none; } -.assets-dam-chip { - padding: 4px 10px; - border-radius: 999px; - border: 1px solid var(--border); - background: transparent; - color: var(--text); - font: inherit; - font-size: 12px; +.assets-shell-search-input.cf-input:focus { + outline: none; + box-shadow: none; +} + +.assets-shell-toolbar-tools { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 6px; +} + +.assets-shell-toolbar-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: rgba(161, 161, 170, 0.95); + margin-right: 4px; +} + +.assets-shell-toolbar-divider { + width: 1px; + height: 22px; + background: var(--border); + margin: 0 6px; +} + +.assets-shell-tool-ico { + width: 36px; + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 8px; + border: 1px solid transparent; + color: rgba(228, 228, 231, 0.85); cursor: pointer; } -.assets-dam-chip--on { - border-color: var(--accent); - color: #a7f3d0; +.assets-shell-tool-ico:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.06); + border-color: var(--border); + color: var(--text-h); +} + +.assets-shell-tool-ico--on { + border-color: rgba(16, 185, 129, 0.45); background: rgba(16, 185, 129, 0.1); + color: #a7f3d0; } -.assets-dam-drop-hint { - margin: 0; - font-size: 12px; +.assets-shell-tool-ico--danger:hover:not(:disabled) { + border-color: rgba(248, 113, 113, 0.35); + background: rgba(248, 113, 113, 0.08); + color: #fecaca; } -.assets-dam-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(108px, 1fr)); +.assets-shell-gallery { + position: relative; + flex: 1; + min-height: 0; + overflow: auto; + padding: 12px 16px 20px; + background: var(--bg); + transition: box-shadow 0.15s ease, background 0.15s ease; +} + +.assets-shell-gallery--drag-active { + background: rgba(16, 185, 129, 0.06); + box-shadow: inset 0 0 0 2px rgba(16, 185, 129, 0.35); +} + +.assets-shell-drop-ghost { + margin: 0 0 12px; + font-size: 11px; + line-height: 1.4; + color: var(--text); + opacity: 0.38; +} + +.assets-shell-drop-overlay { + position: absolute; + inset: 12px 16px 20px; + z-index: 2; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; gap: 10px; + border-radius: 10px; + border: 1px dashed rgba(16, 185, 129, 0.5); + background: rgba(16, 185, 129, 0.08); + color: #a7f3d0; + font-size: 14px; + font-weight: 600; + pointer-events: none; +} + +.assets-shell-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); + gap: 12px; align-content: start; } -.assets-dam-cell { +.assets-shell-cell { display: flex; flex-direction: column; - gap: 6px; + gap: 0; padding: 0; border: 1px solid var(--border); border-radius: 8px; - background: rgba(0, 0, 0, 0.2); + background: rgba(0, 0, 0, 0.18); cursor: pointer; text-align: left; overflow: hidden; } -.assets-dam-cell--selected { +.assets-shell-cell:hover { + border-color: rgba(255, 255, 255, 0.12); +} + +.assets-shell-cell--selected { outline: 2px solid var(--accent); - outline-offset: 1px; + outline-offset: 0; + border-color: rgba(16, 185, 129, 0.35); } -.assets-dam-thumb { +.assets-shell-thumb { width: 100%; - background: #121218; + background: #101014; display: flex; align-items: center; justify-content: center; overflow: hidden; } -.assets-dam-thumb--square { +.assets-shell-thumb--square { aspect-ratio: 1 / 1; } -.assets-dam-thumb--card { +.assets-shell-thumb--card { aspect-ratio: 63 / 88; } -.assets-dam-thumb img { +.assets-shell-thumb img { width: 100%; height: 100%; object-fit: cover; } -.assets-dam-cell-label { +.assets-shell-cell-label { font-size: 11px; - padding: 0 8px 8px; + line-height: 1.3; + padding: 8px 10px; color: var(--text-h); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.assets-dam-inspector { +.assets-shell-no-prev { + font-size: 11px; + color: var(--text); + opacity: 0.45; + padding: 12px; +} + +.assets-shell-empty { + margin: 24px 0 0; + font-size: 13px; + color: var(--text); + opacity: 0.55; +} + +.assets-shell-inspector { + flex: 0 0 300px; + width: 300px; + min-width: 260px; + max-width: 320px; border-left: 1px solid var(--border); - padding: 12px 12px 16px; + padding: 14px 16px 20px; display: flex; flex-direction: column; - gap: 10px; - background: rgba(0, 0, 0, 0.12); - min-width: 0; + gap: 12px; + min-height: 0; + overflow: auto; + background: rgba(0, 0, 0, 0.08); } -.assets-dam-inspector-head { +.assets-shell-inspector-head { + display: flex; + align-items: center; + gap: 8px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; + color: rgba(161, 161, 170, 0.95); +} + +.assets-shell-inspector-empty { + margin: 8px 0 0; + font-size: 13px; + line-height: 1.45; color: var(--text); - opacity: 0.75; + opacity: 0.55; } -.assets-dam-preview { +.assets-shell-preview { border-radius: 8px; overflow: hidden; border: 1px solid var(--border); background: #0f0f12; min-height: 120px; + max-height: 220px; display: flex; align-items: center; justify-content: center; } -.assets-dam-preview-img { +.assets-shell-preview-img { width: 100%; - max-height: 200px; + max-height: 220px; object-fit: contain; } -.assets-dam-promote { - padding: 10px 12px; +.assets-shell-inspector-title { + font-size: 14px; + font-weight: 600; + color: var(--text-h); + line-height: 1.35; + word-break: break-word; +} + +.assets-shell-meta { + margin: 0; + display: grid; + gap: 6px 10px; + grid-template-columns: auto 1fr; + font-size: 11px; +} + +.assets-shell-meta dt { + margin: 0; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgba(161, 161, 170, 0.85); +} + +.assets-shell-meta dd { + margin: 0; + min-width: 0; +} + +.assets-shell-meta-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 10px; + line-height: 1.45; + color: rgba(161, 161, 170, 0.95); + opacity: 0.9; + word-break: break-all; +} + +.assets-shell-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.assets-shell-field-label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgba(161, 161, 170, 0.9); +} + +.assets-shell-select { + width: 100%; + padding: 8px 10px; border-radius: 8px; - border: 1px solid var(--accent); - background: rgba(16, 185, 129, 0.12); - color: #a7f3d0; + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.22); + color: var(--text-h); font: inherit; + font-size: 12px; +} + +.assets-shell-promote { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 10px 14px; + border-radius: 8px; + border: 1px solid rgba(16, 185, 129, 0.45); + background: rgba(16, 185, 129, 0.1); + color: #a7f3d0; + font-size: 13px; font-weight: 600; cursor: pointer; } -.assets-dam-promote:hover:not(:disabled) { - background: rgba(16, 185, 129, 0.2); +.assets-shell-promote:hover:not(:disabled) { + background: rgba(16, 185, 129, 0.18); } -.assets-dam-usage-title { - margin: 8px 0 0; - font-size: 12px; +.assets-shell-usage-block { + margin-top: 4px; + padding-top: 14px; + border-top: 1px solid var(--border); +} + +.assets-shell-usage-head { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; font-weight: 600; - color: var(--text-h); + text-transform: uppercase; + letter-spacing: 0.05em; + color: rgba(161, 161, 170, 0.95); + margin-bottom: 8px; } -.assets-dam-usage-list { +.assets-shell-usage-empty { margin: 0; - padding-left: 18px; - font-size: 13px; + font-size: 12px; + line-height: 1.45; color: var(--text); + opacity: 0.5; +} + +.assets-shell-usage-list { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 6px; +} + +.assets-shell-usage-list li { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.15); + font-size: 12px; + color: var(--text-h); +} + +.assets-shell-usage-list li span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.assets-shell-usage-li-ico { + flex-shrink: 0; + opacity: 0.55; } .mono.small { @@ -3159,15 +3554,33 @@ button.zone-tool--icon:hover:not(:disabled) { } @media (max-width: 960px) { - .assets-dam { - grid-template-columns: 1fr; - grid-template-rows: auto auto auto; + .assets-shell { + flex-direction: column; + overflow: auto; } - .assets-dam-sidebar, - .assets-dam-inspector { - border: none; + .assets-shell-sidebar { + flex: 0 0 auto; + width: 100%; + max-width: none; + border-right: none; border-bottom: 1px solid var(--border); + max-height: 42vh; + } + + .assets-shell-center { + flex: 1 1 auto; + min-height: 280px; + border-right: none; + } + + .assets-shell-inspector { + flex: 0 0 auto; + width: 100%; + max-width: none; + border-left: none; + margin-left: 0; + border-top: 1px solid var(--border); } } diff --git a/frontend/src/components/AssetsTabPanel.tsx b/frontend/src/components/AssetsTabPanel.tsx index e20893d..6bec23d 100644 --- a/frontend/src/components/AssetsTabPanel.tsx +++ b/frontend/src/components/AssetsTabPanel.tsx @@ -1,7 +1,32 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Box, + ChevronDown, + ChevronRight, + Folder, + FolderPlus, + Globe, + ImageOff, + LayoutTemplate, + Library, + Loader2, + Ratio, + Search, + Square, + Trash2, + UploadCloud, +} from 'lucide-react'; import { apiBase } from '../lib/api'; +import { + loadAssetFolderStore, + newFolderId, + saveAssetFolderStore, + type AssetFolderScope, + type AssetFolderStore, +} from '../lib/assetFolderStorage'; import { normalizeArtLookupKey } from '../lib/assetResolve'; import { collectArtKeysFromLayoutState } from '../lib/layoutArtKeys'; +import { Input } from './ui/input'; export type StudioAssetRow = { id: string; @@ -23,9 +48,19 @@ type Props = { onError: (msg: string) => void; }; -type Scope = 'project' | 'global'; +type ViewKind = + | { kind: 'all' } + | { kind: 'unused' } + | { kind: 'folder'; folderId: string }; -const GLOBAL_VISIBILITY_KEY = 'cardgoose.assets.showGlobal'; +type TreeNav = { + scope: AssetFolderScope; + view: ViewKind; +}; + +function assignKey(scope: AssetFolderScope, assetId: string) { + return `${scope}:${assetId}`; +} export function AssetsTabPanel({ projectId, @@ -38,30 +73,31 @@ export function AssetsTabPanel({ onError, }: Props) { const [search, setSearch] = useState(''); - const [scope, setScope] = useState('project'); + const [nav, setNav] = useState({ scope: 'project', view: { kind: 'all' } }); const [selection, setSelection] = useState>(new Set()); const [lastClicked, setLastClicked] = useState(null); const [thumbRatio, setThumbRatio] = useState<'square' | 'card'>('square'); - const [showGlobal, setShowGlobal] = useState(() => { - try { - return localStorage.getItem(GLOBAL_VISIBILITY_KEY) !== '0'; - } catch { - return true; - } - }); - - const setShowGlobalPersist = useCallback((v: boolean) => { - setShowGlobal(v); - try { - localStorage.setItem(GLOBAL_VISIBILITY_KEY, v ? '1' : '0'); - } catch { - /* ignore */ - } - }, []); + const [folderStore, setFolderStore] = useState(() => + loadAssetFolderStore(projectId) + ); + const [expandedLibrary, setExpandedLibrary] = useState>( + () => new Set(['project', 'global']) + ); + const [expandedFolders, setExpandedFolders] = useState>(() => new Set()); + const [dragDepth, setDragDepth] = useState(0); + const [isDraggingFiles, setIsDraggingFiles] = useState(false); + + useEffect(() => { + setFolderStore(loadAssetFolderStore(projectId)); + }, [projectId]); useEffect(() => { - if (!showGlobal && scope === 'global') setScope('project'); - }, [showGlobal, scope]); + saveAssetFolderStore(projectId, folderStore); + }, [projectId, folderStore]); + + const persistFolderStore = useCallback((updater: (prev: AssetFolderStore) => AssetFolderStore) => { + setFolderStore(updater); + }, []); const filter = useCallback( (rows: StudioAssetRow[]) => { @@ -95,18 +131,19 @@ export function AssetsTabPanel({ [visibleProject, usedNormalizedKeys] ); - const [projectFolder, setProjectFolder] = useState<'all' | 'unused'>('all'); - - const gridItems = - scope === 'project' - ? projectFolder === 'unused' - ? unusedProject - : visibleProject - : visibleGlobal; + const gridItems = useMemo((): StudioAssetRow[] => { + const base = nav.scope === 'project' ? visibleProject : visibleGlobal; + if (nav.view.kind === 'all') return base; + if (nav.view.kind === 'unused') { + return nav.scope === 'project' ? unusedProject : []; + } + const fid = nav.view.folderId; + return base.filter((a) => folderStore.assignments[assignKey(nav.scope, a.id)] === fid); + }, [nav, visibleProject, visibleGlobal, unusedProject, folderStore.assignments]); const toggleSelect = (asset: StudioAssetRow, e: React.MouseEvent, list: StudioAssetRow[]) => { e.preventDefault(); - const prefix = scope === 'project' ? 'p' : 'g'; + const prefix = nav.scope === 'project' ? 'p' : 'g'; const idKey = `${prefix}:${asset.id}`; if (e.shiftKey && lastClicked) { const i0 = list.findIndex((x) => `${prefix}:${x.id}` === lastClicked); @@ -149,8 +186,10 @@ export function AssetsTabPanel({ return null; }, [selection, globalAssets]); + const selectedAsset = selectedProjectAsset ?? selectedGlobalAsset; + const usageLayouts = useMemo(() => { - const sel = selectedProjectAsset ?? selectedGlobalAsset; + const sel = selectedAsset; if (!sel) return []; const nk = normalizeArtLookupKey(sel.artKey); const out: { id: string; name: string }[] = []; @@ -161,9 +200,45 @@ export function AssetsTabPanel({ } } return out; - }, [selectedProjectAsset, selectedGlobalAsset, layoutsFull]); + }, [selectedAsset, layoutsFull]); + + const foldersForScope = useCallback( + (scope: AssetFolderScope) => folderStore.folders.filter((f) => f.scope === scope), + [folderStore.folders] + ); - async function uploadFiles(files: FileList | null, target: Scope) { + const childFolders = useCallback( + (scope: AssetFolderScope, parentId: string | null) => + folderStore.folders.filter((f) => f.scope === scope && f.parentId === parentId), + [folderStore.folders] + ); + + const setAssetFolderAssignment = useCallback( + (scope: AssetFolderScope, assetId: string, folderId: string | '') => { + const k = assignKey(scope, assetId); + persistFolderStore((prev) => { + const assignments = { ...prev.assignments }; + if (!folderId) delete assignments[k]; + else assignments[k] = folderId; + return { ...prev, assignments }; + }); + }, + [persistFolderStore] + ); + + const folderOptionsForScope = useMemo(() => { + const scope = selectedProjectAsset ? ('project' as const) : selectedGlobalAsset ? ('global' as const) : null; + if (!scope) return []; + return foldersForScope(scope); + }, [selectedProjectAsset, selectedGlobalAsset, foldersForScope]); + + const selectedAssetFolderId = useMemo(() => { + if (!selectedAsset) return ''; + const scope: AssetFolderScope = selectedProjectAsset ? 'project' : 'global'; + return folderStore.assignments[assignKey(scope, selectedAsset.id)] ?? ''; + }, [selectedAsset, selectedProjectAsset, folderStore.assignments]); + + async function uploadFiles(files: FileList | null, target: AssetFolderScope) { if (!token || !files?.length) return; for (const file of Array.from(files)) { const fd = new FormData(); @@ -238,170 +313,414 @@ export function AssetsTabPanel({ onRefresh(); } + const onGridDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + if (!e.dataTransfer.types.includes('Files')) return; + setDragDepth((d) => d + 1); + setIsDraggingFiles(true); + }; + + const onGridDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + setDragDepth((d) => { + const next = Math.max(0, d - 1); + if (next === 0) setIsDraggingFiles(false); + return next; + }); + }; + + const onGridDragOver = (e: React.DragEvent) => { + e.preventDefault(); + if (e.dataTransfer.types.includes('Files')) { + e.dataTransfer.dropEffect = 'copy'; + } + }; + const onGridDrop = (e: React.DragEvent) => { e.preventDefault(); - void uploadFiles(e.dataTransfer.files, scope); + setDragDepth(0); + setIsDraggingFiles(false); + void uploadFiles(e.dataTransfer.files, nav.scope); }; - const thumbClass = - thumbRatio === 'square' ? 'assets-dam-thumb assets-dam-thumb--square' : 'assets-dam-thumb assets-dam-thumb--card'; + const toggleLibrary = (scope: AssetFolderScope) => { + setExpandedLibrary((prev) => { + const next = new Set(prev); + if (next.has(scope)) next.delete(scope); + else next.add(scope); + return next; + }); + }; - return ( -
    -
    + {isOpen && hasChildren && ( +
    {renderFolderSubtree(scope, f.id, depth + 1)}
    + )} +
    + ); + }); + }; + + const thumbClass = + thumbRatio === 'square' + ? 'assets-shell-thumb assets-shell-thumb--square' + : 'assets-shell-thumb assets-shell-thumb--card'; + + return ( +
    + -
    e.preventDefault()} - onDrop={onGridDrop} - aria-label="Asset gallery" - > -
    - setSearch(e.target.value)} - /> -
    - Thumbnails +
    +
    +
    + + setSearch(e.target.value)} + autoComplete="off" + /> +
    +
    + Thumbnails -
    +
    + +
    + {!isDraggingFiles && ( +

    + Drag files here to upload to the {nav.scope === 'project' ? 'project' : 'global'} library. +

    + )} + {isDraggingFiles && ( +
    + + Drop to upload +
    + )} +
    + {gridItems.map((a) => { + const idKey = `${nav.scope === 'project' ? 'p' : 'g'}:${a.id}`; + const selected = selection.has(idKey); + return ( + + ); + })} +
    + {gridItems.length === 0 && !isDraggingFiles && ( +

    + {nav.view.kind === 'folder' + ? 'This folder has no assets yet. Assign assets from the inspector, or pick another folder.' + : 'No assets match this view.'} +

    + )}
    -

    Drop files here to upload to the {scope} library.

    -
    - {gridItems.map((a) => { - const idKey = `${scope === 'project' ? 'p' : 'g'}:${a.id}`; - const selected = selection.has(idKey); - return ( - - ); - })} -
    - {gridItems.length === 0 &&

    No assets match this filter.

    } -
    +
    -
+ + {assetPickerOpen && projectId && token && ( +
setAssetPickerOpen(false)} + > +
e.stopPropagation()} + > +
+

Choose asset

+ +
+
+ onStudioAssetsRefresh?.()} + onError={(msg) => { + if (msg) console.warn('[AssetsPicker]', msg); + }} + artPickerMode + onArtKeyPicked={(artKey) => { + updateSelected({ fallbackArtKey: artKey }); + setAssetPickerOpen(false); + }} + /> +
+
+
+ )}
); }); diff --git a/frontend/src/components/ZoneHierarchy.tsx b/frontend/src/components/ZoneHierarchy.tsx index 9504275..bd60b7e 100644 --- a/frontend/src/components/ZoneHierarchy.tsx +++ b/frontend/src/components/ZoneHierarchy.tsx @@ -43,7 +43,11 @@ function contentLabel(node: LayoutElement): string { const t = node.text ?? ''; return t.length > 40 ? `${t.slice(0, 40)}…` : t; } - if (node.type === 'image') return node.artKey || 'image'; + if (node.type === 'image') { + const col = node.dynamicSourceColumn?.trim(); + if (col) return `{{${col}}}`; + return node.fallbackArtKey || node.artKey || 'image'; + } return 'Bar'; } diff --git a/frontend/src/components/useImageElement.ts b/frontend/src/components/useImageElement.ts index 583896a..4517b3a 100644 --- a/frontend/src/components/useImageElement.ts +++ b/frontend/src/components/useImageElement.ts @@ -4,13 +4,22 @@ export function useImageElement(url: string | undefined): CanvasImageSource | nu const [loaded, setLoaded] = useState<{ u: string; img: CanvasImageSource } | null>(null); useEffect(() => { - if (!url) return; + if (!url) { + setLoaded(null); + return; + } + let cancelled = false; const i = new window.Image(); i.crossOrigin = 'anonymous'; - i.onload = () => setLoaded({ u: url, img: i }); - i.onerror = () => setLoaded(null); + i.onload = () => { + if (!cancelled) setLoaded({ u: url, img: i }); + }; + i.onerror = () => { + if (!cancelled) setLoaded(null); + }; i.src = url; return () => { + cancelled = true; i.onload = null; i.onerror = null; }; diff --git a/frontend/src/lib/assetResolve.ts b/frontend/src/lib/assetResolve.ts index 2ce5c8a..9c211f5 100644 --- a/frontend/src/lib/assetResolve.ts +++ b/frontend/src/lib/assetResolve.ts @@ -1,3 +1,4 @@ +import type { LayoutImage } from '../types/layout'; import { applyTemplate } from './template'; /** Normalize for case-insensitive matching; strips common image extensions and {{}}. */ @@ -29,6 +30,18 @@ export function buildMergedAssetUrlRecord( return out; } +/** Read a cell value; matches column name case-insensitively when row keys differ from headers. */ +export function rowValueForColumn(row: Record, column: string): string { + const c = column.trim(); + if (!c) return ''; + if (Object.prototype.hasOwnProperty.call(row, c)) return String(row[c] ?? '').trim(); + const lower = c.toLowerCase(); + for (const k of Object.keys(row)) { + if (k.trim().toLowerCase() === lower) return String(row[k] ?? '').trim(); + } + return ''; +} + export function resolveImageUrlFromLookup( artKeyTemplate: string, row: Record, @@ -38,3 +51,74 @@ export function resolveImageUrlFromLookup( if (urls[resolved]) return urls[resolved]; return urls[normalizeArtLookupKey(resolved)]; } + +/** Resolve a literal art key or normalized key against the merged URL map. */ +export function resolveLiteralArtKeyToUrl( + artKey: string, + urls: Record +): string | undefined { + const t = artKey.trim(); + if (!t) return undefined; + if (urls[t]) return urls[t]; + return urls[normalizeArtLookupKey(t)]; +} + +/** + * Match a spreadsheet cell value to an asset URL (exact / normalized, then fuzzy substring). + * `orderedArtKeys` should list project art keys first, then global, for tie-breaking. + */ +export function resolveCellValueToAssetUrl( + cellValue: string, + urls: Record, + orderedArtKeys: string[] +): string | undefined { + const v = cellValue.trim(); + if (!v) return undefined; + const direct = resolveLiteralArtKeyToUrl(v, urls); + if (direct) return direct; + const vn = normalizeArtLookupKey(v); + if (!vn) return undefined; + const keys = + orderedArtKeys.length > 0 + ? orderedArtKeys + : [...new Set(Object.keys(urls))].filter((k) => k.indexOf('{{') < 0); + for (const k of keys) { + if (normalizeArtLookupKey(k) === vn) return urls[k] ?? urls[normalizeArtLookupKey(k)]; + } + for (const k of keys) { + const kn = normalizeArtLookupKey(k); + if (!kn) continue; + if (kn.includes(vn) || (vn.length >= 3 && vn.includes(kn))) { + return urls[k] ?? urls[normalizeArtLookupKey(k)]; + } + } + return undefined; +} + +/** + * Smart image URL for layout image zones: dynamic column → fuzzy resolve → fallback art key → legacy artKey template. + */ +export function smartResolveLayoutImageUrl( + el: Pick, + row: Record, + urls: Record, + orderedArtKeys: string[] +): string | undefined { + const col = el.dynamicSourceColumn?.trim(); + if (col) { + const cell = rowValueForColumn(row, col); + const fromCell = resolveCellValueToAssetUrl(cell, urls, orderedArtKeys); + if (fromCell) return fromCell; + const fb = el.fallbackArtKey?.trim(); + if (fb) { + const u = resolveLiteralArtKeyToUrl(fb, urls); + if (u) return u; + } + return undefined; + } + const legacy = resolveImageUrlFromLookup(el.artKey, row, urls); + if (legacy) return legacy; + const fb2 = el.fallbackArtKey?.trim(); + if (fb2) return resolveLiteralArtKeyToUrl(fb2, urls); + return undefined; +} diff --git a/frontend/src/lib/layoutArtKeys.ts b/frontend/src/lib/layoutArtKeys.ts index 92acbce..10b4cd6 100644 --- a/frontend/src/lib/layoutArtKeys.ts +++ b/frontend/src/lib/layoutArtKeys.ts @@ -7,8 +7,14 @@ export function collectArtKeysFromLayoutState(state: unknown): string[] { function visitNode(n: unknown): void { if (!n || typeof n !== 'object') return; const o = n as Record; - if (o.type === 'image' && typeof o.artKey === 'string' && o.artKey.trim()) { - keys.add(o.artKey.trim()); + if (o.type === 'image') { + if (typeof o.artKey === 'string' && o.artKey.trim()) { + keys.add(o.artKey.trim()); + } + const fb = (o as { fallbackArtKey?: unknown }).fallbackArtKey; + if (typeof fb === 'string' && fb.trim()) { + keys.add(fb.trim()); + } } if (o.type === 'group' && Array.isArray(o.children)) { for (const c of o.children) visitNode(c); diff --git a/frontend/src/pages/ProjectPage.tsx b/frontend/src/pages/ProjectPage.tsx index c5e8435..ff4bb93 100644 --- a/frontend/src/pages/ProjectPage.tsx +++ b/frontend/src/pages/ProjectPage.tsx @@ -3,7 +3,7 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { apiJson } from '../lib/api'; import { useAuth } from '../contexts/useAuth'; import { useToast } from '../contexts/useToast'; -import { buildMergedAssetUrlRecord } from '../lib/assetResolve'; +import { buildMergedAssetUrlRecord, normalizeArtLookupKey } from '../lib/assetResolve'; import { AssetsTabPanel } from '../components/AssetsTabPanel'; import { CardGroupsPanel } from '../components/CardGroupsPanel'; import { ExportTabPanel } from '../components/ExportTabPanel'; @@ -87,6 +87,24 @@ export function ProjectPage() { [assets, globalAssets] ); + const assetResolveOrder = useMemo(() => { + const seen = new Set(); + const out: string[] = []; + for (const a of assets) { + const n = normalizeArtLookupKey(a.artKey); + if (seen.has(n)) continue; + seen.add(n); + out.push(a.artKey); + } + for (const a of globalAssets) { + const n = normalizeArtLookupKey(a.artKey); + if (seen.has(n)) continue; + seen.add(n); + out.push(a.artKey); + } + return out; + }, [assets, globalAssets]); + const deckPreviewOptions = useMemo((): DeckPreviewOption[] => { const out: DeckPreviewOption[] = []; if (csvData && csvData.rows.length > 0) { @@ -94,14 +112,17 @@ export function ProjectPage() { id: '__project__', label: 'Project dataset', rows: csvData.rows, + headers: csvData.headers, layoutId: null, }); } for (const g of cardGroups) { + const csv = g.csvData; out.push({ id: g.id, label: g.name, - rows: g.csvData?.rows ?? [], + rows: csv?.rows ?? [], + headers: csv?.headers, layoutId: g.layoutId, }); } @@ -635,6 +656,7 @@ export function ProjectPage() { token={token} layoutsFull={layoutsFull} assetUrls={mergedAssetUrls} + assetResolveOrder={assetResolveOrder} busy={busy} onAnyPublishedUrlChange={setAnyCardGroupPublishedUrl} onError={(msg) => msg && showError(msg)} @@ -674,6 +696,14 @@ export function ProjectPage() { deckPreviewOptions={deckPreviewOptions} activeLayoutId={activeLayoutId ?? undefined} onCapabilitiesChange={setEditorCaps} + projectAssetArtKeys={assets.map((a) => a.artKey)} + globalAssetArtKeys={globalAssets.map((a) => a.artKey)} + projectId={id} + token={token} + layoutsFull={layoutsFull} + projectAssets={assets} + globalAssets={globalAssets} + onStudioAssetsRefresh={() => void loadPipeline()} /> )} diff --git a/frontend/src/pages/RenderPage.tsx b/frontend/src/pages/RenderPage.tsx index 2698172..8796d24 100644 --- a/frontend/src/pages/RenderPage.tsx +++ b/frontend/src/pages/RenderPage.tsx @@ -7,6 +7,8 @@ export type HeadlessRenderPayload = { layout: unknown; row: Record; assetUrls: Record; + /** Project art keys first, then global — matches layout editor fuzzy resolver. */ + assetResolveOrder?: string[]; pixelWidth: number; }; @@ -38,6 +40,7 @@ type CardFrame = { state: LayoutStateV2; row: Record; assetUrls: Record; + assetResolveOrder: string[]; pixelWidth: number; }; @@ -124,6 +127,9 @@ export function RenderPage() { state, row: payload.row, assetUrls: payload.assetUrls ?? {}, + assetResolveOrder: Array.isArray(payload.assetResolveOrder) + ? payload.assetResolveOrder + : [], pixelWidth: Math.max(32, Math.round(payload.pixelWidth)), }); } catch (e) { @@ -179,6 +185,7 @@ export function RenderPage() { state={card.state} row={card.row} assetUrls={card.assetUrls} + assetResolveOrder={card.assetResolveOrder} pixelWidth={card.pixelWidth} layerRef={layerRef} /> diff --git a/frontend/src/types/layout.ts b/frontend/src/types/layout.ts index 5cdeff3..e99c7dd 100644 --- a/frontend/src/types/layout.ts +++ b/frontend/src/types/layout.ts @@ -25,7 +25,12 @@ export type LayoutImage = LayoutLeafFlags & { y: number; width: number; height: number; + /** Legacy template (e.g. {{Column}}); used when dynamicSourceColumn is not set. */ artKey: string; + /** CSV column header: cell value is resolved to an asset (project, then global, fuzzy). */ + dynamicSourceColumn?: string | null; + /** Static art key when the dynamic cell is empty or resolution fails. */ + fallbackArtKey?: string | null; }; export type LayoutRect = LayoutLeafFlags & { diff --git a/worker/src/baker/renderer.py b/worker/src/baker/renderer.py index eb255ad..9d8408f 100644 --- a/worker/src/baker/renderer.py +++ b/worker/src/baker/renderer.py @@ -152,6 +152,7 @@ def render_card_pngs(payload: dict[str, Any]) -> list[bytes]: "layout": layout, "row": row, "assetUrls": asset_urls, + "assetResolveOrder": payload.get("assetResolveOrder") or [], "pixelWidth": pixel_w, } max_attempts = 3