feat(editor): direct-to-Supabase uploads with progress for large scans

Upload files directly to Supabase Storage via signed URLs, bypassing
the Next.js 100MB body size limit. Supports scans up to 200MB with
real-time progress tracking that persists across sidebar panel switches.

- Add Zustand upload store for persistent upload state
- Add createAssetUploadUrl/confirmAssetUpload server actions
- Add XHR-based upload orchestrator with progress events
- Scene node creation works even if sidebar panel unmounts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-02-27 14:19:06 -05:00
co-authored by Claude Opus 4.6
parent 77f41ba8b7
commit 64de2a8e63
4 changed files with 343 additions and 34 deletions
+123
View File
@@ -0,0 +1,123 @@
import {
type AnyNodeId,
ScanNode as ScanNodeSchema,
GuideNode as GuideNodeSchema,
useScene,
} from '@pascal-app/core'
import {
createAssetUploadUrl,
confirmAssetUpload,
type AssetType,
} from '@/features/community/lib/assets/actions'
import { useUploadStore } from '@/store/use-upload'
import useEditor from '@/store/use-editor'
/**
* Upload a file directly to Supabase Storage via signed URL with progress tracking.
* Runs entirely outside React — survives component unmounts.
*/
export function uploadAssetWithProgress(
projectId: string,
levelId: string,
file: File,
assetType: AssetType,
) {
const store = useUploadStore.getState()
store.startUpload(levelId, assetType, file.name)
// Run async work without blocking the caller
doUpload(projectId, levelId, file, assetType).catch(() => {
// errors are already recorded in the store by doUpload
})
}
async function doUpload(
projectId: string,
levelId: string,
file: File,
assetType: AssetType,
) {
const store = () => useUploadStore.getState()
// Phase 1: Get signed URL
const urlResult = await createAssetUploadUrl(
projectId,
file.name,
file.type || 'application/octet-stream',
assetType,
)
if (!urlResult.success) {
store().setError(levelId, urlResult.error)
return
}
// Phase 2: Upload directly to Supabase via XHR (for progress)
store().setStatus(levelId, 'uploading')
try {
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100)
useUploadStore.getState().setProgress(levelId, pct)
}
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve()
} else {
reject(new Error(`Upload failed: HTTP ${xhr.status}`))
}
})
xhr.addEventListener('error', () => reject(new Error('Network error during upload')))
xhr.addEventListener('abort', () => reject(new Error('Upload aborted')))
xhr.open('PUT', urlResult.signedUrl)
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')
xhr.send(file)
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'Upload failed'
store().setError(levelId, msg)
return
}
// Phase 3: Confirm upload and record in DB
store().setStatus(levelId, 'confirming')
const confirmResult = await confirmAssetUpload(
projectId,
urlResult.assetId,
urlResult.storageKey,
file.name,
file.type || null,
assetType,
)
if (!confirmResult.success) {
store().setError(levelId, confirmResult.error)
return
}
// Phase 4: Create scene node (works even if component is unmounted)
const Schema = assetType === 'scan' ? ScanNodeSchema : GuideNodeSchema
const node = Schema.parse({
url: confirmResult.url,
name: file.name,
parentId: levelId,
})
useScene.getState().createNode(node, levelId as AnyNodeId)
useEditor.getState().setSelectedReferenceId(node.id)
store().setResult(levelId, confirmResult.url)
// Auto-clear after a short delay so the UI shows "done" briefly
setTimeout(() => {
useUploadStore.getState().clearUpload(levelId)
}, 1500)
}