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}`) + }) +})