From 1117e0cb5f282ac5b2c365840d7c722d2beb3be3 Mon Sep 17 00:00:00 2001 From: wass08 Date: Thu, 26 Feb 2026 08:03:05 +0900 Subject: [PATCH 1/6] prevent 0m long wall creation #103 --- apps/editor/components/tools/wall/wall-tool.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/editor/components/tools/wall/wall-tool.tsx b/apps/editor/components/tools/wall/wall-tool.tsx index abe04962..8dc325fa 100644 --- a/apps/editor/components/tools/wall/wall-tool.tsx +++ b/apps/editor/components/tools/wall/wall-tool.tsx @@ -143,6 +143,9 @@ export const WallTool: React.FC = () => { buildingState.current = 1 wallPreviewRef.current.visible = true } else if (buildingState.current === 1) { + const dx = endingPoint.current.x - startingPoint.current.x + const dz = endingPoint.current.z - startingPoint.current.z + if (dx * dx + dz * dz < 0.01 * 0.01) return commitWallDrawing( [startingPoint.current.x, startingPoint.current.z], [endingPoint.current.x, endingPoint.current.z], From b167702bcab5547967f50b6c11dc5d70f47aeecd Mon Sep 17 00:00:00 2001 From: wass08 Date: Thu, 26 Feb 2026 09:35:01 +0900 Subject: [PATCH 2/6] store reference assets on supabase --- .../panels/site-panel/references-dialog.tsx | 92 +- .../features/community/lib/assets/actions.ts | 164 +++ .../community/lib/projects/actions.ts | 12 +- apps/editor/instrumentation.ts | 8 + apps/editor/next.config.ts | 2 +- packages/db/src/schema/index.ts | 1 + packages/db/src/schema/projects/assets.ts | 25 + .../20260226002554_big_pestilence.sql | 14 + .../meta/20260226002554_snapshot.json | 1128 +++++++++++++++++ supabase/migrations/meta/_journal.json | 7 + 10 files changed, 1438 insertions(+), 15 deletions(-) create mode 100644 apps/editor/features/community/lib/assets/actions.ts create mode 100644 packages/db/src/schema/projects/assets.ts create mode 100644 supabase/migrations/20260226002554_big_pestilence.sql create mode 100644 supabase/migrations/meta/20260226002554_snapshot.json diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/references-dialog.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/references-dialog.tsx index 6d384fff..590a8db1 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/references-dialog.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/references-dialog.tsx @@ -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(null) + const [uploading, setUploading] = useState(false) const scanInputRef = useRef(null) const guideInputRef = useRef(null) @@ -42,38 +48,80 @@ export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDial async (e: React.ChangeEvent) => { 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) => { 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 ))} + {uploadError && ( +

{uploadError}

+ )} +
- diff --git a/apps/editor/features/community/lib/assets/actions.ts b/apps/editor/features/community/lib/assets/actions.ts new file mode 100644 index 00000000..f39e9748 --- /dev/null +++ b/apps/editor/features/community/lib/assets/actions.ts @@ -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 { + 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 { + 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', + } + } +} diff --git a/apps/editor/features/community/lib/projects/actions.ts b/apps/editor/features/community/lib/projects/actions.ts index 1c3a1ded..0bc37057 100644 --- a/apps/editor/features/community/lib/projects/actions.ts +++ b/apps/editor/features/community/lib/projects/actions.ts @@ -911,7 +911,17 @@ export async function deleteProject(projectId: string): Promise { } } - // 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) { diff --git a/apps/editor/instrumentation.ts b/apps/editor/instrumentation.ts index a5d0ddca..81648c17 100644 --- a/apps/editor/instrumentation.ts +++ b/apps/editor/instrumentation.ts @@ -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') + } } } diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts index bd08139b..7e9dfaaa 100644 --- a/apps/editor/next.config.ts +++ b/apps/editor/next.config.ts @@ -4,7 +4,7 @@ const nextConfig: NextConfig = { transpilePackages: ['three', '@pascal-app/viewer', '@pascal-app/core'], experimental: { serverActions: { - bodySizeLimit: '10mb', + bodySizeLimit: '100mb', }, }, images: { diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 745b8a7a..65773f88 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -10,6 +10,7 @@ export * from './feedback/feedback' // Project tables export * from './projects/addresses' +export * from './projects/assets' export * from './projects/likes' export * from './projects/models' export * from './projects/projects' diff --git a/packages/db/src/schema/projects/assets.ts b/packages/db/src/schema/projects/assets.ts new file mode 100644 index 00000000..ef2d7444 --- /dev/null +++ b/packages/db/src/schema/projects/assets.ts @@ -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 diff --git a/supabase/migrations/20260226002554_big_pestilence.sql b/supabase/migrations/20260226002554_big_pestilence.sql new file mode 100644 index 00000000..3fdc542f --- /dev/null +++ b/supabase/migrations/20260226002554_big_pestilence.sql @@ -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; \ No newline at end of file diff --git a/supabase/migrations/meta/20260226002554_snapshot.json b/supabase/migrations/meta/20260226002554_snapshot.json new file mode 100644 index 00000000..16b6250f --- /dev/null +++ b/supabase/migrations/meta/20260226002554_snapshot.json @@ -0,0 +1,1128 @@ +{ + "id": "0d2d0fe5-900e-410c-aa0b-e44aa124e5de", + "prevId": "709551b6-3bd6-4cc4-bf0f-683ccc08b2b9", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_accounts": { + "name": "auth_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "auth_accounts_user_id_auth_users_id_fk": { + "name": "auth_accounts_user_id_auth_users_id_fk", + "tableFrom": "auth_accounts", + "tableTo": "auth_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.auth_jwks": { + "name": "auth_jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.auth_sessions": { + "name": "auth_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_project_id": { + "name": "active_project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "auth_sessions_user_id_auth_users_id_fk": { + "name": "auth_sessions_user_id_auth_users_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "auth_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auth_sessions_impersonated_by_auth_users_id_fk": { + "name": "auth_sessions_impersonated_by_auth_users_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "auth_users", + "columnsFrom": [ + "impersonated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.auth_users": { + "name": "auth_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_url": { + "name": "github_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "x_url": { + "name": "x_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "youtube_url": { + "name": "youtube_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_notifications": { + "name": "email_notifications", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "role": { + "name": "role", + "type": "auth_user_roles", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_unique_index": { + "name": "email_unique_index", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "username_unique_index": { + "name": "username_unique_index", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.auth_verifications": { + "name": "auth_verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_index": { + "name": "verification_identifier_index", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.feedback": { + "name": "feedback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scene_graph": { + "name": "scene_graph", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.projects_addresses": { + "name": "projects_addresses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "street_number": { + "name": "street_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "route": { + "name": "route", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "route_short": { + "name": "route_short", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "neighborhood": { + "name": "neighborhood", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "county": { + "name": "county", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_long": { + "name": "state_long", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postal_code_suffix": { + "name": "postal_code_suffix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_long": { + "name": "country_long", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "longitude": { + "name": "longitude", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "raw_json": { + "name": "raw_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "address_components_unique": { + "name": "address_components_unique", + "nullsNotDistinct": false, + "columns": [ + "street_number", + "route", + "city", + "state", + "postal_code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.project_assets": { + "name": "project_assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_assets_project_id_projects_id_fk": { + "name": "project_assets_project_id_projects_id_fk", + "tableFrom": "project_assets", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.projects_likes": { + "name": "projects_likes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_likes_project_id_projects_id_fk": { + "name": "projects_likes_project_id_projects_id_fk", + "tableFrom": "projects_likes", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_likes_project_user_unique": { + "name": "projects_likes_project_user_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.projects_models": { + "name": "projects_models", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "draft": { + "name": "draft", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scene_graph": { + "name": "scene_graph", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "projects_models_project_id_projects_id_fk": { + "name": "projects_models_project_id_projects_id_fk", + "tableFrom": "projects_models", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_id": { + "name": "address_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details_json": { + "name": "details_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_scans_public": { + "name": "show_scans_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_guides_public": { + "name": "show_guides_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "likes": { + "name": "likes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_address_idx": { + "name": "project_address_idx", + "columns": [ + { + "expression": "address_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_owner_idx": { + "name": "project_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_is_private_idx": { + "name": "project_is_private_idx", + "columns": [ + { + "expression": "is_private", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_views_idx": { + "name": "project_views_idx", + "columns": [ + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_likes_idx": { + "name": "project_likes_idx", + "columns": [ + { + "expression": "likes", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_address_id_projects_addresses_id_fk": { + "name": "projects_address_id_projects_addresses_id_fk", + "tableFrom": "projects", + "tableTo": "projects_addresses", + "columnsFrom": [ + "address_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "projects_owner_id_auth_users_id_fk": { + "name": "projects_owner_id_auth_users_id_fk", + "tableFrom": "projects", + "tableTo": "auth_users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": { + "public.auth_user_roles": { + "name": "auth_user_roles", + "schema": "public", + "values": [ + "user", + "admin" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/supabase/migrations/meta/_journal.json b/supabase/migrations/meta/_journal.json index 9921faab..8213f70a 100644 --- a/supabase/migrations/meta/_journal.json +++ b/supabase/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1771911353115, "tag": "20260224053553_stormy_carnage", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1772065554845, + "tag": "20260226002554_big_pestilence", + "breakpoints": true } ] } \ No newline at end of file From c77381a59ad9b8c8a17f544507ac3be7b3ddf728 Mon Sep 17 00:00:00 2001 From: wass08 Date: Thu, 26 Feb 2026 09:53:36 +0900 Subject: [PATCH 3/6] fix deletion of assets on delete ref and delete proj --- .../panels/site-panel/references-dialog.tsx | 18 +++++++--- .../features/community/lib/assets/actions.ts | 34 +++++++++---------- 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/references-dialog.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/references-dialog.tsx index 590a8db1..7fd0b6b1 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/references-dialog.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/references-dialog.tsx @@ -1,3 +1,5 @@ +'use client' + import { type AnyNodeId, type GuideNode, @@ -133,19 +135,25 @@ export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDial ) const handleDelete = useCallback( - (nodeId: string) => { + async (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 + + // Delete storage asset first (before removing from scene) if ( projectId && !projectId.startsWith('local_') && refNode?.url && - refNode.url.startsWith('https://') + (refNode.url.startsWith('http://') || refNode.url.startsWith('https://')) ) { - deleteProjectAssetByUrl(projectId, refNode.url).catch(console.error) + const result = await deleteProjectAssetByUrl(projectId, refNode.url) + if (!result.success) { + setUploadError(`Failed to delete asset: ${result.error}`) + return + } } + + deleteNode(nodeId as AnyNodeId) }, [deleteNode, nodes, activeProject], ) diff --git a/apps/editor/features/community/lib/assets/actions.ts b/apps/editor/features/community/lib/assets/actions.ts index f39e9748..55a0ef00 100644 --- a/apps/editor/features/community/lib/assets/actions.ts +++ b/apps/editor/features/community/lib/assets/actions.ts @@ -130,29 +130,29 @@ export async function deleteProjectAssetByUrl( 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() + // Derive storage_key from the public URL + // URL format: https://.supabase.co/storage/v1/object/public/project-assets/ + const storageKeyFromUrl = url.split(`/${BUCKET}/`)[1]?.split('?')[0] - if (fetchError) { - return { success: false, error: fetchError.message } + if (!storageKeyFromUrl) { + return { success: false, error: 'Could not derive storage key from URL' } } - if (!asset) { - // Nothing to delete — treat as success - return { success: true } + // Delete from storage directly — remove() is a no-op if the file doesn't exist + const { error: storageError } = await supabase.storage.from(BUCKET).remove([storageKeyFromUrl]) + if (storageError) { + return { success: false, error: `Storage delete failed: ${storageError.message}` } } - // Remove from storage - await supabase.storage.from(BUCKET).remove([(asset as any).storage_key]) - - // Delete row - await (supabase.from('project_assets') as any) + // Delete DB row by storage_key scoped to this project + const { error: dbError } = await (supabase.from('project_assets') as any) .delete() - .eq('id', (asset as any).id) + .eq('project_id', projectId) + .eq('storage_key', storageKeyFromUrl) + + if (dbError) { + return { success: false, error: `DB delete failed: ${dbError.message}` } + } return { success: true } } catch (error) { From 48d84e5cd98b82cd7f99167907d70054ebae3cd2 Mon Sep 17 00:00:00 2001 From: wass08 Date: Thu, 26 Feb 2026 09:56:31 +0900 Subject: [PATCH 4/6] redirect to project --- .../features/community/components/project-dropdown.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/editor/features/community/components/project-dropdown.tsx b/apps/editor/features/community/components/project-dropdown.tsx index fee0a974..2fc35f27 100644 --- a/apps/editor/features/community/components/project-dropdown.tsx +++ b/apps/editor/features/community/components/project-dropdown.tsx @@ -1,6 +1,7 @@ 'use client' import { Check, ChevronDown, Home, Plus } from 'lucide-react' +import { useRouter } from 'next/navigation' import { useState } from 'react' import { DropdownMenu, @@ -18,6 +19,8 @@ import { NewProjectDialog } from './new-project-dialog' * Having it in both places caused duplicate subscriptions and 2x server action calls. */ export function ProjectDropdown() { + const router = useRouter() + // Use project store const projects = useProjectStore((state) => state.projects) const activeProject = useProjectStore((state) => state.activeProject) @@ -35,9 +38,8 @@ export function ProjectDropdown() { setIsNewProjectDialogOpen(true) } - const handleProjectCreated = async (projectId: string) => { - // Set the newly created project as active (this will also fetch projects) - await setActiveProject(projectId) + const handleProjectCreated = (projectId: string) => { + router.push(`/editor/${projectId}`) } return ( From 8345e4211987afe1fe24c645f9ef7277315526a6 Mon Sep 17 00:00:00 2001 From: wass08 Date: Thu, 26 Feb 2026 12:04:12 +0900 Subject: [PATCH 5/6] removed non logged in editor --- apps/editor/app/editor/[projectId]/page.tsx | 21 +++- apps/editor/components/editor/index.tsx | 13 -- .../panels/site-panel/references-dialog.tsx | 9 +- .../components/cloud-save-button.tsx | 37 +----- .../community/components/community-hub.tsx | 87 +++---------- .../local-project-migration-dialog.tsx | 83 ------------- .../components/new-project-dialog.tsx | 37 +----- .../community/components/project-grid.tsx | 117 +++++------------- .../community/lib/local-storage/hooks.ts | 93 -------------- .../lib/local-storage/project-store.ts | 114 ----------------- .../community/lib/projects/actions.ts | 70 ----------- 11 files changed, 75 insertions(+), 606 deletions(-) delete mode 100644 apps/editor/features/community/components/local-project-migration-dialog.tsx delete mode 100644 apps/editor/features/community/lib/local-storage/hooks.ts delete mode 100644 apps/editor/features/community/lib/local-storage/project-store.ts diff --git a/apps/editor/app/editor/[projectId]/page.tsx b/apps/editor/app/editor/[projectId]/page.tsx index 09102acc..2a013627 100644 --- a/apps/editor/app/editor/[projectId]/page.tsx +++ b/apps/editor/app/editor/[projectId]/page.tsx @@ -1,24 +1,33 @@ 'use client' import Editor from '@/components/editor' -import { useParams } from 'next/navigation' -import { useEffect, useLayoutEffect } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { useLayoutEffect } from 'react' import { useProjectStore } from '@/features/community/lib/projects/store' import { useAuth } from '@/features/community/lib/auth/hooks' export default function EditorPage() { const params = useParams() const projectId = params.projectId as string - const { isAuthenticated } = useAuth() + const { isAuthenticated, isLoading } = useAuth() const setActiveProject = useProjectStore((state) => state.setActiveProject) + const router = useRouter() // Use layoutEffect to set active project BEFORE the editor renders and hooks run useLayoutEffect(() => { - // For authenticated users with cloud projects, set the active project from URL - if (isAuthenticated && projectId && !projectId.startsWith('local_')) { + if (isLoading) return + if (!isAuthenticated) { + router.replace('/') + return + } + if (projectId) { setActiveProject(projectId) } - }, [projectId, isAuthenticated, setActiveProject]) + }, [projectId, isAuthenticated, isLoading, setActiveProject, router]) + + if (isLoading || !isAuthenticated) { + return null + } return (
diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx index 40edca0c..6509a582 100644 --- a/apps/editor/components/editor/index.tsx +++ b/apps/editor/components/editor/index.tsx @@ -2,8 +2,6 @@ import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core' import { Viewer } from '@pascal-app/viewer' -import { useAuth } from '@/features/community/lib/auth/hooks' -import { useLocalProjectScene } from '@/features/community/lib/local-storage/hooks' import { useProjectScene } from '@/features/community/lib/models/hooks' import { useKeyboard } from '@/hooks/use-keyboard' import { initSFXBus } from '@/lib/sfx-bus' @@ -38,18 +36,7 @@ interface EditorProps { export default function Editor({ projectId }: EditorProps) { useKeyboard() - const { isAuthenticated } = useAuth() - - // Determine which mode to use - const isLocalProject = projectId?.startsWith('local_') - const shouldUseCloud = isAuthenticated && !isLocalProject - const shouldUseLocal = !shouldUseCloud && !!projectId - - // Call hooks unconditionally (hooks internally check if they should activate) - // Cloud hook activates when there's an activeProject in the store useProjectScene() - // Local hook activates when projectId is provided and starts with 'local_' - useLocalProjectScene(shouldUseLocal ? projectId : undefined) return (
diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/references-dialog.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/references-dialog.tsx index 7fd0b6b1..949c084f 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/references-dialog.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/references-dialog.tsx @@ -53,8 +53,8 @@ export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDial e.target.value = '' const projectId = activeProject?.id - if (!projectId || projectId.startsWith('local_')) { - setUploadError('Save your project to the cloud first to add references.') + if (!projectId) { + setUploadError('No active project. Please open a project first.') return } @@ -93,8 +93,8 @@ export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDial e.target.value = '' const projectId = activeProject?.id - if (!projectId || projectId.startsWith('local_')) { - setUploadError('Save your project to the cloud first to add references.') + if (!projectId) { + setUploadError('No active project. Please open a project first.') return } @@ -142,7 +142,6 @@ export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDial // Delete storage asset first (before removing from scene) if ( projectId && - !projectId.startsWith('local_') && refNode?.url && (refNode.url.startsWith('http://') || refNode.url.startsWith('https://')) ) { diff --git a/apps/editor/features/community/components/cloud-save-button.tsx b/apps/editor/features/community/components/cloud-save-button.tsx index 7a3d4f2c..750339ee 100644 --- a/apps/editor/features/community/components/cloud-save-button.tsx +++ b/apps/editor/features/community/components/cloud-save-button.tsx @@ -1,32 +1,23 @@ 'use client' -import { Cloud, Home } from 'lucide-react' -import { useEffect, useState } from 'react' +import { Home } from 'lucide-react' import { useRouter } from 'next/navigation' +import { useEffect } from 'react' import { useAuth } from '../lib/auth/hooks' import { useProjectStore } from '../lib/projects/store' import { ProfileDropdown } from './profile-dropdown' -import { SignInDialog } from './sign-in-dialog' - -interface CloudSaveButtonProps { - projectId?: string -} /** * CloudSaveButton - Shows authentication state and project management * - * Guest with local project: Shows "Save to cloud" button - * Guest without project: Shows "Home" button + * Guest: Shows "Home" button * Authenticated: Shows ProfileDropdown */ -export function CloudSaveButton({ projectId }: CloudSaveButtonProps) { +export function CloudSaveButton() { const { isAuthenticated, isLoading } = useAuth() - const [isSignInDialogOpen, setIsSignInDialogOpen] = useState(false) const initialize = useProjectStore(state => state.initialize) const router = useRouter() - const isLocalProject = projectId?.startsWith('local_') - // Initialize project store when authenticated useEffect(() => { if (isAuthenticated) { @@ -44,25 +35,6 @@ export function CloudSaveButton({ projectId }: CloudSaveButtonProps) { ) } - // Guest user with local project - if (!isAuthenticated && isLocalProject) { - return ( - <> -
- -
- - - ) - } - - // Guest user (no project context or browsing) if (!isAuthenticated) { return (
@@ -77,7 +49,6 @@ export function CloudSaveButton({ projectId }: CloudSaveButtonProps) { ) } - // Authenticated user return (
diff --git a/apps/editor/features/community/components/community-hub.tsx b/apps/editor/features/community/components/community-hub.tsx index 839fdc9b..4c2e235d 100644 --- a/apps/editor/features/community/components/community-hub.tsx +++ b/apps/editor/features/community/components/community-hub.tsx @@ -4,8 +4,6 @@ import Image from 'next/image' import { useRouter } from 'next/navigation' import { useEffect, useState } from 'react' import { useAuth } from '../lib/auth/hooks' -import type { LocalProject } from '../lib/local-storage/project-store' -import { createLocalProject, getLocalProjects } from '../lib/local-storage/project-store' import { getPublicProjects, getUserProjects } from '../lib/projects/actions' import type { Project } from '../lib/projects/types' import { CreateProjectButton } from './create-project-button' @@ -16,27 +14,23 @@ import { ProjectGrid } from './project-grid' import { SignInDialog } from './sign-in-dialog' export default function CommunityHub() { - const { isAuthenticated, isLoading: authLoading, user } = useAuth() + const { isAuthenticated, isLoading: authLoading } = useAuth() const router = useRouter() const [isSignInDialogOpen, setIsSignInDialogOpen] = useState(false) const [isNewProjectDialogOpen, setIsNewProjectDialogOpen] = useState(false) - const [localProjectToSave, setLocalProjectToSave] = useState(null) const [publicProjects, setPublicProjects] = useState([]) const [userProjects, setUserProjects] = useState([]) - const [localProjects, setLocalProjects] = useState([]) const [loading, setLoading] = useState(true) useEffect(() => { async function loadProjects() { setLoading(true) - // Load public projects (always) const publicResult = await getPublicProjects() if (publicResult.success) { setPublicProjects(publicResult.data || []) } - // Load user projects if authenticated if (isAuthenticated) { const userResult = await getUserProjects() if (userResult.success) { @@ -44,9 +38,6 @@ export default function CommunityHub() { } } - // Always load local projects - setLocalProjects(getLocalProjects()) - setLoading(false) } @@ -55,27 +46,7 @@ export default function CommunityHub() { } }, [isAuthenticated, authLoading]) - const handleCreateProject = async () => { - if (!isAuthenticated) { - // Create local project for guest - const project = createLocalProject('Untitled Project') - router.push(`/editor/${project.id}`) - } else { - // Open project creation dialog for authenticated users - setIsNewProjectDialogOpen(true) - } - } - const handleProjectCreated = async (projectId: string) => { - // If this was a local project being saved, delete it from localStorage - if (localProjectToSave) { - const { deleteLocalProject } = await import('../lib/local-storage/project-store') - deleteLocalProject(localProjectToSave.id) - setLocalProjects(getLocalProjects()) - setLocalProjectToSave(null) - } - - // Reload projects and navigate to the new project const result = await getUserProjects() if (result.success) { setUserProjects(result.data || []) @@ -83,11 +54,6 @@ export default function CommunityHub() { router.push(`/editor/${projectId}`) } - const handleSaveLocalToCloud = (localProject: LocalProject) => { - setLocalProjectToSave(localProject) - setIsNewProjectDialogOpen(true) - } - const handleProjectClick = (projectId: string) => { router.push(`/editor/${projectId}`) } @@ -153,18 +119,17 @@ export default function CommunityHub() {

My Projects

- + setIsNewProjectDialogOpen(true)} />
- {userProjects.length === 0 && localProjects.length === 0 ? ( + {userProjects.length === 0 ? (

You don't have any projects yet.

) : ( { @@ -181,30 +146,19 @@ export default function CommunityHub() {
)} - {/* Local Projects Section (Guest Users) */} - {!isAuthenticated && localProjects.length > 0 && ( -
-
-

My Local Projects

- -
- -
- )} - - {/* Create First Project CTA */} - {!isAuthenticated && localProjects.length === 0 && ( -
-

Get Started

-

- Create your first project to start designing + {/* Sign-in CTA for unauthenticated users */} + {!isAuthenticated && ( +

+

Build with Pascal

+

+ Create and share 3D architectural projects. Sign in to get started.

- +
)} @@ -230,15 +184,6 @@ export default function CommunityHub() { open={isNewProjectDialogOpen} onOpenChange={setIsNewProjectDialogOpen} onSuccess={handleProjectCreated} - localProjectData={ - localProjectToSave - ? { - id: localProjectToSave.id, - name: localProjectToSave.name, - sceneGraph: localProjectToSave.scene_graph, - } - : undefined - } />
) diff --git a/apps/editor/features/community/components/local-project-migration-dialog.tsx b/apps/editor/features/community/components/local-project-migration-dialog.tsx deleted file mode 100644 index 277b5670..00000000 --- a/apps/editor/features/community/components/local-project-migration-dialog.tsx +++ /dev/null @@ -1,83 +0,0 @@ -'use client' - -import { useState } from 'react' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/primitives/dialog' -import type { LocalProject } from '../lib/local-storage/project-store' - -interface LocalProjectMigrationDialogProps { - localProjects: LocalProject[] - open: boolean - onMigrate: () => Promise - onSkip: () => void -} - -export function LocalProjectMigrationDialog({ - localProjects, - open, - onMigrate, - onSkip, -}: LocalProjectMigrationDialogProps) { - const [isMigrating, setIsMigrating] = useState(false) - - const handleMigrate = async () => { - setIsMigrating(true) - try { - await onMigrate() - } finally { - setIsMigrating(false) - } - } - - return ( - !open && !isMigrating && onSkip()}> - - - Save Local Projects to Cloud - - You have {localProjects.length} local {localProjects.length === 1 ? 'project' : 'projects'} that {localProjects.length === 1 ? 'hasn\'t' : 'haven\'t'} been saved to the cloud yet. - - - -
-

- Would you like to save {localProjects.length === 1 ? 'it' : 'them'} to your account? -

-
    - {localProjects.map((project) => ( -
  • - - {project.name} -
  • - ))} -
-
- - - - - -
-
- ) -} diff --git a/apps/editor/features/community/components/new-project-dialog.tsx b/apps/editor/features/community/components/new-project-dialog.tsx index 88e40637..5b5fcc63 100644 --- a/apps/editor/features/community/components/new-project-dialog.tsx +++ b/apps/editor/features/community/components/new-project-dialog.tsx @@ -2,26 +2,21 @@ import { X } from 'lucide-react' import { useState } from 'react' -import { createProject } from '../lib/projects/actions' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/primitives/dialog' import { Switch } from '@/components/ui/primitives/switch' +import { createProject } from '../lib/projects/actions' interface NewProjectDialogProps { open: boolean onOpenChange: (open: boolean) => void onSuccess?: (projectId: string) => void - localProjectData?: { - id: string - name: string - sceneGraph: any - } } /** - * NewProjectDialog - Dialog for creating a new project with optional Google Maps address search + * NewProjectDialog - Dialog for creating a new project */ -export function NewProjectDialog({ open, onOpenChange, onSuccess, localProjectData }: NewProjectDialogProps) { - const [projectName, setProjectName] = useState(localProjectData?.name || '') +export function NewProjectDialog({ open, onOpenChange, onSuccess }: NewProjectDialogProps) { + const [projectName, setProjectName] = useState('') const [isPrivate, setIsPrivate] = useState(false) const [isCreating, setIsCreating] = useState(false) const [error, setError] = useState(null) @@ -32,19 +27,10 @@ export function NewProjectDialog({ open, onOpenChange, onSuccess, localProjectDa const name = projectName.trim() || 'Untitled Project' - if (!name) { - setError('Please enter a project name') - return - } - setIsCreating(true) try { - const result = await createProject({ - name, - isPrivate, - sceneGraph: localProjectData?.sceneGraph, - }) + const result = await createProject({ name, isPrivate }) if (result.success && result.data) { onOpenChange(false) @@ -73,7 +59,7 @@ export function NewProjectDialog({ open, onOpenChange, onSuccess, localProjectDa return ( e.preventDefault()} > @@ -120,17 +106,6 @@ export function NewProjectDialog({ open, onOpenChange, onSuccess, localProjectDa
- {localProjectData && ( -
-

- Saving local project: {localProjectData.name} -

-

- Your building data will be preserved -

-
- )} - {error && (
{error} diff --git a/apps/editor/features/community/components/project-grid.tsx b/apps/editor/features/community/components/project-grid.tsx index d4e785bc..9b594f73 100644 --- a/apps/editor/features/community/components/project-grid.tsx +++ b/apps/editor/features/community/components/project-grid.tsx @@ -3,34 +3,25 @@ import { Eye, Heart, Settings } from 'lucide-react' import Link from 'next/link' import { useEffect, useState } from 'react' -import type { Project } from '../lib/projects/types' -import type { LocalProject } from '../lib/local-storage/project-store' -import { ProjectSettingsDialog } from './project-settings-dialog' -import { getUserProjectLikes, toggleProjectLike } from '../lib/projects/actions' import { useAuth } from '../lib/auth/hooks' +import { getUserProjectLikes, toggleProjectLike } from '../lib/projects/actions' +import type { Project } from '../lib/projects/types' +import { ProjectSettingsDialog } from './project-settings-dialog' interface ProjectGridProps { - projects: (Project | LocalProject)[] + projects: Project[] onProjectClick: (id: string) => void onViewClick?: (id: string) => void - onSaveToCloud?: (project: LocalProject) => void showOwner: boolean - isLocal?: boolean canEdit?: boolean onUpdate?: () => void } -function isLocalProject(prop: Project | LocalProject): prop is LocalProject { - return 'is_local' in prop && prop.is_local === true -} - export function ProjectGrid({ projects, onProjectClick, onViewClick, - onSaveToCloud, showOwner, - isLocal = false, canEdit = false, onUpdate, }: ProjectGridProps) { @@ -39,28 +30,21 @@ export function ProjectGrid({ const [userLikes, setUserLikes] = useState>({}) const [likeCounts, setLikeCounts] = useState>({}) - // Initialize like counts from projects useEffect(() => { const counts: Record = {} projects.forEach((proj) => { - if (!isLocalProject(proj)) { - counts[proj.id] = proj.likes - } + counts[proj.id] = proj.likes }) setLikeCounts(counts) }, [projects]) - // Fetch which projects the user has liked useEffect(() => { if (!isAuthenticated) { setUserLikes({}) return } - const projectIds = projects - .filter((p) => !isLocalProject(p)) - .map((p) => p.id) - + const projectIds = projects.map((p) => p.id) if (projectIds.length === 0) return getUserProjectLikes(projectIds).then((result) => { @@ -70,11 +54,9 @@ export function ProjectGrid({ }) }, [projects, isAuthenticated]) - const handleSettingsClick = (e: React.MouseEvent, project: Project | LocalProject) => { + const handleSettingsClick = (e: React.MouseEvent, project: Project) => { e.stopPropagation() - if (!isLocalProject(project)) { - setSettingsProject(project) - } + setSettingsProject(project) } const handleViewClick = (e: React.MouseEvent, projectId: string) => { @@ -85,31 +67,24 @@ export function ProjectGrid({ const handleLikeClick = async (e: React.MouseEvent, projectId: string) => { e.stopPropagation() - if (!isAuthenticated) { - // Could show a sign-in prompt here - return - } + if (!isAuthenticated) return - // Optimistic update const wasLiked = userLikes[projectId] || false const currentCount = likeCounts[projectId] || 0 setUserLikes((prev) => ({ ...prev, [projectId]: !wasLiked })) setLikeCounts((prev) => ({ ...prev, - [projectId]: wasLiked ? currentCount - 1 : currentCount + 1 + [projectId]: wasLiked ? currentCount - 1 : currentCount + 1, })) - // Call server action const result = await toggleProjectLike(projectId) if (result.success && result.data) { - // Update with actual values from server const data = result.data setUserLikes((prev) => ({ ...prev, [projectId]: data.liked })) setLikeCounts((prev) => ({ ...prev, [projectId]: data.likes })) } else { - // Revert on error setUserLikes((prev) => ({ ...prev, [projectId]: wasLiked })) setLikeCounts((prev) => ({ ...prev, [projectId]: currentCount })) } @@ -119,7 +94,7 @@ export function ProjectGrid({ <>
{projects.map((project) => { - const owner = !isLocalProject(project) ? project.owner : null + const owner = project.owner return (
{/* Thumbnail card */}
- {!isLocalProject(project) && project.thumbnail_url ? ( + {project.thumbnail_url ? ( {project.name} )} - {isLocalProject(project) && ( -
- {isAuthenticated && onSaveToCloud ? ( - - ) : ( -
- Local -
- )} -
- )} - {canEdit && !isLocalProject(project) && ( + {canEdit && (
{onViewClick && ( )} - {isLocalProject(project) && ( - {new Date(project.updated_at).toLocaleDateString()} - )} +
+ + {project.views} +
+ · +
@@ -257,7 +201,6 @@ export function ProjectGrid({ })}
- {/* Settings Dialog */} {settingsProject && ( (undefined) - const currentProjectIdRef = useRef(null) - const lastProjectIdRef = useRef(null) - - // Load scene when project ID changes - useEffect(() => { - if (!projectId || !projectId.startsWith('local_')) { - return - } - - if (lastProjectIdRef.current === projectId) { - return - } - - lastProjectIdRef.current = projectId - currentProjectIdRef.current = projectId - - const project = getLocalProject(projectId) - - if (project?.scene_graph) { - const { nodes, rootNodeIds } = project.scene_graph - useScene.getState().setScene(nodes, rootNodeIds as AnyNodeId[]) - initSpatialGridSync() - } else { - useScene.getState().clearScene() - } - - useEditor.getState().setPhase('site') - useViewer.getState().setSelection({ - buildingId: null, - levelId: null, - selectedIds: [], - zoneId: null, - }) - }, [projectId]) - - // Auto-save to localStorage with debouncing - useEffect(() => { - if (!projectId || !projectId.startsWith('local_')) { - currentProjectIdRef.current = null - return - } - - currentProjectIdRef.current = projectId - let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes) - - const unsubscribe = useScene.subscribe((state) => { - const currentNodesSnapshot = JSON.stringify(state.nodes) - - if (currentNodesSnapshot === lastNodesSnapshot) { - return - } - - lastNodesSnapshot = currentNodesSnapshot - const nodes = state.nodes - - if (saveTimeoutRef.current) { - clearTimeout(saveTimeoutRef.current) - } - - // Debounce save by 1 second (faster than cloud save) - saveTimeoutRef.current = setTimeout(() => { - const currentId = currentProjectIdRef.current - if (!currentId) return - - const rootNodeIds = useScene.getState().rootNodeIds - const sceneGraph = { nodes, rootNodeIds } - - updateLocalProjectScene(currentId, sceneGraph) - }, 1000) - }) - - return () => { - if (saveTimeoutRef.current) { - clearTimeout(saveTimeoutRef.current) - } - unsubscribe() - } - }, [projectId]) -} diff --git a/apps/editor/features/community/lib/local-storage/project-store.ts b/apps/editor/features/community/lib/local-storage/project-store.ts deleted file mode 100644 index 4560fb7d..00000000 --- a/apps/editor/features/community/lib/local-storage/project-store.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Local storage management for guest users - * Stores projects and scenes in browser localStorage - */ - -import { createId } from '../utils/id-generator' - -export interface SceneGraph { - nodes: Record - rootNodeIds: string[] -} - -export interface LocalProject { - id: string // Format: 'local_project_xyz' - name: string - created_at: string - updated_at: string - scene_graph: SceneGraph | null - is_local: true -} - -const LOCAL_PROJECTS_KEY = 'pascal_local_projects' -// Keep old key for migration -const LEGACY_LOCAL_PROPERTIES_KEY = 'pascal_local_properties' - -function migrateLegacyStorage(): void { - if (typeof window === 'undefined') return - - try { - const legacy = localStorage.getItem(LEGACY_LOCAL_PROPERTIES_KEY) - if (legacy && !localStorage.getItem(LOCAL_PROJECTS_KEY)) { - // Migrate old data to new key - const parsed = JSON.parse(legacy) - // Update IDs from local_property_* to local_project_* - const migrated = parsed.map((p: any) => ({ - ...p, - id: p.id.replace('local_property_', 'local_project_'), - })) - localStorage.setItem(LOCAL_PROJECTS_KEY, JSON.stringify(migrated)) - localStorage.removeItem(LEGACY_LOCAL_PROPERTIES_KEY) - } - } catch (error) { - console.error('Failed to migrate legacy local properties:', error) - } -} - -export function getLocalProjects(): LocalProject[] { - if (typeof window === 'undefined') return [] - - migrateLegacyStorage() - - try { - const stored = localStorage.getItem(LOCAL_PROJECTS_KEY) - return stored ? JSON.parse(stored) : [] - } catch (error) { - console.error('Failed to load local projects:', error) - return [] - } -} - -export function getLocalProject(id: string): LocalProject | null { - const projects = getLocalProjects() - return projects.find((p) => p.id === id) || null -} - -export function saveLocalProject(project: LocalProject): void { - const projects = getLocalProjects() - const index = projects.findIndex((p) => p.id === project.id) - - if (index >= 0) { - projects[index] = { ...project, updated_at: new Date().toISOString() } - } else { - projects.push(project) - } - - localStorage.setItem(LOCAL_PROJECTS_KEY, JSON.stringify(projects)) -} - -export function createLocalProject(name: string): LocalProject { - const project: LocalProject = { - id: createId('local_project'), - name, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - scene_graph: null, - is_local: true, - } - - saveLocalProject(project) - return project -} - -export function deleteLocalProject(id: string): void { - const projects = getLocalProjects().filter((p) => p.id !== id) - localStorage.setItem(LOCAL_PROJECTS_KEY, JSON.stringify(projects)) -} - -export function updateLocalProjectScene(id: string, sceneGraph: SceneGraph): void { - const project = getLocalProject(id) - if (project) { - project.scene_graph = sceneGraph - saveLocalProject(project) - } -} - -export function migrateLocalProjectsToCloud(userId: string): LocalProject[] { - // Return local projects that need to be migrated - // Actual migration handled by separate function - return getLocalProjects() -} - -export function clearLocalProjects(): void { - localStorage.removeItem(LOCAL_PROJECTS_KEY) -} diff --git a/apps/editor/features/community/lib/projects/actions.ts b/apps/editor/features/community/lib/projects/actions.ts index 0bc37057..a00efd9d 100644 --- a/apps/editor/features/community/lib/projects/actions.ts +++ b/apps/editor/features/community/lib/projects/actions.ts @@ -803,76 +803,6 @@ export async function updateProjectAddress( } } -/** - * Migrate a local project to the cloud - * Creates a new project with the local project's data - */ -export async function migrateLocalProject( - localProject: { - name: string - scene_graph: any - }, -): Promise> { - try { - const session = await getSession() - - if (!session?.user) { - return { - success: false, - error: 'Not authenticated', - } - } - - const supabase = await createServerSupabaseClient() - - // Create the project without an address (user can add one later via settings) - const projectId = createId('project') - const { error: projectError } = await (supabase.from('projects') as any).insert({ - id: projectId, - name: localProject.name, - owner_id: session.user.id, - address_id: null, - is_private: true, // Default to private - }) - - if (projectError) { - return { - success: false, - error: projectError.message, - } - } - - // Create the model with the scene graph - if (localProject.scene_graph) { - const modelId = createId('model') - const { error: modelError } = await (supabase.from('projects_models') as any).insert({ - id: modelId, - project_id: projectId, - version: 1, - scene_graph: localProject.scene_graph, - }) - - if (modelError) { - return { - success: false, - error: modelError.message, - } - } - } - - return { - success: true, - data: { id: projectId }, - message: 'Project migrated successfully', - } - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to migrate project', - } - } -} - /** * Delete a project * Only the owner can delete their project From 552beb1194fc6350be7872c4537b01ec2f5d1bb4 Mon Sep 17 00:00:00 2001 From: wass08 Date: Thu, 26 Feb 2026 12:21:03 +0900 Subject: [PATCH 6/6] select by default first level / building & structure --- .../features/community/lib/models/hooks.ts | 50 +++++++++++++++---- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/apps/editor/features/community/lib/models/hooks.ts b/apps/editor/features/community/lib/models/hooks.ts index 35f02722..7b7025a3 100644 --- a/apps/editor/features/community/lib/models/hooks.ts +++ b/apps/editor/features/community/lib/models/hooks.ts @@ -69,24 +69,56 @@ export function useProjectScene() { // Load the scene graph into the store const { nodes, rootNodeIds } = result.data.scene_graph useScene.getState().setScene(nodes, rootNodeIds) + + // Auto-select the first building + level after store is updated + const sceneNodes = useScene.getState().nodes as Record + const sceneRootIds = useScene.getState().rootNodeIds + const siteNode = sceneRootIds[0] ? sceneNodes[sceneRootIds[0]] : null + const resolve = (child: any) => + typeof child === 'string' ? sceneNodes[child] : child + const firstBuilding = siteNode?.children?.map(resolve).find((n: any) => n?.type === 'building') + const firstLevel = firstBuilding?.children?.map(resolve).find((n: any) => n?.type === 'level') + + if (firstBuilding && firstLevel) { + useViewer.getState().setSelection({ + buildingId: firstBuilding.id, + levelId: firstLevel.id, + selectedIds: [], + zoneId: null, + }) + useEditor.getState().setPhase('structure') + } else { + useEditor.getState().setPhase('site') + useViewer.getState().setSelection({ + buildingId: null, + levelId: null, + selectedIds: [], + zoneId: null, + }) + } } else { // No scene found - clear the scene useScene.getState().clearScene() + useEditor.getState().setPhase('site') + useViewer.getState().setSelection({ + buildingId: null, + levelId: null, + selectedIds: [], + zoneId: null, + }) } } catch (error) { // Fall back to clear scene useScene.getState().clearScene() + useEditor.getState().setPhase('site') + useViewer.getState().setSelection({ + buildingId: null, + levelId: null, + selectedIds: [], + zoneId: null, + }) } - // Reset editor state after loading/clearing scene - useEditor.getState().setPhase('site') - useViewer.getState().setSelection({ - buildingId: null, - levelId: null, - selectedIds: [], - zoneId: null, - }) - // Allow auto-save again after a tick (let the store update propagate) requestAnimationFrame(() => { isLoadingSceneRef.current = false