From 64de2a8e63fcde8e9c63791f30974032472baf61 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Fri, 27 Feb 2026 14:19:06 -0500 Subject: [PATCH] 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 --- .../ui/sidebar/panels/site-panel/index.tsx | 58 ++++---- .../features/community/lib/assets/actions.ts | 128 ++++++++++++++++++ apps/editor/lib/upload-asset.ts | 123 +++++++++++++++++ apps/editor/store/use-upload.ts | 68 ++++++++++ 4 files changed, 343 insertions(+), 34 deletions(-) create mode 100644 apps/editor/lib/upload-asset.ts create mode 100644 apps/editor/store/use-upload.ts diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx index 97f4b4e8..cf15eea1 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx @@ -9,8 +9,6 @@ import { type ZoneNode, type ScanNode, type GuideNode, - ScanNode as ScanNodeSchema, - GuideNode as GuideNodeSchema, } from "@pascal-app/core"; import { useViewer } from "@pascal-app/viewer"; import { @@ -34,7 +32,9 @@ import useEditor from "@/store/use-editor"; import { TreeNode } from "./tree-node"; import { InlineRenameInput } from "./inline-rename-input"; import { useProjectStore } from '@/features/community/lib/projects/store'; -import { deleteProjectAssetByUrl, uploadProjectAsset } from '@/features/community/lib/assets/actions'; +import { deleteProjectAssetByUrl } from '@/features/community/lib/assets/actions'; +import { useUploadStore } from '@/store/use-upload'; +import { uploadAssetWithProgress } from '@/lib/upload-asset'; import { Popover, PopoverContent, @@ -364,17 +364,22 @@ function ReferenceItem({ refNode, isLastRow, setSelectedReferenceId, handleDelet ); } -const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB +const MAX_FILE_SIZE = 200 * 1024 * 1024; // 200MB function LevelReferences({ levelId, isLastLevel }: { levelId: string, isLastLevel?: boolean }) { const nodes = useScene((s) => s.nodes); - const createNode = useScene((s) => s.createNode); const deleteNode = useScene((s) => s.deleteNode); const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId); const activeProject = useProjectStore((s) => s.activeProject); - const [uploadError, setUploadError] = useState(null); - const [uploading, setUploading] = useState(false); - const [uploadingType, setUploadingType] = useState<'scan'|'guide'|null>(null); + const uploadState = useUploadStore((s) => s.uploads[levelId]); + const clearUpload = useUploadStore((s) => s.clearUpload); + + const uploading = uploadState?.status === 'preparing' || + uploadState?.status === 'uploading' || + uploadState?.status === 'confirming'; + const uploadingType = uploadState?.assetType ?? null; + const uploadError = uploadState?.error ?? null; + const progress = uploadState?.progress ?? 0; const scanInputRef = useRef(null); @@ -383,53 +388,38 @@ function LevelReferences({ levelId, isLastLevel }: { levelId: string, isLastLeve (node.type === 'scan' || node.type === 'guide') && node.parentId === levelId, ); - const handleAddAsset = async (e: React.ChangeEvent) => { + const handleAddAsset = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; e.target.value = ''; const projectId = activeProject?.id; if (!projectId) { - setUploadError('No active project. Please open a project first.'); + useUploadStore.getState().startUpload(levelId, 'scan', file.name); + useUploadStore.getState().setError(levelId, 'No active project. Please open a project first.'); return; } if (file.size > MAX_FILE_SIZE) { - setUploadError(`File is too large (${(file.size / 1024 / 1024).toFixed(0)} MB). Maximum size is 100 MB.`); + useUploadStore.getState().startUpload(levelId, 'scan', file.name); + useUploadStore.getState().setError(levelId, `File is too large (${(file.size / 1024 / 1024).toFixed(0)} MB). Maximum size is 200 MB.`); return; } // Auto-detect type based on file extension/mime type const isScan = file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf'); const isImage = file.type.startsWith('image/'); - + if (!isScan && !isImage) { - setUploadError('Invalid file type. Please upload a .glb/.gltf scan or an image.'); + useUploadStore.getState().startUpload(levelId, 'scan', file.name); + useUploadStore.getState().setError(levelId, 'Invalid file type. Please upload a .glb/.gltf scan or an image.'); return; } const type = isScan ? 'scan' : 'guide'; - setUploadError(null); - setUploading(true); - setUploadingType(type); - const result = await uploadProjectAsset(projectId, file, type); - setUploading(false); - setUploadingType(null); - - if (!result.success) { - setUploadError(result.error); - return; - } - - const Schema = type === 'scan' ? ScanNodeSchema : GuideNodeSchema; - const node = Schema.parse({ - url: result.url, - name: file.name, - parentId: levelId, - }); - createNode(node, levelId as AnyNodeId); - setSelectedReferenceId(node.id); + clearUpload(levelId); + uploadAssetWithProgress(projectId, levelId, file, type); }; const handleDelete = async (nodeId: string, e: React.MouseEvent) => { @@ -473,7 +463,7 @@ function LevelReferences({ levelId, isLastLevel }: { levelId: string, isLastLeve onClick={() => scanInputRef.current?.click()} > {uploading ? : } - {uploading ? `Uploading ${uploadingType}...` : "Upload scan/floorplan"} + {uploading ? `Uploading ${uploadingType}... ${progress}%` : "Upload scan/floorplan"} diff --git a/apps/editor/features/community/lib/assets/actions.ts b/apps/editor/features/community/lib/assets/actions.ts index 55a0ef00..3414e8a0 100644 --- a/apps/editor/features/community/lib/assets/actions.ts +++ b/apps/editor/features/community/lib/assets/actions.ts @@ -1,5 +1,6 @@ 'use server' +import type { createClient } from '@supabase/supabase-js' import { createServerSupabaseClient } from '../database/server' import { getSession } from '../auth/server' import { createId } from '../utils/id-generator' @@ -12,6 +13,14 @@ export type UploadAssetResult = | { success: true; url: string } | { success: false; error: string } +export type CreateUploadUrlResult = + | { success: true; signedUrl: string; storageKey: string; assetId: string } + | { success: false; error: string } + +export type ConfirmUploadResult = + | { success: true; url: string } + | { success: false; error: string } + export type DeleteAssetResult = | { success: true } | { success: false; error: string } @@ -99,6 +108,125 @@ export async function uploadProjectAsset( } } +/** + * Create a signed upload URL so the client can upload directly to Supabase Storage. + * Bypasses Next.js body-size limits — supports files up to the bucket limit (500 MB). + */ +export async function createAssetUploadUrl( + projectId: string, + fileName: string, + contentType: string, + type: AssetType, +): Promise { + try { + const session = await getSession() + if (!session?.user?.id) { + return { success: false, error: 'Not authenticated' } + } + + const supabase = await createServerSupabaseClient() + + const { data: project, error: projectError } = await supabase + .from('projects') + .select('owner_id') + .eq('id', projectId) + .single() + + if (projectError || !project) { + return { success: false, error: 'Project not found' } + } + + if ((project as any).owner_id !== session.user.id) { + return { success: false, error: 'Not authorized to upload to this project' } + } + + const ext = fileName.includes('.') ? fileName.split('.').pop()! : '' + const assetId = createId('asset') + const storageKey = ext ? `${projectId}/${assetId}.${ext}` : `${projectId}/${assetId}` + + const { data, error } = await ( + supabase as ReturnType + ).storage + .from(BUCKET) + .createSignedUploadUrl(storageKey) + + if (error || !data) { + return { success: false, error: `Failed to create upload URL: ${error?.message}` } + } + + return { success: true, signedUrl: data.signedUrl, storageKey, assetId } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to create upload URL', + } + } +} + +/** + * Record a successfully uploaded asset in the project_assets table. + * Called after the client uploads the file directly to Supabase Storage. + */ +export async function confirmAssetUpload( + projectId: string, + assetId: string, + storageKey: string, + originalName: string, + mimeType: string | null, + type: AssetType, +): Promise { + try { + const session = await getSession() + if (!session?.user?.id) { + return { success: false, error: 'Not authenticated' } + } + + const supabase = await createServerSupabaseClient() + + const { data: project, error: projectError } = await supabase + .from('projects') + .select('owner_id') + .eq('id', projectId) + .single() + + if (projectError || !project) { + return { success: false, error: 'Project not found' } + } + + if ((project as any).owner_id !== session.user.id) { + return { success: false, error: 'Not authorized' } + } + + const { data: urlData } = supabase.storage + .from(BUCKET) + .getPublicUrl(storageKey) + + const url = urlData.publicUrl + + const { error: insertError } = await (supabase.from('project_assets') as any).insert({ + id: assetId, + project_id: projectId, + storage_key: storageKey, + url, + type, + original_name: originalName, + mime_type: mimeType, + }) + + if (insertError) { + await supabase.storage.from(BUCKET).remove([storageKey]) + return { success: false, error: `Failed to record asset: ${insertError.message}` } + } + + return { success: true, url } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to confirm upload', + } + } +} + /** * Delete a project asset by its public URL. * Removes both the storage file and the project_assets row. diff --git a/apps/editor/lib/upload-asset.ts b/apps/editor/lib/upload-asset.ts new file mode 100644 index 00000000..29860f3a --- /dev/null +++ b/apps/editor/lib/upload-asset.ts @@ -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((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) +} diff --git a/apps/editor/store/use-upload.ts b/apps/editor/store/use-upload.ts new file mode 100644 index 00000000..1c41c212 --- /dev/null +++ b/apps/editor/store/use-upload.ts @@ -0,0 +1,68 @@ +import { create } from 'zustand' + +export type UploadStatus = 'preparing' | 'uploading' | 'confirming' | 'done' | 'error' + +export interface UploadEntry { + status: UploadStatus + assetType: 'scan' | 'guide' + fileName: string + progress: number // 0-100 + error: string | null + resultUrl: string | null +} + +interface UploadState { + uploads: Record + startUpload: (levelId: string, assetType: 'scan' | 'guide', fileName: string) => void + setProgress: (levelId: string, progress: number) => void + setStatus: (levelId: string, status: UploadStatus) => void + setError: (levelId: string, error: string) => void + setResult: (levelId: string, url: string) => void + clearUpload: (levelId: string) => void +} + +export const useUploadStore = create((set) => ({ + uploads: {}, + + startUpload: (levelId, assetType, fileName) => + set((s) => ({ + uploads: { + ...s.uploads, + [levelId]: { status: 'preparing', assetType, fileName, progress: 0, error: null, resultUrl: null }, + }, + })), + + setProgress: (levelId, progress) => + set((s) => { + const entry = s.uploads[levelId] + if (!entry) return s + return { uploads: { ...s.uploads, [levelId]: { ...entry, progress } } } + }), + + setStatus: (levelId, status) => + set((s) => { + const entry = s.uploads[levelId] + if (!entry) return s + return { uploads: { ...s.uploads, [levelId]: { ...entry, status } } } + }), + + setError: (levelId, error) => + set((s) => { + const entry = s.uploads[levelId] + if (!entry) return s + return { uploads: { ...s.uploads, [levelId]: { ...entry, status: 'error' as const, error } } } + }), + + setResult: (levelId, url) => + set((s) => { + const entry = s.uploads[levelId] + if (!entry) return s + return { uploads: { ...s.uploads, [levelId]: { ...entry, status: 'done' as const, resultUrl: url } } } + }), + + clearUpload: (levelId) => + set((s) => { + const { [levelId]: _, ...rest } = s.uploads + return { uploads: rest } + }), +}))