store reference assets on supabase

This commit is contained in:
wass08
2026-02-26 09:56:50 +09:00
parent 1117e0cb5f
commit b167702bca
10 changed files with 1438 additions and 15 deletions
@@ -5,12 +5,13 @@ import {
type LevelNode,
type ScanNode,
ScanNode as ScanNodeSchema,
saveAsset,
useScene,
} from '@pascal-app/core'
import { Box, Image, Pencil, Plus, Trash2 } from 'lucide-react'
import { useCallback, useRef } from 'react'
import { useCallback, useRef, useState } from 'react'
import useEditor from '@/store/use-editor'
import { deleteProjectAssetByUrl, uploadProjectAsset } from '@/features/community/lib/assets/actions'
import { useProjectStore } from '@/features/community/lib/projects/store'
import {
Dialog,
DialogContent,
@@ -23,6 +24,8 @@ import {
PopoverTrigger,
} from '@/components/ui/primitives/popover'
const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB — matches server action bodySizeLimit
interface ReferencesDialogProps {
levelId: string
open: boolean
@@ -34,6 +37,9 @@ export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDial
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<string | null>(null)
const [uploading, setUploading] = useState(false)
const scanInputRef = useRef<HTMLInputElement>(null)
const guideInputRef = useRef<HTMLInputElement>(null)
@@ -42,38 +48,80 @@ export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDial
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const url = await saveAsset(file)
e.target.value = ''
const projectId = activeProject?.id
if (!projectId || projectId.startsWith('local_')) {
setUploadError('Save your project to the cloud first to add references.')
return
}
if (file.size > MAX_FILE_SIZE) {
setUploadError(`File is too large (${(file.size / 1024 / 1024).toFixed(0)} MB). Maximum size is 100 MB.`)
return
}
setUploadError(null)
setUploading(true)
const result = await uploadProjectAsset(projectId, file, 'scan')
setUploading(false)
if (!result.success) {
setUploadError(result.error)
return
}
const node = ScanNodeSchema.parse({
url,
url: result.url,
name: file.name,
parentId: levelId,
})
createNode(node, levelId as AnyNodeId)
e.target.value = ''
// Auto-select and close dialog
setSelectedReferenceId(node.id)
onOpenChange(false)
},
[levelId, createNode, setSelectedReferenceId, onOpenChange],
[levelId, createNode, setSelectedReferenceId, onOpenChange, activeProject],
)
const handleAddGuide = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const url = await saveAsset(file)
e.target.value = ''
const projectId = activeProject?.id
if (!projectId || projectId.startsWith('local_')) {
setUploadError('Save your project to the cloud first to add references.')
return
}
if (file.size > MAX_FILE_SIZE) {
setUploadError(`File is too large (${(file.size / 1024 / 1024).toFixed(0)} MB). Maximum size is 100 MB.`)
return
}
setUploadError(null)
setUploading(true)
const result = await uploadProjectAsset(projectId, file, 'guide')
setUploading(false)
if (!result.success) {
setUploadError(result.error)
return
}
const node = GuideNodeSchema.parse({
url,
url: result.url,
name: file.name,
parentId: levelId,
})
createNode(node, levelId as AnyNodeId)
e.target.value = ''
// Auto-select and close dialog
setSelectedReferenceId(node.id)
onOpenChange(false)
},
[levelId, createNode, setSelectedReferenceId, onOpenChange],
[levelId, createNode, setSelectedReferenceId, onOpenChange, activeProject],
)
const handleEdit = useCallback(
@@ -86,9 +134,20 @@ export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDial
const handleDelete = useCallback(
(nodeId: string) => {
const refNode = nodes[nodeId as AnyNodeId] as ScanNode | GuideNode | undefined
deleteNode(nodeId as AnyNodeId)
// Fire-and-forget storage cleanup for Supabase-hosted assets
const projectId = activeProject?.id
if (
projectId &&
!projectId.startsWith('local_') &&
refNode?.url &&
refNode.url.startsWith('https://')
) {
deleteProjectAssetByUrl(projectId, refNode.url).catch(console.error)
}
},
[deleteNode],
[deleteNode, nodes, activeProject],
)
const level = nodes[levelId as AnyNodeId] as LevelNode | undefined
@@ -145,6 +204,10 @@ export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDial
))}
</div>
{uploadError && (
<p className="text-xs text-destructive px-1 pb-1">{uploadError}</p>
)}
<div className="flex justify-end pt-2 border-t border-border/50">
<input
ref={scanInputRef}
@@ -163,9 +226,12 @@ export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDial
<Popover>
<PopoverTrigger asChild>
<button className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm bg-primary text-primary-foreground hover:bg-primary/90 cursor-pointer">
<button
disabled={uploading}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm bg-primary text-primary-foreground hover:bg-primary/90 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="w-3.5 h-3.5" />
Add
{uploading ? 'Uploading…' : 'Add'}
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-1">
@@ -0,0 +1,164 @@
'use server'
import { createServerSupabaseClient } from '../database/server'
import { getSession } from '../auth/server'
import { createId } from '../utils/id-generator'
const BUCKET = 'project-assets'
export type AssetType = 'scan' | 'guide'
export type UploadAssetResult =
| { success: true; url: string }
| { success: false; error: string }
export type DeleteAssetResult =
| { success: true }
| { success: false; error: string }
/**
* Upload a scan or guide file to Supabase Storage and record it in project_assets.
* Returns the public HTTPS URL that can be stored directly on the scene node.
*/
export async function uploadProjectAsset(
projectId: string,
file: File,
type: AssetType,
): Promise<UploadAssetResult> {
try {
const session = await getSession()
if (!session?.user?.id) {
return { success: false, error: 'Not authenticated' }
}
const supabase = await createServerSupabaseClient()
// Verify the user owns this project
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' }
}
// Derive extension from file name
const ext = file.name.includes('.') ? file.name.split('.').pop()! : ''
const assetId = createId('asset')
const storageKey = ext ? `${projectId}/${assetId}.${ext}` : `${projectId}/${assetId}`
const arrayBuffer = await file.arrayBuffer()
const bytes = new Uint8Array(arrayBuffer)
const { data: uploadData, error: uploadError } = await supabase.storage
.from(BUCKET)
.upload(storageKey, bytes, {
contentType: file.type || 'application/octet-stream',
upsert: false,
})
if (uploadError) {
return { success: false, error: `Upload failed: ${uploadError.message}` }
}
const { data: urlData } = supabase.storage
.from(BUCKET)
.getPublicUrl(uploadData.path)
const url = urlData.publicUrl
// Record in project_assets table
const { error: insertError } = await (supabase.from('project_assets') as any).insert({
id: assetId,
project_id: projectId,
storage_key: storageKey,
url,
type,
original_name: file.name,
mime_type: file.type || null,
})
if (insertError) {
// Best-effort cleanup: remove the uploaded file
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 upload asset',
}
}
}
/**
* Delete a project asset by its public URL.
* Removes both the storage file and the project_assets row.
*/
export async function deleteProjectAssetByUrl(
projectId: string,
url: string,
): Promise<DeleteAssetResult> {
try {
const session = await getSession()
if (!session?.user?.id) {
return { success: false, error: 'Not authenticated' }
}
const supabase = await createServerSupabaseClient()
// Verify ownership
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' }
}
// Look up the asset row by url + projectId
const { data: asset, error: fetchError } = await (supabase.from('project_assets') as any)
.select('id, storage_key')
.eq('project_id', projectId)
.eq('url', url)
.maybeSingle()
if (fetchError) {
return { success: false, error: fetchError.message }
}
if (!asset) {
// Nothing to delete — treat as success
return { success: true }
}
// Remove from storage
await supabase.storage.from(BUCKET).remove([(asset as any).storage_key])
// Delete row
await (supabase.from('project_assets') as any)
.delete()
.eq('id', (asset as any).id)
return { success: true }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to delete asset',
}
}
}
@@ -911,7 +911,17 @@ export async function deleteProject(projectId: string): Promise<ActionResult> {
}
}
// Delete the project (cascade will delete related records)
// Delete project asset files from storage before deleting the project
const { data: assets } = await (supabase.from('project_assets') as any)
.select('storage_key')
.eq('project_id', projectId)
if (assets && assets.length > 0) {
const storageKeys = (assets as { storage_key: string }[]).map((a) => a.storage_key)
await supabase.storage.from('project-assets').remove(storageKeys)
}
// Delete the project (cascade will delete related records including project_assets rows)
const { error } = await supabase.from('projects').delete().eq('id', projectId)
if (error) {
+8
View File
@@ -29,5 +29,13 @@ export async function register() {
})
console.log('Created "project-thumbnails" storage bucket')
}
if (!bucketNames.has('project-assets')) {
await supabase.storage.createBucket('project-assets', {
public: true,
fileSizeLimit: 500 * 1024 * 1024, // 500MB for GLB/GLTF scans
})
console.log('Created "project-assets" storage bucket')
}
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ const nextConfig: NextConfig = {
transpilePackages: ['three', '@pascal-app/viewer', '@pascal-app/core'],
experimental: {
serverActions: {
bodySizeLimit: '10mb',
bodySizeLimit: '100mb',
},
},
images: {