Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions app/src/components/compose/CanvasToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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<HTMLInputElement>(null)
const [isExporting, setIsExporting] = useState(false)
const updateFigure = useUpdateFigure(projectId)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -150,13 +159,13 @@ export function CanvasToolbar({ stageRef, overlayRef, selectedId, figures, surfa

<div className="h-5 w-px bg-white/[0.08] mx-0.5 sm:mx-1" />

<Button variant="outline" size="sm" className="rounded-full h-8 text-xs sm:text-sm" onClick={() => bgInputRef.current?.click()}>
<Button variant="outline" size="sm" className="rounded-full h-8 text-xs sm:text-sm" disabled={backgroundPending} onClick={() => bgInputRef.current?.click()}>
<ImagePlus className="h-3.5 w-3.5 sm:mr-1.5" />
<span className="hidden sm:inline">Background</span>
<span className="hidden sm:inline">{backgroundPending ? 'Saving...' : 'Background'}</span>
</Button>
<input ref={bgInputRef} type="file" className="hidden" accept="image/*" onChange={handleBgFileChange} />

<Button variant="default" size="sm" className="rounded-full h-8 text-xs sm:text-sm" onClick={handleExportPng} disabled={isExporting}>
<Button variant="default" size="sm" className="rounded-full h-8 text-xs sm:text-sm" onClick={handleExportPng} disabled={isExporting || backgroundPending}>
<Download className="h-3.5 w-3.5 sm:mr-1.5" />
<span className="hidden sm:inline">{isExporting ? 'Exporting...' : `Export ${surface.dpi_target} DPI`}</span>
</Button>
Expand Down
63 changes: 56 additions & 7 deletions app/src/components/compose/CompositionCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
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'
Expand All @@ -25,8 +27,15 @@
const containerRef = useRef<HTMLDivElement>(null)
const [containerSize, setContainerSize] = useState({ width: 800, height: 600 })
const [selectedId, setSelectedId] = useState<string | null>(null)
const [bgImage, setBgImage] = useState<HTMLImageElement | null>(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
Expand All @@ -44,6 +53,48 @@
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<string, string> = {}
for (const fig of figures) {
Expand Down Expand Up @@ -89,7 +140,7 @@
onClick={() => setSelectedId(fig.id)}
>
<div className="w-10 h-10 rounded-lg overflow-hidden bg-white/[0.03] shrink-0">
<img src={url} alt={fig.label ?? 'Figure'} className="w-full h-full object-cover" />

Check warning on line 143 in app/src/components/compose/CompositionCanvas.tsx

View workflow job for this annotation

GitHub Actions / verify

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
</div>
<span className="text-xs text-foreground/80 truncate">{fig.label ?? 'Unnamed'}</span>
</button>
Expand Down Expand Up @@ -228,12 +279,10 @@
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}
/>
</div>
</div>
Expand Down
35 changes: 35 additions & 0 deletions app/src/hooks/useComposition.ts
Original file line number Diff line number Diff line change
@@ -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<Composition | null>({
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<Composition>('/api/compositions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['composition', projectId] })
},
})
}
44 changes: 44 additions & 0 deletions app/src/lib/api/wired.test.ts
Original file line number Diff line number Diff line change
@@ -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}`)
})
})
Loading