Merge pull request #121 from pascalorg/fix/pass-issues-and-polish
Fix/pass issues and polish
This commit is contained in:
@@ -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 (
|
||||
<div className="flex h-screen w-full max-w-screen">
|
||||
|
||||
@@ -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 (
|
||||
<div className="w-full h-full">
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type GuideNode,
|
||||
@@ -5,12 +7,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 +26,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 +39,9 @@ export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDial
|
||||
const createNode = useScene((s) => s.createNode)
|
||||
const deleteNode = useScene((s) => s.deleteNode)
|
||||
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
|
||||
const activeProject = useProjectStore((s) => s.activeProject)
|
||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
const scanInputRef = useRef<HTMLInputElement>(null)
|
||||
const guideInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -42,38 +50,80 @@ export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDial
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const url = await saveAsset(file)
|
||||
e.target.value = ''
|
||||
|
||||
const projectId = activeProject?.id
|
||||
if (!projectId) {
|
||||
setUploadError('No active project. Please open a project first.')
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setUploadError(`File is too large (${(file.size / 1024 / 1024).toFixed(0)} MB). Maximum size is 100 MB.`)
|
||||
return
|
||||
}
|
||||
|
||||
setUploadError(null)
|
||||
setUploading(true)
|
||||
const result = await uploadProjectAsset(projectId, file, 'scan')
|
||||
setUploading(false)
|
||||
|
||||
if (!result.success) {
|
||||
setUploadError(result.error)
|
||||
return
|
||||
}
|
||||
|
||||
const node = ScanNodeSchema.parse({
|
||||
url,
|
||||
url: result.url,
|
||||
name: file.name,
|
||||
parentId: levelId,
|
||||
})
|
||||
createNode(node, levelId as AnyNodeId)
|
||||
e.target.value = ''
|
||||
// Auto-select and close dialog
|
||||
setSelectedReferenceId(node.id)
|
||||
onOpenChange(false)
|
||||
},
|
||||
[levelId, createNode, setSelectedReferenceId, onOpenChange],
|
||||
[levelId, createNode, setSelectedReferenceId, onOpenChange, activeProject],
|
||||
)
|
||||
|
||||
const handleAddGuide = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const url = await saveAsset(file)
|
||||
e.target.value = ''
|
||||
|
||||
const projectId = activeProject?.id
|
||||
if (!projectId) {
|
||||
setUploadError('No active project. Please open a project first.')
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setUploadError(`File is too large (${(file.size / 1024 / 1024).toFixed(0)} MB). Maximum size is 100 MB.`)
|
||||
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(
|
||||
@@ -85,10 +135,26 @@ 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
|
||||
const projectId = activeProject?.id
|
||||
|
||||
// Delete storage asset first (before removing from scene)
|
||||
if (
|
||||
projectId &&
|
||||
refNode?.url &&
|
||||
(refNode.url.startsWith('http://') || refNode.url.startsWith('https://'))
|
||||
) {
|
||||
const result = await deleteProjectAssetByUrl(projectId, refNode.url)
|
||||
if (!result.success) {
|
||||
setUploadError(`Failed to delete asset: ${result.error}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
deleteNode(nodeId as AnyNodeId)
|
||||
},
|
||||
[deleteNode],
|
||||
[deleteNode, nodes, activeProject],
|
||||
)
|
||||
|
||||
const level = nodes[levelId as AnyNodeId] as LevelNode | undefined
|
||||
@@ -145,6 +211,10 @@ export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDial
|
||||
))}
|
||||
</div>
|
||||
|
||||
{uploadError && (
|
||||
<p className="text-xs text-destructive px-1 pb-1">{uploadError}</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-2 border-t border-border/50">
|
||||
<input
|
||||
ref={scanInputRef}
|
||||
@@ -163,9 +233,12 @@ export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDial
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm bg-primary text-primary-foreground hover:bg-primary/90 cursor-pointer">
|
||||
<button
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm bg-primary text-primary-foreground hover:bg-primary/90 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add
|
||||
{uploading ? 'Uploading…' : 'Add'}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-44 p-1">
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className="pointer-events-auto">
|
||||
<button
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 text-sm font-medium shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={() => setIsSignInDialogOpen(true)}
|
||||
>
|
||||
<Cloud className="h-4 w-4" />
|
||||
Save to cloud
|
||||
</button>
|
||||
</div>
|
||||
<SignInDialog open={isSignInDialogOpen} onOpenChange={setIsSignInDialogOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Guest user (no project context or browsing)
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="pointer-events-auto">
|
||||
@@ -77,7 +49,6 @@ export function CloudSaveButton({ projectId }: CloudSaveButtonProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// Authenticated user
|
||||
return (
|
||||
<div className="pointer-events-auto">
|
||||
<ProfileDropdown />
|
||||
|
||||
@@ -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<LocalProject | null>(null)
|
||||
const [publicProjects, setPublicProjects] = useState<Project[]>([])
|
||||
const [userProjects, setUserProjects] = useState<Project[]>([])
|
||||
const [localProjects, setLocalProjects] = useState<LocalProject[]>([])
|
||||
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() {
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold">My Projects</h2>
|
||||
<CreateProjectButton onCreateProject={handleCreateProject} />
|
||||
<CreateProjectButton onCreateProject={() => setIsNewProjectDialogOpen(true)} />
|
||||
</div>
|
||||
{userProjects.length === 0 && localProjects.length === 0 ? (
|
||||
{userProjects.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-16 text-center">
|
||||
<p className="text-muted-foreground">You don't have any projects yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ProjectGrid
|
||||
projects={[...userProjects, ...localProjects]}
|
||||
projects={userProjects}
|
||||
onProjectClick={handleProjectClick}
|
||||
onViewClick={handleViewProject}
|
||||
onSaveToCloud={handleSaveLocalToCloud}
|
||||
showOwner={false}
|
||||
canEdit
|
||||
onUpdate={() => {
|
||||
@@ -181,30 +146,19 @@ export default function CommunityHub() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Local Projects Section (Guest Users) */}
|
||||
{!isAuthenticated && localProjects.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold">My Local Projects</h2>
|
||||
<CreateProjectButton onCreateProject={handleCreateProject} />
|
||||
</div>
|
||||
<ProjectGrid
|
||||
projects={localProjects}
|
||||
onProjectClick={handleProjectClick}
|
||||
showOwner={false}
|
||||
isLocal
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Create First Project CTA */}
|
||||
{!isAuthenticated && localProjects.length === 0 && (
|
||||
<section className="text-center py-12">
|
||||
<h2 className="text-2xl font-semibold mb-4">Get Started</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Create your first project to start designing
|
||||
{/* Sign-in CTA for unauthenticated users */}
|
||||
{!isAuthenticated && (
|
||||
<section className="rounded-2xl border border-border bg-neutral-50 dark:bg-neutral-900/50 px-8 py-12 text-center">
|
||||
<h2 className="text-2xl font-semibold mb-2">Build with Pascal</h2>
|
||||
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
|
||||
Create and share 3D architectural projects. Sign in to get started.
|
||||
</p>
|
||||
<CreateProjectButton onCreateProject={handleCreateProject} />
|
||||
<button
|
||||
onClick={() => setIsSignInDialogOpen(true)}
|
||||
className="rounded-lg bg-primary px-6 py-2.5 text-primary-foreground font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Sign in to create a project
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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<void>
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(open) => !open && !isMigrating && onSkip()}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save Local Projects to Cloud</DialogTitle>
|
||||
<DialogDescription>
|
||||
You have {localProjects.length} local {localProjects.length === 1 ? 'project' : 'projects'} that {localProjects.length === 1 ? 'hasn\'t' : 'haven\'t'} been saved to the cloud yet.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2 py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Would you like to save {localProjects.length === 1 ? 'it' : 'them'} to your account?
|
||||
</p>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{localProjects.map((project) => (
|
||||
<li key={project.id} className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<span className="font-medium">{project.name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSkip}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm hover:bg-accent"
|
||||
disabled={isMigrating}
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMigrate}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
|
||||
disabled={isMigrating}
|
||||
>
|
||||
{isMigrating ? 'Saving...' : 'Save to Cloud'}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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<string | null>(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 (
|
||||
<Dialog open={open} onOpenChange={handleClose} modal={false}>
|
||||
<DialogContent
|
||||
className="sm:max-w-[500px]"
|
||||
className="sm:max-w-125"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
@@ -120,17 +106,6 @@ export function NewProjectDialog({ open, onOpenChange, onSuccess, localProjectDa
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{localProjectData && (
|
||||
<div className="rounded-md border border-blue-500/50 bg-blue-500/10 p-3 text-sm">
|
||||
<p className="font-medium text-blue-700 dark:text-blue-300">
|
||||
Saving local project: {localProjectData.name}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Your building data will be preserved
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
|
||||
{error}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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<Record<string, boolean>>({})
|
||||
const [likeCounts, setLikeCounts] = useState<Record<string, number>>({})
|
||||
|
||||
// Initialize like counts from projects
|
||||
useEffect(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
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({
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||
{projects.map((project) => {
|
||||
const owner = !isLocalProject(project) ? project.owner : null
|
||||
const owner = project.owner
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -129,7 +104,7 @@ export function ProjectGrid({
|
||||
>
|
||||
{/* Thumbnail card */}
|
||||
<div className="relative aspect-[4/3] rounded-xl rounded-smooth-xl bg-neutral-50 overflow-hidden shadow-[0_1px_3px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.04)] transition-shadow group-hover:shadow-[0_4px_12px_rgba(0,0,0,0.08),0_0_0_1px_rgba(0,0,0,0.04)]">
|
||||
{!isLocalProject(project) && project.thumbnail_url ? (
|
||||
{project.thumbnail_url ? (
|
||||
<img
|
||||
src={project.thumbnail_url}
|
||||
alt={project.name}
|
||||
@@ -140,27 +115,7 @@ export function ProjectGrid({
|
||||
No preview
|
||||
</div>
|
||||
)}
|
||||
{isLocalProject(project) && (
|
||||
<div className="absolute top-3 right-3">
|
||||
{isAuthenticated && onSaveToCloud ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onSaveToCloud(project)
|
||||
}}
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white text-xs px-2.5 py-1 rounded-md transition-colors"
|
||||
title="Save to cloud"
|
||||
>
|
||||
Save to cloud
|
||||
</button>
|
||||
) : (
|
||||
<div className="bg-blue-500 text-white text-xs px-2.5 py-1 rounded-md">
|
||||
Local
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canEdit && !isLocalProject(project) && (
|
||||
{canEdit && (
|
||||
<div className="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{onViewClick && (
|
||||
<button
|
||||
@@ -186,7 +141,6 @@ export function ProjectGrid({
|
||||
|
||||
{/* Info row below the card */}
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
{/* Avatar */}
|
||||
{showOwner && owner ? (
|
||||
<Link
|
||||
href={owner.username ? `/u/${owner.username}` : '#'}
|
||||
@@ -207,7 +161,6 @@ export function ProjectGrid({
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{/* Name + stats */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-medium text-sm truncate">{project.name}</h3>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
|
||||
@@ -220,35 +173,26 @@ export function ProjectGrid({
|
||||
>
|
||||
{owner.username || owner.name}
|
||||
</Link>
|
||||
{!isLocalProject(project) && <span className="shrink-0">·</span>}
|
||||
</>
|
||||
)}
|
||||
{!isLocalProject(project) && (
|
||||
<>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
<span>{project.views}</span>
|
||||
</div>
|
||||
<span className="shrink-0">·</span>
|
||||
<button
|
||||
onClick={(e) => handleLikeClick(e, project.id)}
|
||||
className="flex items-center gap-0.5 shrink-0 hover:text-red-500 transition-colors"
|
||||
disabled={!isAuthenticated}
|
||||
>
|
||||
<Heart
|
||||
className={`w-3.5 h-3.5 ${
|
||||
userLikes[project.id]
|
||||
? 'fill-red-500 text-red-500'
|
||||
: ''
|
||||
}`}
|
||||
/>
|
||||
<span>{likeCounts[project.id] ?? project.likes}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isLocalProject(project) && (
|
||||
<span>{new Date(project.updated_at).toLocaleDateString()}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
<span>{project.views}</span>
|
||||
</div>
|
||||
<span className="shrink-0">·</span>
|
||||
<button
|
||||
onClick={(e) => handleLikeClick(e, project.id)}
|
||||
className="flex items-center gap-0.5 shrink-0 hover:text-red-500 transition-colors"
|
||||
disabled={!isAuthenticated}
|
||||
>
|
||||
<Heart
|
||||
className={`w-3.5 h-3.5 ${
|
||||
userLikes[project.id] ? 'fill-red-500 text-red-500' : ''
|
||||
}`}
|
||||
/>
|
||||
<span>{likeCounts[project.id] ?? project.likes}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -257,7 +201,6 @@ export function ProjectGrid({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Settings Dialog */}
|
||||
{settingsProject && (
|
||||
<ProjectSettingsDialog
|
||||
project={settingsProject}
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
|
||||
// Derive storage_key from the public URL
|
||||
// URL format: https://<project>.supabase.co/storage/v1/object/public/project-assets/<storageKey>
|
||||
const storageKeyFromUrl = url.split(`/${BUCKET}/`)[1]?.split('?')[0]
|
||||
|
||||
if (!storageKeyFromUrl) {
|
||||
return { success: false, error: 'Could not derive storage key from URL' }
|
||||
}
|
||||
|
||||
// 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}` }
|
||||
}
|
||||
|
||||
// Delete DB row by storage_key scoped to this project
|
||||
const { error: dbError } = await (supabase.from('project_assets') as any)
|
||||
.delete()
|
||||
.eq('project_id', projectId)
|
||||
.eq('storage_key', storageKeyFromUrl)
|
||||
|
||||
if (dbError) {
|
||||
return { success: false, error: `DB delete failed: ${dbError.message}` }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete asset',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNodeId, initSpatialGridSync, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import useEditor from '@/store/use-editor'
|
||||
import { getLocalProject, updateLocalProjectScene } from './project-store'
|
||||
|
||||
/**
|
||||
* Hook for local project scene management (guest users)
|
||||
* Loads scene from localStorage and auto-saves changes
|
||||
*/
|
||||
export function useLocalProjectScene(projectId?: string) {
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
|
||||
const currentProjectIdRef = useRef<string | null>(null)
|
||||
const lastProjectIdRef = useRef<string | null>(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])
|
||||
}
|
||||
@@ -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<string, any>
|
||||
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)
|
||||
}
|
||||
@@ -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<string, any>
|
||||
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
|
||||
|
||||
@@ -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<ActionResult<{ id: string }>> {
|
||||
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
|
||||
@@ -911,7 +841,17 @@ export async function deleteProject(projectId: string): Promise<ActionResult> {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the project (cascade will delete related records)
|
||||
// Delete project asset files from storage before deleting the project
|
||||
const { data: assets } = await (supabase.from('project_assets') as any)
|
||||
.select('storage_key')
|
||||
.eq('project_id', projectId)
|
||||
|
||||
if (assets && assets.length > 0) {
|
||||
const storageKeys = (assets as { storage_key: string }[]).map((a) => a.storage_key)
|
||||
await supabase.storage.from('project-assets').remove(storageKeys)
|
||||
}
|
||||
|
||||
// Delete the project (cascade will delete related records including project_assets rows)
|
||||
const { error } = await supabase.from('projects').delete().eq('id', projectId)
|
||||
|
||||
if (error) {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ const nextConfig: NextConfig = {
|
||||
transpilePackages: ['three', '@pascal-app/viewer', '@pascal-app/core'],
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: '10mb',
|
||||
bodySizeLimit: '100mb',
|
||||
},
|
||||
},
|
||||
images: {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
"tag": "20260224053553_stormy_carnage",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1772065554845,
|
||||
"tag": "20260226002554_big_pestilence",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user