store reference assets on supabase
This commit is contained in:
@@ -5,12 +5,13 @@ import {
|
|||||||
type LevelNode,
|
type LevelNode,
|
||||||
type ScanNode,
|
type ScanNode,
|
||||||
ScanNode as ScanNodeSchema,
|
ScanNode as ScanNodeSchema,
|
||||||
saveAsset,
|
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { Box, Image, Pencil, Plus, Trash2 } from 'lucide-react'
|
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 useEditor from '@/store/use-editor'
|
||||||
|
import { deleteProjectAssetByUrl, uploadProjectAsset } from '@/features/community/lib/assets/actions'
|
||||||
|
import { useProjectStore } from '@/features/community/lib/projects/store'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -23,6 +24,8 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from '@/components/ui/primitives/popover'
|
} from '@/components/ui/primitives/popover'
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB — matches server action bodySizeLimit
|
||||||
|
|
||||||
interface ReferencesDialogProps {
|
interface ReferencesDialogProps {
|
||||||
levelId: string
|
levelId: string
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -34,6 +37,9 @@ export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDial
|
|||||||
const createNode = useScene((s) => s.createNode)
|
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 [uploadError, setUploadError] = useState<string | null>(null)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
|
||||||
const scanInputRef = useRef<HTMLInputElement>(null)
|
const scanInputRef = useRef<HTMLInputElement>(null)
|
||||||
const guideInputRef = useRef<HTMLInputElement>(null)
|
const guideInputRef = useRef<HTMLInputElement>(null)
|
||||||
@@ -42,38 +48,80 @@ export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDial
|
|||||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
if (!file) return
|
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({
|
const node = ScanNodeSchema.parse({
|
||||||
url,
|
url: result.url,
|
||||||
name: file.name,
|
name: file.name,
|
||||||
parentId: levelId,
|
parentId: levelId,
|
||||||
})
|
})
|
||||||
createNode(node, levelId as AnyNodeId)
|
createNode(node, levelId as AnyNodeId)
|
||||||
e.target.value = ''
|
|
||||||
// Auto-select and close dialog
|
// Auto-select and close dialog
|
||||||
setSelectedReferenceId(node.id)
|
setSelectedReferenceId(node.id)
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
},
|
},
|
||||||
[levelId, createNode, setSelectedReferenceId, onOpenChange],
|
[levelId, createNode, setSelectedReferenceId, onOpenChange, activeProject],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleAddGuide = useCallback(
|
const handleAddGuide = useCallback(
|
||||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
if (!file) return
|
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({
|
const node = GuideNodeSchema.parse({
|
||||||
url,
|
url: result.url,
|
||||||
name: file.name,
|
name: file.name,
|
||||||
parentId: levelId,
|
parentId: levelId,
|
||||||
})
|
})
|
||||||
createNode(node, levelId as AnyNodeId)
|
createNode(node, levelId as AnyNodeId)
|
||||||
e.target.value = ''
|
|
||||||
// Auto-select and close dialog
|
// Auto-select and close dialog
|
||||||
setSelectedReferenceId(node.id)
|
setSelectedReferenceId(node.id)
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
},
|
},
|
||||||
[levelId, createNode, setSelectedReferenceId, onOpenChange],
|
[levelId, createNode, setSelectedReferenceId, onOpenChange, activeProject],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleEdit = useCallback(
|
const handleEdit = useCallback(
|
||||||
@@ -86,9 +134,20 @@ export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDial
|
|||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
(nodeId: string) => {
|
(nodeId: string) => {
|
||||||
|
const refNode = nodes[nodeId as AnyNodeId] as ScanNode | GuideNode | undefined
|
||||||
deleteNode(nodeId as AnyNodeId)
|
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
|
const level = nodes[levelId as AnyNodeId] as LevelNode | undefined
|
||||||
@@ -145,6 +204,10 @@ export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDial
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<div className="flex justify-end pt-2 border-t border-border/50">
|
||||||
<input
|
<input
|
||||||
ref={scanInputRef}
|
ref={scanInputRef}
|
||||||
@@ -163,9 +226,12 @@ export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDial
|
|||||||
|
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<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" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
Add
|
{uploading ? 'Uploading…' : 'Add'}
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent align="end" className="w-44 p-1">
|
<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)
|
const { error } = await supabase.from('projects').delete().eq('id', projectId)
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -29,5 +29,13 @@ export async function register() {
|
|||||||
})
|
})
|
||||||
console.log('Created "project-thumbnails" storage bucket')
|
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')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const nextConfig: NextConfig = {
|
|||||||
transpilePackages: ['three', '@pascal-app/viewer', '@pascal-app/core'],
|
transpilePackages: ['three', '@pascal-app/viewer', '@pascal-app/core'],
|
||||||
experimental: {
|
experimental: {
|
||||||
serverActions: {
|
serverActions: {
|
||||||
bodySizeLimit: '10mb',
|
bodySizeLimit: '100mb',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export * from './feedback/feedback'
|
|||||||
|
|
||||||
// Project tables
|
// Project tables
|
||||||
export * from './projects/addresses'
|
export * from './projects/addresses'
|
||||||
|
export * from './projects/assets'
|
||||||
export * from './projects/likes'
|
export * from './projects/likes'
|
||||||
export * from './projects/models'
|
export * from './projects/models'
|
||||||
export * from './projects/projects'
|
export * from './projects/projects'
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { relations } from 'drizzle-orm'
|
||||||
|
import { pgTable } from 'drizzle-orm/pg-core'
|
||||||
|
import { id, timestampsColumns } from '../../helpers'
|
||||||
|
import { projects } from './projects'
|
||||||
|
|
||||||
|
export const projectAssets = pgTable('project_assets', (t) => ({
|
||||||
|
id: id('asset'),
|
||||||
|
projectId: t.text('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }),
|
||||||
|
storageKey: t.text('storage_key').notNull(),
|
||||||
|
url: t.text('url').notNull(),
|
||||||
|
type: t.text('type').notNull(), // 'scan' | 'guide'
|
||||||
|
originalName: t.text('original_name'),
|
||||||
|
mimeType: t.text('mime_type'),
|
||||||
|
...timestampsColumns,
|
||||||
|
})).enableRLS()
|
||||||
|
|
||||||
|
export const projectAssetsRelations = relations(projectAssets, ({ one }) => ({
|
||||||
|
project: one(projects, {
|
||||||
|
fields: [projectAssets.projectId],
|
||||||
|
references: [projects.id],
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
export type ProjectAsset = typeof projectAssets.$inferSelect
|
||||||
|
export type NewProjectAsset = typeof projectAssets.$inferInsert
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE "project_assets" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"project_id" text NOT NULL,
|
||||||
|
"storage_key" text NOT NULL,
|
||||||
|
"url" text NOT NULL,
|
||||||
|
"type" text NOT NULL,
|
||||||
|
"original_name" text,
|
||||||
|
"mime_type" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "project_assets" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||||
|
ALTER TABLE "project_assets" ADD CONSTRAINT "project_assets_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,13 @@
|
|||||||
"when": 1771911353115,
|
"when": 1771911353115,
|
||||||
"tag": "20260224053553_stormy_carnage",
|
"tag": "20260224053553_stormy_carnage",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1772065554845,
|
||||||
|
"tag": "20260226002554_big_pestilence",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user