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:
co-authored by
Claude Opus 4.6
parent
77f41ba8b7
commit
64de2a8e63
@@ -9,8 +9,6 @@ import {
|
|||||||
type ZoneNode,
|
type ZoneNode,
|
||||||
type ScanNode,
|
type ScanNode,
|
||||||
type GuideNode,
|
type GuideNode,
|
||||||
ScanNode as ScanNodeSchema,
|
|
||||||
GuideNode as GuideNodeSchema,
|
|
||||||
} from "@pascal-app/core";
|
} from "@pascal-app/core";
|
||||||
import { useViewer } from "@pascal-app/viewer";
|
import { useViewer } from "@pascal-app/viewer";
|
||||||
import {
|
import {
|
||||||
@@ -34,7 +32,9 @@ import useEditor from "@/store/use-editor";
|
|||||||
import { TreeNode } from "./tree-node";
|
import { TreeNode } from "./tree-node";
|
||||||
import { InlineRenameInput } from "./inline-rename-input";
|
import { InlineRenameInput } from "./inline-rename-input";
|
||||||
import { useProjectStore } from '@/features/community/lib/projects/store';
|
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 {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
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 }) {
|
function LevelReferences({ levelId, isLastLevel }: { levelId: string, isLastLevel?: boolean }) {
|
||||||
const nodes = useScene((s) => s.nodes);
|
const nodes = useScene((s) => s.nodes);
|
||||||
const createNode = useScene((s) => s.createNode);
|
|
||||||
const deleteNode = useScene((s) => s.deleteNode);
|
const deleteNode = useScene((s) => s.deleteNode);
|
||||||
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId);
|
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId);
|
||||||
const activeProject = useProjectStore((s) => s.activeProject);
|
const activeProject = useProjectStore((s) => s.activeProject);
|
||||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
const uploadState = useUploadStore((s) => s.uploads[levelId]);
|
||||||
const [uploading, setUploading] = useState(false);
|
const clearUpload = useUploadStore((s) => s.clearUpload);
|
||||||
const [uploadingType, setUploadingType] = useState<'scan'|'guide'|null>(null);
|
|
||||||
|
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<HTMLInputElement>(null);
|
const scanInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -383,19 +388,21 @@ function LevelReferences({ levelId, isLastLevel }: { levelId: string, isLastLeve
|
|||||||
(node.type === 'scan' || node.type === 'guide') && node.parentId === levelId,
|
(node.type === 'scan' || node.type === 'guide') && node.parentId === levelId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAddAsset = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleAddAsset = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
|
|
||||||
const projectId = activeProject?.id;
|
const projectId = activeProject?.id;
|
||||||
if (!projectId) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,32 +411,15 @@ function LevelReferences({ levelId, isLastLevel }: { levelId: string, isLastLeve
|
|||||||
const isImage = file.type.startsWith('image/');
|
const isImage = file.type.startsWith('image/');
|
||||||
|
|
||||||
if (!isScan && !isImage) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = isScan ? 'scan' : 'guide';
|
const type = isScan ? 'scan' : 'guide';
|
||||||
|
|
||||||
setUploadError(null);
|
clearUpload(levelId);
|
||||||
setUploading(true);
|
uploadAssetWithProgress(projectId, levelId, file, type);
|
||||||
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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (nodeId: string, e: React.MouseEvent) => {
|
const handleDelete = async (nodeId: string, e: React.MouseEvent) => {
|
||||||
@@ -473,7 +463,7 @@ function LevelReferences({ levelId, isLastLevel }: { levelId: string, isLastLeve
|
|||||||
onClick={() => scanInputRef.current?.click()}
|
onClick={() => scanInputRef.current?.click()}
|
||||||
>
|
>
|
||||||
{uploading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Plus className="w-3.5 h-3.5" />}
|
{uploading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Plus className="w-3.5 h-3.5" />}
|
||||||
{uploading ? `Uploading ${uploadingType}...` : "Upload scan/floorplan"}
|
{uploading ? `Uploading ${uploadingType}... ${progress}%` : "Upload scan/floorplan"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<input ref={scanInputRef} type="file" accept=".glb,.gltf,image/jpeg,image/png,image/webp,image/gif" className="hidden" onChange={handleAddAsset} />
|
<input ref={scanInputRef} type="file" accept=".glb,.gltf,image/jpeg,image/png,image/webp,image/gif" className="hidden" onChange={handleAddAsset} />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use server'
|
'use server'
|
||||||
|
|
||||||
|
import type { createClient } from '@supabase/supabase-js'
|
||||||
import { createServerSupabaseClient } from '../database/server'
|
import { createServerSupabaseClient } from '../database/server'
|
||||||
import { getSession } from '../auth/server'
|
import { getSession } from '../auth/server'
|
||||||
import { createId } from '../utils/id-generator'
|
import { createId } from '../utils/id-generator'
|
||||||
@@ -12,6 +13,14 @@ export type UploadAssetResult =
|
|||||||
| { success: true; url: string }
|
| { success: true; url: string }
|
||||||
| { success: false; error: 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 =
|
export type DeleteAssetResult =
|
||||||
| { success: true }
|
| { success: true }
|
||||||
| { success: false; error: string }
|
| { 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<CreateUploadUrlResult> {
|
||||||
|
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<typeof createClient>
|
||||||
|
).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<ConfirmUploadResult> {
|
||||||
|
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.
|
* Delete a project asset by its public URL.
|
||||||
* Removes both the storage file and the project_assets row.
|
* Removes both the storage file and the project_assets row.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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<string, UploadEntry>
|
||||||
|
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<UploadState>((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 }
|
||||||
|
}),
|
||||||
|
}))
|
||||||
Reference in New Issue
Block a user