From 8fb8aa62856a46442f37433e871d958895adbed4 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Fri, 28 Aug 2026 06:06:16 +0200 Subject: [PATCH] fix(compose): keep the scene background instead of losing it on refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The background is the scene — the place the figures stand in. The editor collected it into a URL.createObjectURL blob in React state and stopped there: it was never uploaded, and compositions.background_url was never written, even though the column, the storage folder and an authorized, versioned /api/compositions route all already existed. So the background survived exactly one page view. Reopen the project the next day and the canvas is bare black — and the export takes the stage as it finds it, renders the panels from it and reports "print-ready" for a file with no scene in it at all. Same failure shape as the baked-in guides in #20: a confident success message on an artifact nobody re-checks before it goes to the print shop. The upload now goes to storage and the path to the composition row, so the background reloads with the project. Export refuses to run while it is still in flight rather than printing the bare canvas. Only background_url is written. Figure placement already lives on the figures rows, and copying it into layout would give it a second home. An orphaned endpoint is what made this invisible, so wired.test.ts fails if any route family has no caller in the app — verified by removing the new hook, which fails it on /api/compositions. verify: lint 0 errors (8 pre-existing warnings) · tsc clean · 78 tests pass Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014opKWKa65PXxn2MiWSKwwh --- app/src/components/compose/CanvasToolbar.tsx | 17 +++-- .../components/compose/CompositionCanvas.tsx | 63 ++++++++++++++++--- app/src/hooks/useComposition.ts | 35 +++++++++++ app/src/lib/api/wired.test.ts | 44 +++++++++++++ 4 files changed, 148 insertions(+), 11 deletions(-) create mode 100644 app/src/hooks/useComposition.ts create mode 100644 app/src/lib/api/wired.test.ts diff --git a/app/src/components/compose/CanvasToolbar.tsx b/app/src/components/compose/CanvasToolbar.tsx index 92f706f..f2c4d12 100644 --- a/app/src/components/compose/CanvasToolbar.tsx +++ b/app/src/components/compose/CanvasToolbar.tsx @@ -18,6 +18,8 @@ interface CanvasToolbarProps { figures: Figure[] surface: Surface projectId: string + /** The saved background has not painted onto the stage yet. */ + backgroundPending: boolean onBackgroundUpload: (file: File) => void } @@ -29,7 +31,7 @@ const MAX_EXPORT_ATTEMPTS = 4 /** Browsers drop downloads fired back to back; give each one room to start. */ const DOWNLOAD_GAP_MS = 400 -export function CanvasToolbar({ stageRef, overlayRef, selectedId, figures, surface, projectId, onBackgroundUpload }: CanvasToolbarProps) { +export function CanvasToolbar({ stageRef, overlayRef, selectedId, figures, surface, projectId, backgroundPending, onBackgroundUpload }: CanvasToolbarProps) { const bgInputRef = useRef(null) const [isExporting, setIsExporting] = useState(false) const updateFigure = useUpdateFigure(projectId) @@ -58,6 +60,13 @@ export function CanvasToolbar({ stageRef, overlayRef, selectedId, figures, surfa const stage = stageRef.current if (!stage || isExporting) return + // Exporting now would print the bare canvas and still report success — + // the scene would be missing from a file nobody re-checks before printing. + if (backgroundPending) { + toast.error('The background is still loading — exporting now would print without the scene.') + return + } + // One sheet per physical panel — that is what gets printed and trimmed. const regions = getPanelExportRegions({ panels: surface.panels, @@ -150,13 +159,13 @@ export function CanvasToolbar({ stageRef, overlayRef, selectedId, figures, surfa
- - diff --git a/app/src/components/compose/CompositionCanvas.tsx b/app/src/components/compose/CompositionCanvas.tsx index 74b27f5..93bd394 100644 --- a/app/src/components/compose/CompositionCanvas.tsx +++ b/app/src/components/compose/CompositionCanvas.tsx @@ -7,6 +7,8 @@ import { CanvasToolbar } from './CanvasToolbar' import { getTotalDimensions, SEAM_BUFFER_CM } from '@/lib/domain/surface' import type { PlacementViolation } from '@/lib/domain/surface' import { useUpdateFigure } from '@/hooks/useFigures' +import { useComposition, useSaveComposition } from '@/hooks/useComposition' +import { useSupabaseUpload } from '@/hooks/useSupabaseUpload' import { getImageUrl } from '@/lib/supabase/storage' import { cn } from '@/lib/utils' import { toast } from 'sonner' @@ -25,8 +27,15 @@ export function CompositionCanvas({ projectId, figures, surface }: CompositionCa const containerRef = useRef(null) const [containerSize, setContainerSize] = useState({ width: 800, height: 600 }) const [selectedId, setSelectedId] = useState(null) - const [bgImage, setBgImage] = useState(null) + const [loadedBg, setLoadedBg] = useState<{ path: string; image: HTMLImageElement } | null>(null) + const [bgUploading, setBgUploading] = useState(false) const updateFigure = useUpdateFigure(projectId) + // The background is the scene itself. Held only in memory it vanished on + // refresh, and the export then silently printed a black wall. + const { data: composition, isLoading: compositionLoading } = useComposition(projectId) + const saveComposition = useSaveComposition(projectId) + const { upload } = useSupabaseUpload(projectId) + const backgroundPath = composition?.background_url ?? null const { width_cm, height_cm } = getTotalDimensions(surface.panels) const aspectRatio = width_cm / height_cm @@ -44,6 +53,48 @@ export function CompositionCanvas({ projectId, figures, surface }: CompositionCa return () => window.removeEventListener('resize', resize) }, [aspectRatio]) + // Restore the saved background. crossOrigin is what keeps the Konva stage + // untainted, so the export can still read pixels back out of it. + useEffect(() => { + if (!backgroundPath) return + let cancelled = false + const img = new window.Image() + img.crossOrigin = 'anonymous' + img.onload = () => { + if (!cancelled) setLoadedBg({ path: backgroundPath, image: img }) + } + img.onerror = () => { + if (!cancelled) toast.error('The saved background could not be loaded.') + } + img.src = getImageUrl(backgroundPath) + return () => { + cancelled = true + } + }, [backgroundPath]) + + // Derived, not stored: a background that no longer matches the saved path is + // a stale image, and painting it would export a scene the project dropped. + const bgImage = loadedBg?.path === backgroundPath ? loadedBg.image : null + + const handleBackgroundUpload = useCallback( + async (file: File) => { + setBgUploading(true) + const result = await upload(file, 'backgrounds') + setBgUploading(false) + if (!result) { + toast.error('Background upload failed — nothing was saved.') + return + } + // Only the background is stored here. Figure placement lives on the + // figures rows, and duplicating it into layout would give it two homes. + saveComposition.mutate( + { project_id: projectId, background_url: result.path }, + { onError: (err) => toast.error(err.message) } + ) + }, + [upload, saveComposition, projectId] + ) + // Build image URLs (synchronous — public bucket) const imageUrls: Record = {} for (const fig of figures) { @@ -228,12 +279,10 @@ export function CompositionCanvas({ projectId, figures, surface }: CompositionCa figures={figures} surface={surface} projectId={projectId} - onBackgroundUpload={(file) => { - const url = URL.createObjectURL(file) - const img = new window.Image() - img.onload = () => setBgImage(img) - img.src = url - }} + backgroundPending={ + bgUploading || compositionLoading || (!!backgroundPath && !bgImage) + } + onBackgroundUpload={handleBackgroundUpload} />
diff --git a/app/src/hooks/useComposition.ts b/app/src/hooks/useComposition.ts new file mode 100644 index 0000000..d441c2c --- /dev/null +++ b/app/src/hooks/useComposition.ts @@ -0,0 +1,35 @@ +'use client' + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import type { Composition } from '@/types/database' +import type { UpsertComposition } from '@/lib/schemas/validation' +import { fetchJson } from '@/lib/fetchJson' + +export function useComposition(projectId: string) { + return useQuery({ + queryKey: ['composition', projectId], + queryFn: async () => { + const res = await fetch(`/api/compositions?project_id=${projectId}`) + const json = await res.json() + if (!json.success && json.error) throw new Error(json.error) + return json.data ?? null + }, + enabled: !!projectId, + }) +} + +/** Each save inserts the next version — the table keeps the history by design. */ +export function useSaveComposition(projectId: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (data: UpsertComposition) => + fetchJson('/api/compositions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['composition', projectId] }) + }, + }) +} diff --git a/app/src/lib/api/wired.test.ts b/app/src/lib/api/wired.test.ts new file mode 100644 index 0000000..293d44c --- /dev/null +++ b/app/src/lib/api/wired.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' + +/** + * /api/compositions was written, authorized and versioned, and then no caller + * was ever added. The scene background the editor collected lived in a blob URL + * in React state instead: it disappeared on refresh, and the export went on + * reporting "print-ready" for a file whose entire background was missing. + * + * An endpoint the app never calls is a feature that silently does not exist. + * This checks the collection path of each route family, so an orphaned endpoint + * fails here instead of surfacing as lost work. + */ +describe('every API route family has a caller in the app', () => { + const srcDir = join(process.cwd(), 'src') + const apiDir = join(srcDir, 'app/api') + + function filesUnder(dir: string, ext: string[]): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return filesUnder(path, ext) + return ext.some(e => entry.name.endsWith(e)) ? [path] : [] + }) + } + + const routeFamilies = readdirSync(apiDir, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => entry.name) + + // Callers live outside the API layer — a route referencing itself proves nothing. + const callerSources = filesUnder(srcDir, ['.ts', '.tsx']) + .filter(file => !file.startsWith(apiDir) && !file.endsWith('.test.ts')) + .map(file => readFileSync(file, 'utf8')) + .join('\n') + + it('finds the route families', () => { + expect(routeFamilies.length).toBeGreaterThan(0) + }) + + it.each(routeFamilies)('/api/%s is called from the app', family => { + expect(callerSources).toContain(`/api/${family}`) + }) +})