Merge pull request #142 from pascalorg/chore/carving-out-community
Chore/carving out community
This commit is contained in:
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* Better Auth API route handler
|
||||
* Handles all /api/auth/* routes for authentication
|
||||
*/
|
||||
|
||||
import { toNextJsHandler } from 'better-auth/next-js'
|
||||
import { auth } from '@/lib/auth'
|
||||
|
||||
const { GET, POST } = toNextJsHandler(auth)
|
||||
|
||||
export { GET, POST }
|
||||
@@ -0,0 +1,3 @@
|
||||
export function GET() {
|
||||
return Response.json({ status: 'ok', app: 'editor', timestamp: new Date().toISOString() })
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { headers } from 'next/headers'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { supabaseAdmin } from '@/lib/supabase/server'
|
||||
|
||||
// PUT /api/presets/[id]
|
||||
// Accepts any subset of: name, data, is_community
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const body = await req.json()
|
||||
const { name, data, is_community } = body
|
||||
|
||||
if (name === undefined && data === undefined && is_community === undefined) {
|
||||
return NextResponse.json({ error: 'Nothing to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
const existingResult = await supabaseAdmin
|
||||
.from('presets')
|
||||
.select('user_id')
|
||||
.eq('id', id)
|
||||
.single()
|
||||
const existing = existingResult.data as { user_id: string | null } | null
|
||||
|
||||
if (!existing || existing.user_id !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Not found or forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = {}
|
||||
if (name !== undefined) updates.name = name
|
||||
if (data !== undefined) updates.data = data
|
||||
if (is_community !== undefined) updates.is_community = is_community
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data: preset, error } = await (supabaseAdmin as any)
|
||||
.from('presets')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
return NextResponse.json({ preset })
|
||||
}
|
||||
|
||||
// DELETE /api/presets/[id]
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const existingResult = await supabaseAdmin
|
||||
.from('presets')
|
||||
.select('user_id, thumbnail_url')
|
||||
.eq('id', id)
|
||||
.single()
|
||||
const existing = existingResult.data as { user_id: string | null; thumbnail_url: string | null } | null
|
||||
|
||||
if (!existing || existing.user_id !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Not found or forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Delete thumbnail from storage if present
|
||||
if (existing.thumbnail_url) {
|
||||
const url = existing.thumbnail_url as string
|
||||
const match = url.match(/preset-thumbnails\/(.+)$/)
|
||||
if (match?.[1]) {
|
||||
await supabaseAdmin.storage.from('preset-thumbnails').remove([match[1]])
|
||||
}
|
||||
}
|
||||
|
||||
const { error } = await supabaseAdmin.from('presets').delete().eq('id', id)
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { headers } from 'next/headers'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { supabaseAdmin } from '@/lib/supabase/server'
|
||||
|
||||
// POST /api/presets/[id]/thumbnail
|
||||
// Accepts a raw PNG blob, uploads to preset-thumbnails bucket, updates thumbnail_url
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const existingResult = await supabaseAdmin
|
||||
.from('presets')
|
||||
.select('user_id')
|
||||
.eq('id', id)
|
||||
.single()
|
||||
const existing = existingResult.data as { user_id: string | null } | null
|
||||
|
||||
if (!existing || existing.user_id !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Not found or forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const blob = await req.blob()
|
||||
|
||||
const filename = `${id}/thumbnail.png`
|
||||
const { data: uploadData, error: uploadError } = await supabaseAdmin.storage
|
||||
.from('preset-thumbnails')
|
||||
.upload(filename, blob, {
|
||||
contentType: 'image/png',
|
||||
upsert: true,
|
||||
})
|
||||
|
||||
if (uploadError) {
|
||||
return NextResponse.json({ error: uploadError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const { data: urlData } = supabaseAdmin.storage
|
||||
.from('preset-thumbnails')
|
||||
.getPublicUrl(uploadData.path)
|
||||
|
||||
const thumbnailUrl = `${urlData.publicUrl}?t=${Date.now()}`
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { error: updateError } = await (supabaseAdmin as any)
|
||||
.from('presets')
|
||||
.update({ thumbnail_url: thumbnailUrl })
|
||||
.eq('id', id)
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ thumbnail_url: thumbnailUrl })
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { headers } from 'next/headers'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { supabaseAdmin } from '@/lib/supabase/server'
|
||||
import { createId } from '@pascal-app/db'
|
||||
|
||||
// GET /api/presets?type=door|window&tab=community|mine
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = req.nextUrl
|
||||
const type = searchParams.get('type')
|
||||
const tab = searchParams.get('tab') ?? 'community'
|
||||
|
||||
if (!type || (type !== 'door' && type !== 'window')) {
|
||||
return NextResponse.json({ error: 'Invalid type' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (tab === 'mine') {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from('presets')
|
||||
.select('*')
|
||||
.eq('type', type)
|
||||
.eq('user_id', session.user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
return NextResponse.json({ presets: data })
|
||||
}
|
||||
|
||||
// community tab
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from('presets')
|
||||
.select('*')
|
||||
.eq('type', type)
|
||||
.eq('is_community', true)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
return NextResponse.json({ presets: data })
|
||||
}
|
||||
|
||||
// POST /api/presets
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { type, name, data, thumbnailUrl } = body
|
||||
|
||||
if (!type || !name || !data) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (type !== 'door' && type !== 'window') {
|
||||
return NextResponse.json({ error: 'Invalid type' }, { status: 400 })
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data: preset, error } = await (supabaseAdmin as any)
|
||||
.from('presets')
|
||||
.insert({
|
||||
id: createId('preset'),
|
||||
type,
|
||||
name,
|
||||
data,
|
||||
thumbnail_url: thumbnailUrl ?? null,
|
||||
user_id: session.user.id,
|
||||
is_community: false,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
return NextResponse.json({ preset }, { status: 201 })
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export default function EditorRouteError({
|
||||
error,
|
||||
reset,
|
||||
}: Readonly<{
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}>) {
|
||||
useEffect(() => {
|
||||
console.error('[editor-route] Unhandled editor error:', error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen w-full items-center justify-center bg-background p-4 text-foreground">
|
||||
<div className="w-full max-w-md rounded-2xl border border-border/60 bg-background p-6 shadow-xl">
|
||||
<h1 className="text-lg font-semibold">Editor error</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
We couldn't load this editor route. You can retry or return home.
|
||||
</p>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
className="rounded-md border border-border bg-accent px-3 py-2 text-sm font-medium hover:bg-accent/80"
|
||||
onClick={reset}
|
||||
type="button"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
<Link
|
||||
className="rounded-md border border-border bg-background px-3 py-2 text-sm font-medium hover:bg-accent/40"
|
||||
href="/"
|
||||
>
|
||||
Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Editor Workspace',
|
||||
description: 'Edit your Pascal projects in a full 3D workspace.',
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
}
|
||||
|
||||
export default function EditorProjectLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<div style={{ cursor: "url('/cursor.svg') 4 2, default" }}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Editor from '@/components/editor'
|
||||
import { SceneLoader } from '@/components/ui/scene-loader'
|
||||
import { useAuth } from '@/features/community/lib/auth/hooks'
|
||||
import { useProjectStore } from '@/features/community/lib/projects/store'
|
||||
|
||||
export default function EditorPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
const { isAuthenticated, isLoading } = useAuth()
|
||||
const setActiveProject = useProjectStore((state) => state.setActiveProject)
|
||||
const router = useRouter()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
// Use layoutEffect to set active project BEFORE the editor renders and hooks run
|
||||
useEffect(() => {
|
||||
if (isLoading) return
|
||||
if (!isAuthenticated) {
|
||||
router.replace('/')
|
||||
return
|
||||
}
|
||||
if (projectId) {
|
||||
setActiveProject(projectId)
|
||||
}
|
||||
}, [projectId, isAuthenticated, isLoading, setActiveProject, router])
|
||||
|
||||
if (!mounted || isLoading) {
|
||||
return <SceneLoader fullScreen />
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full max-w-screen">
|
||||
<div className="relative h-full w-full">
|
||||
<Editor projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+148
-40
@@ -1,12 +1,19 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@source "../../../packages/editor/src";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--font-sans: var(--font-barlow), var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
--font-barlow: var(--font-barlow), var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||
--font-sans:
|
||||
var(--font-barlow), var(--font-geist-sans), ui-sans-serif, system-ui,
|
||||
sans-serif;
|
||||
--font-mono:
|
||||
var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco,
|
||||
Consolas, monospace;
|
||||
--font-barlow:
|
||||
var(--font-barlow), var(--font-geist-sans), ui-sans-serif, system-ui,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -97,7 +104,9 @@
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.235 0 0); /* slightly lighter than background (0.205) but darker than previous (0.269) */
|
||||
--accent: oklch(
|
||||
0.235 0 0
|
||||
); /* slightly lighter than background (0.205) but darker than previous (0.269) */
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
@@ -166,90 +175,189 @@
|
||||
.pascal-loader-1 {
|
||||
width: 45px;
|
||||
aspect-ratio: 1;
|
||||
--c:no-repeat linear-gradient(currentColor 0 0);
|
||||
--c: no-repeat linear-gradient(currentColor 0 0);
|
||||
background: var(--c), var(--c), var(--c);
|
||||
animation:
|
||||
animation:
|
||||
pascal-l1-1 1s infinite,
|
||||
pascal-l1-2 1s infinite;
|
||||
}
|
||||
@keyframes pascal-l1-1 {
|
||||
0%,100% {background-size:20% 100%}
|
||||
33%,66% {background-size:20% 20%}
|
||||
0%,
|
||||
100% {
|
||||
background-size: 20% 100%;
|
||||
}
|
||||
33%,
|
||||
66% {
|
||||
background-size: 20% 20%;
|
||||
}
|
||||
}
|
||||
@keyframes pascal-l1-2 {
|
||||
0%,33% {background-position: 0 0,50% 50%,100% 100%}
|
||||
66%,100% {background-position: 100% 0,50% 50%,0 100%}
|
||||
0%,
|
||||
33% {
|
||||
background-position:
|
||||
0 0,
|
||||
50% 50%,
|
||||
100% 100%;
|
||||
}
|
||||
66%,
|
||||
100% {
|
||||
background-position:
|
||||
100% 0,
|
||||
50% 50%,
|
||||
0 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.pascal-loader-2 {
|
||||
width: 45px;
|
||||
aspect-ratio: .75;
|
||||
aspect-ratio: 0.75;
|
||||
--c: no-repeat linear-gradient(currentColor 0 0);
|
||||
background:
|
||||
var(--c) 0% 50%,
|
||||
var(--c) 50% 50%,
|
||||
background:
|
||||
var(--c) 0% 50%,
|
||||
var(--c) 50% 50%,
|
||||
var(--c) 100% 50%;
|
||||
background-size: 20% 50%;
|
||||
animation: pascal-l2 1s infinite linear;
|
||||
}
|
||||
@keyframes pascal-l2 {
|
||||
20% {background-position: 0% 0% ,50% 50% ,100% 50% }
|
||||
40% {background-position: 0% 100%,50% 0% ,100% 50% }
|
||||
60% {background-position: 0% 50% ,50% 100%,100% 0% }
|
||||
80% {background-position: 0% 50% ,50% 50% ,100% 100%}
|
||||
20% {
|
||||
background-position:
|
||||
0% 0%,
|
||||
50% 50%,
|
||||
100% 50%;
|
||||
}
|
||||
40% {
|
||||
background-position:
|
||||
0% 100%,
|
||||
50% 0%,
|
||||
100% 50%;
|
||||
}
|
||||
60% {
|
||||
background-position:
|
||||
0% 50%,
|
||||
50% 100%,
|
||||
100% 0%;
|
||||
}
|
||||
80% {
|
||||
background-position:
|
||||
0% 50%,
|
||||
50% 50%,
|
||||
100% 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.pascal-loader-3 {
|
||||
width: 45px;
|
||||
aspect-ratio: .75;
|
||||
--c:no-repeat linear-gradient(currentColor 0 0);
|
||||
background:
|
||||
var(--c) 0% 100%,
|
||||
var(--c) 50% 100%,
|
||||
aspect-ratio: 0.75;
|
||||
--c: no-repeat linear-gradient(currentColor 0 0);
|
||||
background:
|
||||
var(--c) 0% 100%,
|
||||
var(--c) 50% 100%,
|
||||
var(--c) 100% 100%;
|
||||
background-size: 20% 65%;
|
||||
animation: pascal-l3 1s infinite linear;
|
||||
}
|
||||
@keyframes pascal-l3 {
|
||||
16.67% {background-position: 0% 0% ,50% 100%,100% 100%}
|
||||
33.33% {background-position: 0% 0% ,50% 0% ,100% 100%}
|
||||
50% {background-position: 0% 0% ,50% 0% ,100% 0% }
|
||||
66.67% {background-position: 0% 100%,50% 0% ,100% 0% }
|
||||
83.33% {background-position: 0% 100%,50% 100%,100% 0% }
|
||||
16.67% {
|
||||
background-position:
|
||||
0% 0%,
|
||||
50% 100%,
|
||||
100% 100%;
|
||||
}
|
||||
33.33% {
|
||||
background-position:
|
||||
0% 0%,
|
||||
50% 0%,
|
||||
100% 100%;
|
||||
}
|
||||
50% {
|
||||
background-position:
|
||||
0% 0%,
|
||||
50% 0%,
|
||||
100% 0%;
|
||||
}
|
||||
66.67% {
|
||||
background-position:
|
||||
0% 100%,
|
||||
50% 0%,
|
||||
100% 0%;
|
||||
}
|
||||
83.33% {
|
||||
background-position:
|
||||
0% 100%,
|
||||
50% 100%,
|
||||
100% 0%;
|
||||
}
|
||||
}
|
||||
|
||||
.pascal-loader-4 {
|
||||
width: 45px;
|
||||
aspect-ratio: 1;
|
||||
--c:no-repeat linear-gradient(currentColor 0 0);
|
||||
--c: no-repeat linear-gradient(currentColor 0 0);
|
||||
background: var(--c), var(--c), var(--c);
|
||||
animation:
|
||||
animation:
|
||||
pascal-l4-1 1s infinite,
|
||||
pascal-l4-2 1s infinite;
|
||||
}
|
||||
@keyframes pascal-l4-1 {
|
||||
0%,100% {background-size:20% 100%}
|
||||
33%,66% {background-size:20% 40%}
|
||||
0%,
|
||||
100% {
|
||||
background-size: 20% 100%;
|
||||
}
|
||||
33%,
|
||||
66% {
|
||||
background-size: 20% 40%;
|
||||
}
|
||||
}
|
||||
@keyframes pascal-l4-2 {
|
||||
0%,33% {background-position: 0 0,50% 100%,100% 100%}
|
||||
66%,100% {background-position: 100% 0,0 100%,50% 100%}
|
||||
0%,
|
||||
33% {
|
||||
background-position:
|
||||
0 0,
|
||||
50% 100%,
|
||||
100% 100%;
|
||||
}
|
||||
66%,
|
||||
100% {
|
||||
background-position:
|
||||
100% 0,
|
||||
0 100%,
|
||||
50% 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.pascal-loader-5 {
|
||||
width: 45px;
|
||||
aspect-ratio: 1;
|
||||
--c:no-repeat linear-gradient(currentColor 0 0);
|
||||
--c: no-repeat linear-gradient(currentColor 0 0);
|
||||
background: var(--c), var(--c), var(--c);
|
||||
animation:
|
||||
animation:
|
||||
pascal-l5-1 1s infinite,
|
||||
pascal-l5-2 1s infinite;
|
||||
}
|
||||
@keyframes pascal-l5-1 {
|
||||
0%,100% {background-size:20% 100%}
|
||||
33%,66% {background-size:20% 40%}
|
||||
0%,
|
||||
100% {
|
||||
background-size: 20% 100%;
|
||||
}
|
||||
33%,
|
||||
66% {
|
||||
background-size: 20% 40%;
|
||||
}
|
||||
}
|
||||
@keyframes pascal-l5-2 {
|
||||
0%,33% {background-position: 0 0 ,50% 100%,100% 0}
|
||||
66%,100% {background-position: 0 100%,50% 0 ,100% 100%}
|
||||
0%,
|
||||
33% {
|
||||
background-position:
|
||||
0 0,
|
||||
50% 100%,
|
||||
100% 0;
|
||||
}
|
||||
66%,
|
||||
100% {
|
||||
background-position:
|
||||
0 100%,
|
||||
50% 0,
|
||||
100% 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Script from 'next/script'
|
||||
import localFont from 'next/font/local'
|
||||
import { Barlow } from 'next/font/google'
|
||||
import { Analytics } from '@vercel/analytics/react'
|
||||
import { SpeedInsights } from '@vercel/speed-insights/next'
|
||||
import { VercelToolbar } from '@vercel/toolbar/next'
|
||||
import { UsernameGate } from '@/features/community/components/username-gate'
|
||||
import { siteConfig } from './seo'
|
||||
import localFont from 'next/font/local'
|
||||
import Script from 'next/script'
|
||||
import './globals.css'
|
||||
|
||||
const geistSans = localFont({
|
||||
@@ -26,48 +21,8 @@ const barlow = Barlow({
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(siteConfig.url),
|
||||
title: {
|
||||
default: siteConfig.name,
|
||||
template: '%s | Pascal Editor',
|
||||
},
|
||||
description: siteConfig.description,
|
||||
applicationName: siteConfig.name,
|
||||
keywords: [...siteConfig.keywords],
|
||||
authors: [{ name: 'Pascal', url: 'https://pascal.app' }],
|
||||
creator: 'Pascal',
|
||||
publisher: 'Pascal',
|
||||
alternates: {
|
||||
canonical: '/',
|
||||
},
|
||||
icons: [{ rel: 'icon', url: '/favicon.ico' }],
|
||||
openGraph: {
|
||||
title: siteConfig.name,
|
||||
description: siteConfig.description,
|
||||
url: siteConfig.url,
|
||||
siteName: siteConfig.name,
|
||||
images: [{ url: siteConfig.ogImage, alt: 'Pascal Editor' }],
|
||||
locale: 'en_US',
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: siteConfig.name,
|
||||
description: siteConfig.description,
|
||||
creator: siteConfig.twitterHandle,
|
||||
images: [siteConfig.ogImage],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
'max-image-preview': 'large',
|
||||
'max-snippet': -1,
|
||||
'max-video-preview': -1,
|
||||
},
|
||||
},
|
||||
title: 'Pascal Editor',
|
||||
description: 'Standalone building editor',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -75,32 +30,25 @@ export default function RootLayout({
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
const shouldShowToolbar = process.env.NODE_ENV === 'development'
|
||||
|
||||
return (
|
||||
<html lang="en" className={`${geistSans.variable} ${geistMono.variable} ${barlow.variable}`}>
|
||||
<html className={`${geistSans.variable} ${geistMono.variable} ${barlow.variable}`} lang="en">
|
||||
<head>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<>
|
||||
<Script
|
||||
src="//unpkg.com/react-scan/dist/auto.global.js"
|
||||
crossOrigin="anonymous"
|
||||
src="//unpkg.com/react-scan/dist/auto.global.js"
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
<Script
|
||||
src="//unpkg.com/react-grab/dist/index.global.js"
|
||||
crossOrigin="anonymous"
|
||||
src="//unpkg.com/react-grab/dist/index.global.js"
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</head>
|
||||
<body className="font-sans">
|
||||
<UsernameGate>{children}</UsernameGate>
|
||||
<Analytics />
|
||||
<SpeedInsights />
|
||||
{shouldShowToolbar && <VercelToolbar />}
|
||||
</body>
|
||||
<body className="font-sans">{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { ImageResponse } from 'next/og'
|
||||
import * as z from 'zod'
|
||||
import { siteConfig } from '@/app/seo'
|
||||
|
||||
const ogImageSchema = z.object({
|
||||
title: z.string().default(''),
|
||||
description: z.string().default(''),
|
||||
theme: z.enum(['light', 'dark']).default('dark'),
|
||||
})
|
||||
|
||||
let sansFont: ArrayBuffer | null = null
|
||||
let jakartaFont: ArrayBuffer | null = null
|
||||
|
||||
async function loadFonts() {
|
||||
if (!(sansFont && jakartaFont)) {
|
||||
const fontDir = join(process.cwd(), 'public', 'fonts')
|
||||
const [sans, jakarta] = await Promise.all([
|
||||
readFile(join(fontDir, 'geist-regular.ttf')),
|
||||
readFile(join(fontDir, 'PlusJakartaSans-SemiBold.ttf')),
|
||||
])
|
||||
sansFont = sans.buffer.slice(sans.byteOffset, sans.byteOffset + sans.byteLength)
|
||||
jakartaFont = jakarta.buffer.slice(jakarta.byteOffset, jakarta.byteOffset + jakarta.byteLength)
|
||||
}
|
||||
return { sans: sansFont, jakarta: jakartaFont }
|
||||
}
|
||||
|
||||
function PascalLogo({ color = '#fff', size = 100 }: { color?: string; size?: number }) {
|
||||
return (
|
||||
<svg fill="none" height={size} style={{ display: 'flex' }} viewBox="0 0 100 100" width={size}>
|
||||
<rect fill={color} height="40" width="20" y="60" />
|
||||
<rect fill={color} height="40" width="20" x="40" y="30" />
|
||||
<rect fill={color} height="40" width="20" x="80" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const url = new URL(req.url)
|
||||
const values = ogImageSchema.parse(Object.fromEntries(url.searchParams))
|
||||
const heading =
|
||||
values.title.length > 140 ? `${values.title.substring(0, 140)}...` : values.title
|
||||
|
||||
const { theme } = values
|
||||
const paint = theme === 'dark' ? '#fff' : '#000'
|
||||
const logoColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.9)'
|
||||
|
||||
const fontSize = heading.length > 100 ? '70px' : '100px'
|
||||
|
||||
const showLargeLogo = !(values.title || values.description)
|
||||
|
||||
return new ImageResponse(
|
||||
<div
|
||||
style={{
|
||||
color: paint,
|
||||
background:
|
||||
theme === 'dark'
|
||||
? 'linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 50%, #0f0f0f 100%)'
|
||||
: 'white',
|
||||
}}
|
||||
tw="flex relative flex-col w-full h-full items-start bg-cover"
|
||||
>
|
||||
{showLargeLogo ? (
|
||||
<div tw="flex flex-col flex-1 py-10 px-12 h-full justify-center">
|
||||
<div style={{ display: 'flex', marginBottom: 24 }}>
|
||||
<PascalLogo color={logoColor} size={80} />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'jakarta-semibold',
|
||||
fontWeight: 'bolder',
|
||||
fontSize: '72px',
|
||||
letterSpacing: '-0.02em',
|
||||
color: paint,
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
Pascal Editor
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'sans-regular',
|
||||
fontWeight: 'normal',
|
||||
fontSize: '28px',
|
||||
letterSpacing: '0.05em',
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
marginTop: 16,
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
{siteConfig.description}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div tw="flex flex-col flex-1 py-10 px-12 h-full">
|
||||
<div style={{ display: 'flex', marginBottom: 24 }}>
|
||||
<PascalLogo color={logoColor} size={40} />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'jakarta-semibold',
|
||||
fontWeight: 'bolder',
|
||||
marginLeft: '-3px',
|
||||
fontSize,
|
||||
letterSpacing: '.05rem',
|
||||
}}
|
||||
tw="flex leading-[1.1] text-[80px] tracking-tighter font-sans mb-4"
|
||||
>
|
||||
{heading}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'sans-regular',
|
||||
fontWeight: 'normal',
|
||||
letterSpacing: '.1rem',
|
||||
}}
|
||||
tw="flex flex-1 text-[50px] tracking-tight font-sans"
|
||||
>
|
||||
{values.description}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!showLargeLogo && (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'sans-regular',
|
||||
fontWeight: 'normal',
|
||||
letterSpacing: '.1rem',
|
||||
}}
|
||||
tw="w-full pt-24 p-10 text-[30px] tracking-tight font-sans text-right w-full"
|
||||
>
|
||||
editor.pascal.app
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fonts: await loadFonts().then(({ sans, jakarta }) => [
|
||||
{
|
||||
name: 'sans-regular',
|
||||
data: sans,
|
||||
style: 'normal' as const,
|
||||
weight: 400 as const,
|
||||
},
|
||||
{
|
||||
name: 'jakarta-semibold',
|
||||
data: jakarta,
|
||||
style: 'normal' as const,
|
||||
weight: 600 as const,
|
||||
},
|
||||
]),
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
console.log('error', error)
|
||||
return new Response('Failed to generate image', {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { Metadata } from 'next'
|
||||
import CommunityHub from '@/features/community/components/community-hub'
|
||||
'use client'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Community Projects',
|
||||
description:
|
||||
'Create and share 3D home projects with Pascal Editor, the open-source building editor.',
|
||||
}
|
||||
import { Editor } from '@pascal-app/editor'
|
||||
|
||||
export default function Home() {
|
||||
return <CommunityHub />
|
||||
return (
|
||||
<div className="h-screen w-screen">
|
||||
<Editor />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,100 +9,101 @@ export const metadata: Metadata = {
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="border-b border-border bg-background/95 backdrop-blur sticky top-0 z-10">
|
||||
<header className="sticky top-0 z-10 border-border border-b bg-background/95 backdrop-blur">
|
||||
<div className="container mx-auto px-6 py-4">
|
||||
<nav className="flex items-center gap-4 text-sm">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
href="/"
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<Link
|
||||
href="/terms"
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
href="/terms"
|
||||
>
|
||||
Terms of Service
|
||||
</Link>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-foreground font-medium">Privacy Policy</span>
|
||||
<span className="font-medium text-foreground">Privacy Policy</span>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto max-w-3xl px-6 py-12">
|
||||
<article className="prose prose-neutral dark:prose-invert max-w-none">
|
||||
<h1 className="text-3xl font-bold mb-2">Privacy Policy</h1>
|
||||
<p className="text-muted-foreground text-sm mb-8">
|
||||
Effective Date: February 20, 2026
|
||||
</p>
|
||||
<h1 className="mb-2 font-bold text-3xl">Privacy Policy</h1>
|
||||
<p className="mb-8 text-muted-foreground text-sm">Effective Date: February 20, 2026</p>
|
||||
|
||||
<section className="space-y-4 mb-8">
|
||||
<h2 className="text-xl font-semibold">1. Introduction</h2>
|
||||
<section className="mb-8 space-y-4">
|
||||
<h2 className="font-semibold text-xl">1. Introduction</h2>
|
||||
<p className="text-foreground/90 leading-relaxed">
|
||||
Pascal Group Inc. ("we," "us," or "our") operates the Pascal Editor and
|
||||
Platform at pascal.app. This Privacy Policy explains how we collect, use, and
|
||||
protect your information when you use our services.
|
||||
Pascal Group Inc. ("we," "us," or "our") operates the
|
||||
Pascal Editor and Platform at pascal.app. This Privacy Policy explains how we collect,
|
||||
use, and protect your information when you use our services.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 mb-8">
|
||||
<h2 className="text-xl font-semibold">2. Information We Collect</h2>
|
||||
<section className="mb-8 space-y-4">
|
||||
<h2 className="font-semibold text-xl">2. Information We Collect</h2>
|
||||
|
||||
<h3 className="text-lg font-medium mt-4">Account Information</h3>
|
||||
<h3 className="mt-4 font-medium text-lg">Account Information</h3>
|
||||
<p className="text-foreground/90 leading-relaxed">
|
||||
When you create an account, we collect:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2 text-foreground/90">
|
||||
<ul className="list-disc space-y-2 pl-6 text-foreground/90">
|
||||
<li>Email address</li>
|
||||
<li>Name</li>
|
||||
<li>Profile picture/avatar</li>
|
||||
<li>OAuth provider data (from Google when you sign in with Google)</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-medium mt-4">Project Data</h3>
|
||||
<h3 className="mt-4 font-medium text-lg">Project Data</h3>
|
||||
<p className="text-foreground/90 leading-relaxed">
|
||||
When you use the Platform, we store your projects, including 3D building designs,
|
||||
floor plans, and associated metadata.
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-medium mt-4">Usage Analytics</h3>
|
||||
<h3 className="mt-4 font-medium text-lg">Usage Analytics</h3>
|
||||
<p className="text-foreground/90 leading-relaxed">
|
||||
We use Vercel Analytics and Speed Insights to collect anonymized usage data,
|
||||
including page views, performance metrics, and general usage patterns. This helps
|
||||
us improve the Platform.
|
||||
We use Vercel Analytics and Speed Insights to collect anonymized usage data, including
|
||||
page views, performance metrics, and general usage patterns. This helps us improve the
|
||||
Platform.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 mb-8">
|
||||
<h2 className="text-xl font-semibold">3. How We Use Your Information</h2>
|
||||
<section className="mb-8 space-y-4">
|
||||
<h2 className="font-semibold text-xl">3. How We Use Your Information</h2>
|
||||
<p className="text-foreground/90 leading-relaxed">We use your information to:</p>
|
||||
<ul className="list-disc pl-6 space-y-2 text-foreground/90">
|
||||
<ul className="list-disc space-y-2 pl-6 text-foreground/90">
|
||||
<li>Provide and maintain your account</li>
|
||||
<li>Store and sync your projects across devices</li>
|
||||
<li>Improve our services based on usage patterns</li>
|
||||
<li>Send optional email notifications about new features and updates (you can opt out in settings)</li>
|
||||
<li>
|
||||
Send optional email notifications about new features and updates (you can opt out in
|
||||
settings)
|
||||
</li>
|
||||
<li>Respond to support requests</li>
|
||||
<li>Ensure platform security and prevent abuse</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 mb-8">
|
||||
<h2 className="text-xl font-semibold">4. Data Storage</h2>
|
||||
<section className="mb-8 space-y-4">
|
||||
<h2 className="font-semibold text-xl">4. Data Storage</h2>
|
||||
<p className="text-foreground/90 leading-relaxed">
|
||||
Your data is stored using Supabase (PostgreSQL database) on secure cloud
|
||||
infrastructure. We implement appropriate technical and organizational measures
|
||||
to protect your data.
|
||||
infrastructure. We implement appropriate technical and organizational measures to
|
||||
protect your data.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 mb-8">
|
||||
<h2 className="text-xl font-semibold">5. Third-Party Services</h2>
|
||||
<section className="mb-8 space-y-4">
|
||||
<h2 className="font-semibold text-xl">5. Third-Party Services</h2>
|
||||
<p className="text-foreground/90 leading-relaxed">
|
||||
We use the following third-party services to operate the Platform:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2 text-foreground/90">
|
||||
<ul className="list-disc space-y-2 pl-6 text-foreground/90">
|
||||
<li>
|
||||
<strong>Google</strong> - OAuth authentication for sign-in
|
||||
</li>
|
||||
@@ -113,44 +114,44 @@ export default function PrivacyPage() {
|
||||
<strong>Supabase</strong> - Database hosting and authentication infrastructure
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-foreground/90 leading-relaxed mt-4">
|
||||
Each of these services has their own privacy policies governing their handling
|
||||
of your data.
|
||||
<p className="mt-4 text-foreground/90 leading-relaxed">
|
||||
Each of these services has their own privacy policies governing their handling of your
|
||||
data.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 mb-8">
|
||||
<h2 className="text-xl font-semibold">6. Cookies</h2>
|
||||
<section className="mb-8 space-y-4">
|
||||
<h2 className="font-semibold text-xl">6. Cookies</h2>
|
||||
<p className="text-foreground/90 leading-relaxed">
|
||||
We use minimal cookies necessary for the Platform to function:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2 text-foreground/90">
|
||||
<ul className="list-disc space-y-2 pl-6 text-foreground/90">
|
||||
<li>
|
||||
<strong>Session cookies</strong> - Essential for authentication and keeping you
|
||||
signed in
|
||||
</li>
|
||||
<li>
|
||||
<strong>Analytics cookies</strong> - Used by Vercel Analytics to collect
|
||||
anonymized usage data
|
||||
<strong>Analytics cookies</strong> - Used by Vercel Analytics to collect anonymized
|
||||
usage data
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 mb-8">
|
||||
<h2 className="text-xl font-semibold">7. Your Rights</h2>
|
||||
<section className="mb-8 space-y-4">
|
||||
<h2 className="font-semibold text-xl">7. Your Rights</h2>
|
||||
<p className="text-foreground/90 leading-relaxed">You have the right to:</p>
|
||||
<ul className="list-disc pl-6 space-y-2 text-foreground/90">
|
||||
<ul className="list-disc space-y-2 pl-6 text-foreground/90">
|
||||
<li>Access the personal data we hold about you</li>
|
||||
<li>Request correction of inaccurate data</li>
|
||||
<li>Request deletion of your data</li>
|
||||
<li>Export your project data</li>
|
||||
<li>Opt out of marketing communications</li>
|
||||
</ul>
|
||||
<p className="text-foreground/90 leading-relaxed mt-4">
|
||||
<p className="mt-4 text-foreground/90 leading-relaxed">
|
||||
To exercise any of these rights, please contact us at{' '}
|
||||
<a
|
||||
href="mailto:support@pascal.app"
|
||||
className="text-foreground underline hover:text-foreground/80"
|
||||
href="mailto:support@pascal.app"
|
||||
>
|
||||
support@pascal.app
|
||||
</a>
|
||||
@@ -158,42 +159,41 @@ export default function PrivacyPage() {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 mb-8">
|
||||
<h2 className="text-xl font-semibold">8. Data Retention</h2>
|
||||
<section className="mb-8 space-y-4">
|
||||
<h2 className="font-semibold text-xl">8. Data Retention</h2>
|
||||
<p className="text-foreground/90 leading-relaxed">
|
||||
We retain your data for as long as your account is active. If you delete your
|
||||
account, we will delete your personal data and project data within 30 days,
|
||||
except where we are required by law to retain certain information.
|
||||
We retain your data for as long as your account is active. If you delete your account,
|
||||
we will delete your personal data and project data within 30 days, except where we are
|
||||
required by law to retain certain information.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 mb-8">
|
||||
<h2 className="text-xl font-semibold">9. Children's Privacy</h2>
|
||||
<section className="mb-8 space-y-4">
|
||||
<h2 className="font-semibold text-xl">9. Children's Privacy</h2>
|
||||
<p className="text-foreground/90 leading-relaxed">
|
||||
The Platform is not intended for children under 13. We do not knowingly collect
|
||||
personal information from children under 13. If you believe we have collected
|
||||
such information, please contact us immediately.
|
||||
personal information from children under 13. If you believe we have collected such
|
||||
information, please contact us immediately.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 mb-8">
|
||||
<h2 className="text-xl font-semibold">10. Changes to This Policy</h2>
|
||||
<section className="mb-8 space-y-4">
|
||||
<h2 className="font-semibold text-xl">10. Changes to This Policy</h2>
|
||||
<p className="text-foreground/90 leading-relaxed">
|
||||
We may update this Privacy Policy from time to time. We will notify you of
|
||||
material changes by posting the updated policy on the Platform. Your continued
|
||||
use of the Platform after changes are posted constitutes your acceptance of the
|
||||
revised policy.
|
||||
We may update this Privacy Policy from time to time. We will notify you of material
|
||||
changes by posting the updated policy on the Platform. Your continued use of the
|
||||
Platform after changes are posted constitutes your acceptance of the revised policy.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">11. Contact Us</h2>
|
||||
<h2 className="font-semibold text-xl">11. Contact Us</h2>
|
||||
<p className="text-foreground/90 leading-relaxed">
|
||||
If you have questions about this Privacy Policy or how we handle your data,
|
||||
please contact us at{' '}
|
||||
If you have questions about this Privacy Policy or how we handle your data, please
|
||||
contact us at{' '}
|
||||
<a
|
||||
href="mailto:support@pascal.app"
|
||||
className="text-foreground underline hover:text-foreground/80"
|
||||
href="mailto:support@pascal.app"
|
||||
>
|
||||
support@pascal.app
|
||||
</a>
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { MetadataRoute } from 'next'
|
||||
import { siteConfig } from './seo'
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: ['/', '/viewer/', '/u/'],
|
||||
disallow: ['/api/', '/editor/', '/settings', '/_next/'],
|
||||
},
|
||||
],
|
||||
sitemap: `${siteConfig.url}/sitemap.xml`,
|
||||
host: siteConfig.url,
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { BASE_URL } from '@/lib/utils'
|
||||
|
||||
export const siteConfig = {
|
||||
name: 'Pascal Editor',
|
||||
description:
|
||||
'Pascal Editor is an open-source 3D building editor for designing, editing, and sharing home projects.',
|
||||
url: BASE_URL,
|
||||
website: 'editor.pascal.app',
|
||||
ogImage: '/og',
|
||||
keywords: [
|
||||
'Pascal Editor',
|
||||
'open-source 3D editor',
|
||||
'3D building editor',
|
||||
'home design software',
|
||||
'architecture editor',
|
||||
'collaborative design',
|
||||
],
|
||||
twitterHandle: '@pascal_app',
|
||||
links: {
|
||||
github: 'https://github.com/pascalorg/editor',
|
||||
},
|
||||
} as const
|
||||
@@ -1,40 +0,0 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import type { Metadata } from 'next'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/features/community/lib/auth/server'
|
||||
import { getUserProfile, getConnectedAccounts } from '@/features/community/lib/auth/actions'
|
||||
import { SettingsPage } from '@/features/community/components/settings-page'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Account Settings',
|
||||
description: 'Manage your Pascal Editor account profile and connected providers.',
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
}
|
||||
|
||||
export default async function Settings() {
|
||||
const session = await getSession()
|
||||
if (!session?.user) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
const [profile, connectedAccounts] = await Promise.all([
|
||||
getUserProfile(),
|
||||
getConnectedAccounts(),
|
||||
])
|
||||
|
||||
return (
|
||||
<SettingsPage
|
||||
user={session.user}
|
||||
currentUsername={profile?.username ?? null}
|
||||
currentGithubUrl={profile?.githubUrl ?? null}
|
||||
currentXUrl={profile?.xUrl ?? null}
|
||||
currentYoutubeUrl={profile?.youtubeUrl ?? null}
|
||||
currentEmailNotifications={profile?.emailNotifications ?? true}
|
||||
connectedAccounts={connectedAccounts}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { MetadataRoute } from 'next'
|
||||
import { siteConfig } from './seo'
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const currentDate = new Date()
|
||||
const baseUrl = siteConfig.url
|
||||
|
||||
return [
|
||||
{
|
||||
url: baseUrl,
|
||||
lastModified: currentDate,
|
||||
changeFrequency: 'daily',
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/viewer/demo_1`,
|
||||
lastModified: currentDate,
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.7,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -9,21 +9,21 @@ export const metadata: Metadata = {
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="border-b border-border bg-background/95 backdrop-blur sticky top-0 z-10">
|
||||
<header className="sticky top-0 z-10 border-border border-b bg-background/95 backdrop-blur">
|
||||
<div className="container mx-auto px-6 py-4">
|
||||
<nav className="flex items-center gap-4 text-sm">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
href="/"
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span className="text-foreground font-medium">Terms of Service</span>
|
||||
<span className="font-medium text-foreground">Terms of Service</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<Link
|
||||
href="/privacy"
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
href="/privacy"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
@@ -33,26 +33,25 @@ export default function TermsPage() {
|
||||
|
||||
<main className="container mx-auto max-w-3xl px-6 py-12">
|
||||
<article className="prose prose-neutral dark:prose-invert max-w-none">
|
||||
<h1 className="text-3xl font-bold mb-2">Terms of Service</h1>
|
||||
<p className="text-muted-foreground text-sm mb-8">
|
||||
Effective Date: February 20, 2026
|
||||
</p>
|
||||
<h1 className="mb-2 font-bold text-3xl">Terms of Service</h1>
|
||||
<p className="mb-8 text-muted-foreground text-sm">Effective Date: February 20, 2026</p>
|
||||
|
||||
<section className="space-y-4 mb-8">
|
||||
<h2 className="text-xl font-semibold">1. Introduction</h2>
|
||||
<section className="mb-8 space-y-4">
|
||||
<h2 className="font-semibold text-xl">1. Introduction</h2>
|
||||
<p className="text-foreground/90 leading-relaxed">
|
||||
Welcome to Pascal Editor ("Editor") and the Pascal platform at pascal.app
|
||||
("Platform"), operated by Pascal Group Inc. ("we," "us," or "our").
|
||||
By accessing or using our services, you agree to these Terms of Service.
|
||||
("Platform"), operated by Pascal Group Inc. ("we," "us,"
|
||||
or "our"). By accessing or using our services, you agree to these Terms of
|
||||
Service.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 mb-8">
|
||||
<h2 className="text-xl font-semibold">2. The Editor and Platform</h2>
|
||||
<section className="mb-8 space-y-4">
|
||||
<h2 className="font-semibold text-xl">2. The Editor and Platform</h2>
|
||||
<p className="text-foreground/90 leading-relaxed">
|
||||
The Pascal Editor is open-source software released under the MIT License.
|
||||
You may use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Editor software in accordance with the MIT License terms.
|
||||
The Pascal Editor is open-source software released under the MIT License. You may use,
|
||||
copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Editor
|
||||
software in accordance with the MIT License terms.
|
||||
</p>
|
||||
<p className="text-foreground/90 leading-relaxed">
|
||||
The Pascal platform (pascal.app) and its associated services, including user accounts,
|
||||
@@ -61,22 +60,26 @@ export default function TermsPage() {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 mb-8">
|
||||
<h2 className="text-xl font-semibold">3. Accounts and Authentication</h2>
|
||||
<section className="mb-8 space-y-4">
|
||||
<h2 className="font-semibold text-xl">3. Accounts and Authentication</h2>
|
||||
<p className="text-foreground/90 leading-relaxed">
|
||||
To use certain features of the Platform, you must create an account. We use
|
||||
Google OAuth and magic link email authentication through Supabase. You are
|
||||
responsible for maintaining the security of your account credentials and for
|
||||
all activities that occur under your account.
|
||||
To use certain features of the Platform, you must create an account. We use Google
|
||||
OAuth and magic link email authentication through Supabase. You are responsible for
|
||||
maintaining the security of your account credentials and for all activities that occur
|
||||
under your account.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 mb-8">
|
||||
<h2 className="text-xl font-semibold">4. Acceptable Use</h2>
|
||||
<section className="mb-8 space-y-4">
|
||||
<h2 className="font-semibold text-xl">4. Acceptable Use</h2>
|
||||
<p className="text-foreground/90 leading-relaxed">You agree not to:</p>
|
||||
<ul className="list-disc pl-6 space-y-2 text-foreground/90">
|
||||
<li>Use the Platform for any unlawful purpose or in violation of any applicable laws</li>
|
||||
<li>Upload, share, or distribute content that infringes intellectual property rights</li>
|
||||
<ul className="list-disc space-y-2 pl-6 text-foreground/90">
|
||||
<li>
|
||||
Use the Platform for any unlawful purpose or in violation of any applicable laws
|
||||
</li>
|
||||
<li>
|
||||
Upload, share, or distribute content that infringes intellectual property rights
|
||||
</li>
|
||||
<li>Attempt to gain unauthorized access to the Platform or its systems</li>
|
||||
<li>Interfere with or disrupt the Platform's infrastructure</li>
|
||||
<li>Upload malicious code, viruses, or harmful content</li>
|
||||
@@ -85,39 +88,39 @@ export default function TermsPage() {
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 mb-8">
|
||||
<h2 className="text-xl font-semibold">5. Your Content and Intellectual Property</h2>
|
||||
<section className="mb-8 space-y-4">
|
||||
<h2 className="font-semibold text-xl">5. Your Content and Intellectual Property</h2>
|
||||
<p className="text-foreground/90 leading-relaxed">
|
||||
You retain full ownership of all content, projects, and data you create or upload
|
||||
to the Platform ("Your Content"). By using the Platform, you grant us a limited
|
||||
license to store, display, and transmit Your Content solely to provide our services
|
||||
to you.
|
||||
You retain full ownership of all content, projects, and data you create or upload to
|
||||
the Platform ("Your Content"). By using the Platform, you grant us a limited
|
||||
license to store, display, and transmit Your Content solely to provide our services to
|
||||
you.
|
||||
</p>
|
||||
<p className="text-foreground/90 leading-relaxed">
|
||||
We do not claim any ownership rights over Your Content. You may export or delete
|
||||
Your Content at any time.
|
||||
We do not claim any ownership rights over Your Content. You may export or delete Your
|
||||
Content at any time.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 mb-8">
|
||||
<h2 className="text-xl font-semibold">6. Platform Ownership</h2>
|
||||
<section className="mb-8 space-y-4">
|
||||
<h2 className="font-semibold text-xl">6. Platform Ownership</h2>
|
||||
<p className="text-foreground/90 leading-relaxed">
|
||||
The Platform, including its design, features, and proprietary code, is owned by
|
||||
Pascal Group Inc. and protected by intellectual property laws. While the Editor
|
||||
source code is open-source under the MIT License, the Platform services, branding,
|
||||
and infrastructure remain our proprietary property.
|
||||
The Platform, including its design, features, and proprietary code, is owned by Pascal
|
||||
Group Inc. and protected by intellectual property laws. While the Editor source code
|
||||
is open-source under the MIT License, the Platform services, branding, and
|
||||
infrastructure remain our proprietary property.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 mb-8">
|
||||
<h2 className="text-xl font-semibold">7. Account Termination</h2>
|
||||
<section className="mb-8 space-y-4">
|
||||
<h2 className="font-semibold text-xl">7. Account Termination</h2>
|
||||
<p className="text-foreground/90 leading-relaxed">
|
||||
We reserve the right to suspend or terminate your account if you violate these
|
||||
Terms or engage in conduct that we determine is harmful to the Platform or other
|
||||
users. You may also delete your account at any time by contacting us at{' '}
|
||||
We reserve the right to suspend or terminate your account if you violate these Terms
|
||||
or engage in conduct that we determine is harmful to the Platform or other users. You
|
||||
may also delete your account at any time by contacting us at{' '}
|
||||
<a
|
||||
href="mailto:support@pascal.app"
|
||||
className="text-foreground underline hover:text-foreground/80"
|
||||
href="mailto:support@pascal.app"
|
||||
>
|
||||
support@pascal.app
|
||||
</a>
|
||||
@@ -125,45 +128,45 @@ export default function TermsPage() {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 mb-8">
|
||||
<h2 className="text-xl font-semibold">8. Disclaimer of Warranties</h2>
|
||||
<section className="mb-8 space-y-4">
|
||||
<h2 className="font-semibold text-xl">8. Disclaimer of Warranties</h2>
|
||||
<p className="text-foreground/90 leading-relaxed">
|
||||
THE PLATFORM IS PROVIDED "AS IS" AND "AS AVAILABLE" WITHOUT WARRANTIES OF ANY
|
||||
KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO IMPLIED WARRANTIES
|
||||
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.
|
||||
THE PLATFORM IS PROVIDED "AS IS" AND "AS AVAILABLE" WITHOUT
|
||||
WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND
|
||||
NON-INFRINGEMENT.
|
||||
</p>
|
||||
<p className="text-foreground/90 leading-relaxed">
|
||||
We do not warrant that the Platform will be uninterrupted, error-free, or free
|
||||
of harmful components.
|
||||
We do not warrant that the Platform will be uninterrupted, error-free, or free of
|
||||
harmful components.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 mb-8">
|
||||
<h2 className="text-xl font-semibold">9. Limitation of Liability</h2>
|
||||
<section className="mb-8 space-y-4">
|
||||
<h2 className="font-semibold text-xl">9. Limitation of Liability</h2>
|
||||
<p className="text-foreground/90 leading-relaxed">
|
||||
TO THE MAXIMUM EXTENT PERMITTED BY LAW, PASCAL GROUP INC. SHALL NOT BE LIABLE
|
||||
FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES,
|
||||
INCLUDING LOSS OF DATA, PROFITS, OR GOODWILL, ARISING FROM YOUR USE OF THE
|
||||
PLATFORM.
|
||||
TO THE MAXIMUM EXTENT PERMITTED BY LAW, PASCAL GROUP INC. SHALL NOT BE LIABLE FOR ANY
|
||||
INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, INCLUDING LOSS OF
|
||||
DATA, PROFITS, OR GOODWILL, ARISING FROM YOUR USE OF THE PLATFORM.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 mb-8">
|
||||
<h2 className="text-xl font-semibold">10. Changes to Terms</h2>
|
||||
<section className="mb-8 space-y-4">
|
||||
<h2 className="font-semibold text-xl">10. Changes to Terms</h2>
|
||||
<p className="text-foreground/90 leading-relaxed">
|
||||
We may update these Terms from time to time. We will notify you of material
|
||||
changes by posting the updated Terms on the Platform. Your continued use of the
|
||||
Platform after changes are posted constitutes your acceptance of the revised Terms.
|
||||
We may update these Terms from time to time. We will notify you of material changes by
|
||||
posting the updated Terms on the Platform. Your continued use of the Platform after
|
||||
changes are posted constitutes your acceptance of the revised Terms.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">11. Contact Us</h2>
|
||||
<h2 className="font-semibold text-xl">11. Contact Us</h2>
|
||||
<p className="text-foreground/90 leading-relaxed">
|
||||
If you have questions about these Terms, please contact us at{' '}
|
||||
<a
|
||||
href="mailto:support@pascal.app"
|
||||
className="text-foreground underline hover:text-foreground/80"
|
||||
href="mailto:support@pascal.app"
|
||||
>
|
||||
support@pascal.app
|
||||
</a>
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getPublicProfile } from '@/features/community/lib/auth/actions'
|
||||
import { getPublicProjectsByUserId } from '@/features/community/lib/projects/actions'
|
||||
import { PublicProfilePage } from '@/features/community/components/public-profile-page'
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ username: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { username } = await params
|
||||
|
||||
return {
|
||||
title: `${username}'s Projects`,
|
||||
description: `Public projects shared by @${username} on Pascal Editor.`,
|
||||
alternates: {
|
||||
canonical: `/u/${encodeURIComponent(username)}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ProfilePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ username: string }>
|
||||
}) {
|
||||
const { username } = await params
|
||||
const profileResult = await getPublicProfile(username)
|
||||
|
||||
if (!profileResult.success || !profileResult.data) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const projectsResult = await getPublicProjectsByUserId(profileResult.data.id)
|
||||
|
||||
return (
|
||||
<PublicProfilePage
|
||||
profile={profileResult.data}
|
||||
projects={projectsResult.data ?? []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type CollectionId,
|
||||
type Control,
|
||||
type ControlValue,
|
||||
type ItemNode,
|
||||
useInteractive,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// ─── Shared control derivation ───────────────────────────────────────────────
|
||||
|
||||
type ItemControlRef = { itemId: AnyNodeId; controlIndex: number }
|
||||
|
||||
type SharedControlDef = {
|
||||
kind: 'toggle' | 'slider' | 'temperature'
|
||||
label?: string
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
unit?: string
|
||||
refs: ItemControlRef[]
|
||||
}
|
||||
|
||||
function deriveSharedControls(items: ItemNode[]): SharedControlDef[] {
|
||||
if (items.length === 0) return []
|
||||
const result: SharedControlDef[] = []
|
||||
|
||||
for (const kind of ['toggle', 'slider', 'temperature'] as const) {
|
||||
const refs: ItemControlRef[] = []
|
||||
let ref: Control | null = null
|
||||
let allHave = true
|
||||
|
||||
for (const item of items) {
|
||||
const idx = item.asset.interactive!.controls.findIndex((c) => c.kind === kind)
|
||||
if (idx === -1) { allHave = false; break }
|
||||
refs.push({ itemId: item.id, controlIndex: idx })
|
||||
if (!ref) ref = item.asset.interactive!.controls[idx]!
|
||||
}
|
||||
|
||||
if (!allHave || !ref) continue
|
||||
|
||||
const def: SharedControlDef = { kind, label: ref.label, refs }
|
||||
if ('min' in ref) { def.min = ref.min; def.max = ref.max }
|
||||
if ('step' in ref) def.step = (ref as { step?: number }).step
|
||||
if ('unit' in ref) def.unit = (ref as { unit?: string }).unit
|
||||
result.push(def)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Shared control widget ───────────────────────────────────────────────────
|
||||
|
||||
function SharedWidget({ def, value, onChange }: { def: SharedControlDef; value: ControlValue; onChange: (v: ControlValue) => void }) {
|
||||
if (def.kind === 'toggle') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(!value)}
|
||||
className={cn(
|
||||
'flex h-7 w-full items-center justify-center rounded-md px-3 text-xs font-medium transition-colors',
|
||||
value ? 'bg-green-500/20 text-green-400' : 'bg-white/10 text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{def.label ?? (value ? 'On' : 'Off')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
|
||||
<span>{def.label ?? def.kind}</span>
|
||||
<span>{value}{def.kind === 'temperature' ? '°' : ''}{def.unit ? ` ${def.unit}` : ''}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={def.min}
|
||||
max={def.max}
|
||||
step={def.step ?? 1}
|
||||
value={value as number}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
className="w-full accent-white"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Individual item control widget ──────────────────────────────────────────
|
||||
|
||||
function ItemWidget({ control, value, onChange }: { control: Control; value: ControlValue; onChange: (v: ControlValue) => void }) {
|
||||
if (control.kind === 'toggle') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(!value)}
|
||||
className={cn(
|
||||
'flex h-6 w-full items-center justify-center rounded px-2 text-[10px] font-medium transition-colors',
|
||||
value ? 'bg-green-500/20 text-green-400' : 'bg-white/5 text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{control.label ?? (value ? 'On' : 'Off')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
|
||||
<span>{control.label ?? control.kind}</span>
|
||||
<span>{value}{control.kind === 'temperature' ? '°' : ''}{'unit' in control && control.unit ? ` ${control.unit}` : ''}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={'min' in control ? control.min : 0}
|
||||
max={'max' in control ? control.max : 100}
|
||||
step={'step' in control ? (control as { step?: number }).step ?? 1 : 1}
|
||||
value={value as number}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
className="w-full accent-white"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Collection row ───────────────────────────────────────────────────────────
|
||||
|
||||
function CollectionRow({ collectionId }: { collectionId: CollectionId }) {
|
||||
const collection = useScene((s) => s.collections[collectionId])
|
||||
|
||||
const interactiveItems = useScene(
|
||||
useShallow((s) =>
|
||||
(collection?.nodeIds ?? [])
|
||||
.map((id) => s.nodes[id])
|
||||
.filter((n): n is ItemNode => n?.type === 'item' && !!n.asset.interactive)
|
||||
),
|
||||
)
|
||||
|
||||
const allItems = useInteractive((s) => s.items)
|
||||
const controlValuesByItem = useMemo(
|
||||
() => Object.fromEntries(interactiveItems.map((n) => [n.id, allItems[n.id]?.controlValues ?? []])),
|
||||
[allItems, interactiveItems],
|
||||
)
|
||||
|
||||
const setControlValue = useInteractive((s) => s.setControlValue)
|
||||
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [expandedItemIds, setExpandedItemIds] = useState<Set<AnyNodeId>>(new Set())
|
||||
|
||||
if (!collection) return null
|
||||
|
||||
const sharedControls = deriveSharedControls(interactiveItems)
|
||||
|
||||
const getSharedValue = (def: SharedControlDef): ControlValue => {
|
||||
if (def.kind === 'toggle') {
|
||||
return def.refs.every(({ itemId, controlIndex }) => Boolean(controlValuesByItem[itemId]?.[controlIndex]))
|
||||
}
|
||||
const first = def.refs[0]!
|
||||
return controlValuesByItem[first.itemId]?.[first.controlIndex] ?? 0
|
||||
}
|
||||
|
||||
const setSharedValue = (def: SharedControlDef, value: ControlValue) => {
|
||||
for (const { itemId, controlIndex } of def.refs) {
|
||||
setControlValue(itemId, controlIndex, value)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleItemExpand = (id: AnyNodeId) => {
|
||||
setExpandedItemIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2.5 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-sm border border-white/20"
|
||||
style={{ backgroundColor: collection.color ?? '#6366f1' }}
|
||||
/>
|
||||
<span className="flex-1 min-w-0 text-xs font-medium text-foreground truncate text-left">
|
||||
{collection.name}
|
||||
</span>
|
||||
{interactiveItems.length > 0 && (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
{interactiveItems.length}
|
||||
</span>
|
||||
)}
|
||||
{expanded
|
||||
? <ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
: <ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />}
|
||||
</button>
|
||||
|
||||
{/* Expanded */}
|
||||
{expanded && (
|
||||
<div className="pb-1">
|
||||
{interactiveItems.length === 0 ? (
|
||||
<p className="px-3 pb-2 text-[11px] text-muted-foreground">No interactive items.</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Shared controls */}
|
||||
{sharedControls.length > 0 && (
|
||||
<div className="px-3 pt-0.5 pb-2.5 border-b border-border/30">
|
||||
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mb-2">All</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{sharedControls.map((def, i) => (
|
||||
<SharedWidget
|
||||
key={i}
|
||||
def={def}
|
||||
value={getSharedValue(def)}
|
||||
onChange={(v) => setSharedValue(def, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Individual items */}
|
||||
{interactiveItems.map((item) => {
|
||||
const isItemExpanded = expandedItemIds.has(item.id)
|
||||
const controls = item.asset.interactive!.controls
|
||||
const values = controlValuesByItem[item.id] ?? []
|
||||
|
||||
return (
|
||||
<div key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleItemExpand(item.id)}
|
||||
className="flex w-full items-center gap-1.5 px-3 py-1.5 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
{isItemExpanded
|
||||
? <ChevronDown className="h-2.5 w-2.5 shrink-0 text-muted-foreground/50" />
|
||||
: <ChevronRight className="h-2.5 w-2.5 shrink-0 text-muted-foreground/50" />}
|
||||
<span className="flex-1 min-w-0 text-[11px] text-muted-foreground truncate text-left">
|
||||
{item.name || item.asset.name}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isItemExpanded && (
|
||||
<div className="px-3 pb-2 flex flex-col gap-1.5">
|
||||
{controls.map((control, i) => (
|
||||
<ItemWidget
|
||||
key={i}
|
||||
control={control}
|
||||
value={values[i] ?? false}
|
||||
onChange={(v) => setControlValue(item.id, i, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main panel ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function CollectionsPanel() {
|
||||
const collectionIds = useScene(
|
||||
useShallow((s) => Object.keys(s.collections) as CollectionId[]),
|
||||
)
|
||||
|
||||
if (collectionIds.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto flex flex-col rounded-2xl border border-border/40 bg-background/95 shadow-lg backdrop-blur-xl overflow-hidden w-56">
|
||||
<div className="px-3 py-2 border-b border-border/40 shrink-0">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Collections</span>
|
||||
</div>
|
||||
<div className="overflow-y-auto max-h-[70vh] no-scrollbar divide-y divide-border/30">
|
||||
{collectionIds.map((id) => (
|
||||
<CollectionRow key={id} collectionId={id} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export default function ViewerRouteError({
|
||||
error,
|
||||
reset,
|
||||
}: Readonly<{
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}>) {
|
||||
useEffect(() => {
|
||||
console.error('[viewer-route] Unhandled viewer error:', error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen w-full items-center justify-center bg-background p-4 text-foreground">
|
||||
<div className="w-full max-w-md rounded-2xl border border-border/60 bg-background p-6 shadow-xl">
|
||||
<h1 className="text-lg font-semibold">Viewer error</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
We couldn't load this project view. You can retry without leaving the app.
|
||||
</p>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
className="rounded-md border border-border bg-accent px-3 py-2 text-sm font-medium hover:bg-accent/80"
|
||||
onClick={reset}
|
||||
type="button"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
<Link
|
||||
className="rounded-md border border-border bg-background px-3 py-2 text-sm font-medium hover:bg-accent/40"
|
||||
href="/"
|
||||
>
|
||||
Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Project Viewer',
|
||||
description: 'View and share 3D projects built with Pascal Editor.',
|
||||
}
|
||||
|
||||
export default function ViewerLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return children
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { initSpatialGridSync, useScene } from '@pascal-app/core'
|
||||
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ErrorBoundary } from '@/components/ui/primitives/error-boundary'
|
||||
import { SceneLoader } from '@/components/ui/scene-loader'
|
||||
import {
|
||||
getProjectModelPublic,
|
||||
incrementProjectViews,
|
||||
} from '@/features/community/lib/projects/actions'
|
||||
import type { ProjectOwner } from '@/features/community/lib/projects/types'
|
||||
import { ViewerCameraControls } from './viewer-camera-controls'
|
||||
import { ViewerGuestCTA } from './viewer-guest-cta'
|
||||
import { ViewerOverlay } from './viewer-overlay'
|
||||
import { ViewerZoneSystem } from './viewer-zone-system'
|
||||
|
||||
function ViewerSceneCrashFallback({ projectName }: { projectName?: string | null }) {
|
||||
return (
|
||||
<div className="absolute inset-0 z-30 flex items-center justify-center bg-background/95 p-4 text-foreground">
|
||||
<div className="w-full max-w-md rounded-2xl border border-border/60 bg-background p-6 shadow-xl">
|
||||
<h2 className="text-lg font-semibold">The 3D scene failed to render</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{projectName ? `"${projectName}" ` : ''}
|
||||
hit a rendering error. The rest of the app is still available.
|
||||
</p>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
className="rounded-md border border-border bg-accent px-3 py-2 text-sm font-medium hover:bg-accent/80"
|
||||
onClick={() => window.location.reload()}
|
||||
type="button"
|
||||
>
|
||||
Reload scene
|
||||
</button>
|
||||
<Link
|
||||
className="rounded-md border border-border bg-background px-3 py-2 text-sm font-medium hover:bg-accent/40"
|
||||
href="/"
|
||||
>
|
||||
Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ViewerPage() {
|
||||
const params = useParams()
|
||||
const id = params.id as string
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [projectId, setProjectId] = useState<string | null>(null)
|
||||
const [projectName, setProjectName] = useState<string | null>(null)
|
||||
const [owner, setOwner] = useState<ProjectOwner | null>(null)
|
||||
const [canShowScans, setCanShowScans] = useState(true)
|
||||
const [canShowGuides, setCanShowGuides] = useState(true)
|
||||
const setScene = useScene((state) => state.setScene)
|
||||
|
||||
useEffect(() => {
|
||||
useViewer.getState().setProjectId(projectId)
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setProjectId(null)
|
||||
setProjectName(null)
|
||||
setOwner(null)
|
||||
setCanShowScans(true)
|
||||
setCanShowGuides(true)
|
||||
useViewer.getState().setShowScans(true)
|
||||
useViewer.getState().setShowGuides(true)
|
||||
|
||||
const loadContent = async () => {
|
||||
try {
|
||||
// Check if it's a demo file (starts with 'demo_')
|
||||
if (id.startsWith('demo_')) {
|
||||
const response = await fetch(`/demos/${id}.json`)
|
||||
if (cancelled) return
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Demo "${id}" not found`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (cancelled) return
|
||||
|
||||
if (data.nodes && data.rootNodeIds) {
|
||||
setScene(data.nodes, data.rootNodeIds)
|
||||
initSpatialGridSync()
|
||||
}
|
||||
|
||||
setProjectName('Demo')
|
||||
} else {
|
||||
// Load from database (public project)
|
||||
const result = await getProjectModelPublic(id)
|
||||
if (cancelled) return
|
||||
|
||||
if (result.success && result.data) {
|
||||
const { project, model, isOwner } = result.data
|
||||
const projectData = project as any
|
||||
|
||||
setProjectId(project.id)
|
||||
setProjectName(project.name)
|
||||
setOwner(projectData.owner ?? null)
|
||||
|
||||
// Apply public visibility settings for scans/guides (only for non-owners)
|
||||
if (!isOwner) {
|
||||
const scansAllowed = projectData.show_scans_public !== false
|
||||
const guidesAllowed = projectData.show_guides_public !== false
|
||||
setCanShowScans(scansAllowed)
|
||||
setCanShowGuides(guidesAllowed)
|
||||
|
||||
if (!scansAllowed) {
|
||||
useViewer.getState().setShowScans(false)
|
||||
}
|
||||
if (!guidesAllowed) {
|
||||
useViewer.getState().setShowGuides(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (model?.scene_graph) {
|
||||
const { nodes, rootNodeIds } = model.scene_graph
|
||||
setScene(nodes, rootNodeIds)
|
||||
initSpatialGridSync()
|
||||
}
|
||||
|
||||
// Increment view count
|
||||
await incrementProjectViews(id)
|
||||
if (cancelled) return
|
||||
} else {
|
||||
throw new Error(result.error || 'Project not found')
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load content')
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadContent()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [id, setScene])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center bg-neutral-100">
|
||||
<p className="text-destructive">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-screen w-full">
|
||||
{loading && <SceneLoader fullScreen />}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
<ViewerOverlay
|
||||
projectName={projectName}
|
||||
owner={owner}
|
||||
canShowScans={canShowScans}
|
||||
canShowGuides={canShowGuides}
|
||||
/>
|
||||
<ViewerGuestCTA />
|
||||
|
||||
<ErrorBoundary key={id} fallback={<ViewerSceneCrashFallback projectName={projectName} />}>
|
||||
<Viewer>
|
||||
<ViewerCameraControls />
|
||||
<ViewerZoneSystem />
|
||||
<InteractiveSystem />
|
||||
</Viewer>
|
||||
</ErrorBoundary>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { emitter, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { Box3, Vector3 } from 'three'
|
||||
|
||||
const tempBox = new Box3()
|
||||
const tempCenter = new Vector3()
|
||||
const tempSize = new Vector3()
|
||||
|
||||
export const ViewerCameraControls = () => {
|
||||
const controls = useRef<CameraControlsImpl>(null!)
|
||||
const selection = useViewer((s) => s.selection)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const cameraMode = useViewer((s) => s.cameraMode)
|
||||
const firstLoad = useRef(true)
|
||||
|
||||
// Get the deepest selected node ID (excluding selectedIds)
|
||||
const targetNodeId = selection.zoneId ?? selection.levelId ?? selection.buildingId
|
||||
|
||||
// Configure mouse buttons - same as editor
|
||||
const mouseButtons = useMemo(() => {
|
||||
const wheelAction =
|
||||
cameraMode === 'orthographic'
|
||||
? CameraControlsImpl.ACTION.ZOOM
|
||||
: CameraControlsImpl.ACTION.DOLLY
|
||||
|
||||
return {
|
||||
left: CameraControlsImpl.ACTION.SCREEN_PAN,
|
||||
middle: CameraControlsImpl.ACTION.SCREEN_PAN,
|
||||
right: CameraControlsImpl.ACTION.ROTATE,
|
||||
wheel: wheelAction,
|
||||
}
|
||||
}, [cameraMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!controls.current) return
|
||||
|
||||
// On first load, set a default camera position
|
||||
if (firstLoad.current) {
|
||||
firstLoad.current = false
|
||||
controls.current.setLookAt(30, 30, 30, 0, 0, 0, false)
|
||||
}
|
||||
|
||||
|
||||
let node = targetNodeId ? nodes[targetNodeId] : null;
|
||||
if (!targetNodeId) {
|
||||
const site = Object.values(nodes).find((n) => n.type === 'site')
|
||||
node = site || null
|
||||
}
|
||||
if (!node) return
|
||||
|
||||
// Check if node has a saved camera
|
||||
if (node.camera) {
|
||||
|
||||
const { position, target } = node.camera
|
||||
requestAnimationFrame(() => {
|
||||
controls.current.setLookAt(
|
||||
position[0],
|
||||
position[1],
|
||||
position[2],
|
||||
target[0],
|
||||
target[1],
|
||||
target[2],
|
||||
true,
|
||||
)
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!targetNodeId) {
|
||||
// No selection and no site - do nothing
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate camera position based on the node's 3D object
|
||||
const object3D = sceneRegistry.nodes.get(targetNodeId)
|
||||
if (!object3D) return
|
||||
|
||||
// Compute bounding box
|
||||
tempBox.setFromObject(object3D)
|
||||
tempBox.getCenter(tempCenter)
|
||||
tempBox.getSize(tempSize)
|
||||
|
||||
// Calculate a good viewing distance based on the object size
|
||||
const maxDim = Math.max(tempSize.x, tempSize.y, tempSize.z)
|
||||
const distance = Math.max(maxDim * 2, 15)
|
||||
|
||||
// Position camera at an angle looking at the center
|
||||
const cameraPos = new Vector3(
|
||||
tempCenter.x + distance * 0.7,
|
||||
tempCenter.y + distance * 0.5,
|
||||
tempCenter.z + distance * 0.7,
|
||||
)
|
||||
|
||||
controls.current.setLookAt(
|
||||
cameraPos.x,
|
||||
cameraPos.y,
|
||||
cameraPos.z,
|
||||
tempCenter.x,
|
||||
tempCenter.y,
|
||||
tempCenter.z,
|
||||
true,
|
||||
)
|
||||
}, [targetNodeId, nodes])
|
||||
|
||||
useEffect(() => {
|
||||
const handleTopView = () => {
|
||||
if (!controls.current) return
|
||||
|
||||
const currentPolarAngle = controls.current.polarAngle
|
||||
|
||||
// Toggle: if already near top view (< 0.1 radians ≈ 5.7°), go back to 45°
|
||||
// Otherwise, go to top view (0°)
|
||||
const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0
|
||||
|
||||
controls.current.rotatePolarTo(targetAngle, true)
|
||||
}
|
||||
|
||||
const handleOrbitCW = () => {
|
||||
if (!controls.current) return
|
||||
|
||||
const currentAzimuth = controls.current.azimuthAngle
|
||||
const currentPolar = controls.current.polarAngle
|
||||
// Round to nearest 90° increment, then rotate 90° clockwise
|
||||
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
|
||||
const target = rounded - Math.PI / 2
|
||||
|
||||
controls.current.rotateTo(target, currentPolar, true)
|
||||
}
|
||||
|
||||
const handleOrbitCCW = () => {
|
||||
if (!controls.current) return
|
||||
|
||||
const currentAzimuth = controls.current.azimuthAngle
|
||||
const currentPolar = controls.current.polarAngle
|
||||
// Round to nearest 90° increment, then rotate 90° counter-clockwise
|
||||
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
|
||||
const target = rounded + Math.PI / 2
|
||||
|
||||
controls.current.rotateTo(target, currentPolar, true)
|
||||
}
|
||||
|
||||
emitter.on('camera-controls:top-view', handleTopView)
|
||||
emitter.on('camera-controls:orbit-cw', handleOrbitCW)
|
||||
emitter.on('camera-controls:orbit-ccw', handleOrbitCCW)
|
||||
|
||||
return () => {
|
||||
emitter.off('camera-controls:top-view', handleTopView)
|
||||
emitter.off('camera-controls:orbit-cw', handleOrbitCW)
|
||||
emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onTransitionStart = useCallback(() => {
|
||||
useViewer.getState().setCameraDragging(true)
|
||||
}, [])
|
||||
|
||||
const onRest = useCallback(() => {
|
||||
useViewer.getState().setCameraDragging(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<CameraControls
|
||||
ref={controls}
|
||||
maxDistance={100}
|
||||
minDistance={5}
|
||||
maxPolarAngle={Math.PI / 2 - 0.1}
|
||||
minPolarAngle={0}
|
||||
mouseButtons={mouseButtons}
|
||||
onTransitionStart={onTransitionStart}
|
||||
onRest={onRest}
|
||||
restThreshold={0.01}
|
||||
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '@/features/community/lib/auth/hooks'
|
||||
import { SignInDialog } from '@/features/community/components/sign-in-dialog'
|
||||
|
||||
export function ViewerGuestCTA() {
|
||||
const { isAuthenticated, isLoading } = useAuth()
|
||||
const [showSignIn, setShowSignIn] = useState(false)
|
||||
|
||||
if (isLoading || isAuthenticated) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="absolute top-4 right-4 z-20 dark text-foreground">
|
||||
<div className="pointer-events-auto bg-background/95 backdrop-blur-xl border border-border/40 rounded-2xl px-6 py-3 shadow-lg transition-colors duration-200 ease-out flex flex-col sm:flex-row items-center gap-4">
|
||||
<p className="text-sm font-medium text-foreground text-center">Want to create your own 3D project?</p>
|
||||
<button
|
||||
onClick={() => setShowSignIn(true)}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors whitespace-nowrap w-full sm:w-auto"
|
||||
>
|
||||
Get Started
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<SignInDialog open={showSignIn} onOpenChange={setShowSignIn} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core'
|
||||
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
|
||||
import { useEffect } from 'react'
|
||||
import { useProjectScene } from '@/features/community/lib/models/hooks'
|
||||
import { useProjectStore } from '@/features/community/lib/projects/store'
|
||||
import { useKeyboard } from '@/hooks/use-keyboard'
|
||||
import { initSFXBus } from '@/lib/sfx-bus'
|
||||
import useEditor from '@/store/use-editor'
|
||||
import { ViewerOverlay } from '@/app/viewer/[id]/viewer-overlay'
|
||||
import { ViewerZoneSystem } from '@/app/viewer/[id]/viewer-zone-system'
|
||||
import { FeedbackDialog } from '../feedback-dialog'
|
||||
import { PascalRadio } from '../pascal-radio'
|
||||
import { PreviewButton } from '../preview-button'
|
||||
import { CeilingSystem } from '../systems/ceiling/ceiling-system'
|
||||
import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
|
||||
import { ZoneSystem } from '../systems/zone/zone-system'
|
||||
import { ToolManager } from '../tools/tool-manager'
|
||||
import { ActionMenu } from '../ui/action-menu'
|
||||
import { HelperManager } from '../ui/helpers/helper-manager'
|
||||
import { PanelManager } from '../ui/panels/panel-manager'
|
||||
import { ErrorBoundary } from '../ui/primitives/error-boundary'
|
||||
import { SidebarProvider } from '../ui/primitives/sidebar'
|
||||
import { SceneLoader } from '../ui/scene-loader'
|
||||
import { AppSidebar } from '../ui/sidebar/app-sidebar'
|
||||
import { CustomCameraControls } from './custom-camera-controls'
|
||||
import { ExportManager } from './export-manager'
|
||||
import { FloatingActionMenu } from './floating-action-menu'
|
||||
import { Grid } from './grid'
|
||||
import { PresetThumbnailGenerator } from './preset-thumbnail-generator'
|
||||
import { SelectionManager } from './selection-manager'
|
||||
import { SiteEdgeLabels } from './site-edge-labels'
|
||||
import { ThumbnailGenerator } from './thumbnail-generator'
|
||||
|
||||
// Load default scene initially (will be replaced when project loads)
|
||||
useScene.getState().loadScene()
|
||||
initSpatialGridSync()
|
||||
initSpaceDetectionSync(useScene, useEditor)
|
||||
|
||||
// Auto-select the first building and level for the default scene
|
||||
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')
|
||||
useEditor.getState().setStructureLayer('elements')
|
||||
|
||||
// Auto-select the wall tool if the level is empty
|
||||
if (!firstLevel.children || firstLevel.children.length === 0) {
|
||||
useEditor.getState().setMode('build')
|
||||
useEditor.getState().setTool('wall')
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize SFX bus to connect events to sound effects
|
||||
initSFXBus()
|
||||
|
||||
interface EditorProps {
|
||||
projectId?: string
|
||||
}
|
||||
|
||||
function EditorSceneCrashFallback() {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-background/95 p-4 text-foreground">
|
||||
<div className="w-full max-w-md rounded-2xl border border-border/60 bg-background p-6 shadow-xl">
|
||||
<h2 className="text-lg font-semibold">The editor scene failed to render</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
You can retry the scene or return home without reloading the whole app shell.
|
||||
</p>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
className="rounded-md border border-border bg-accent px-3 py-2 text-sm font-medium hover:bg-accent/80"
|
||||
onClick={() => window.location.reload()}
|
||||
type="button"
|
||||
>
|
||||
Reload editor
|
||||
</button>
|
||||
<a
|
||||
className="rounded-md border border-border bg-background px-3 py-2 text-sm font-medium hover:bg-accent/40"
|
||||
href="/"
|
||||
>
|
||||
Back to home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Editor({ projectId }: EditorProps) {
|
||||
useKeyboard()
|
||||
useProjectScene()
|
||||
|
||||
const isProjectLoading = useProjectStore((state) => state.isLoading)
|
||||
const isSceneLoading = useProjectStore((state) => state.isSceneLoading)
|
||||
const isLoading = isProjectLoading || isSceneLoading
|
||||
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
||||
const activeProject = useProjectStore((s) => s.activeProject)
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
useViewer.getState().setProjectId(projectId)
|
||||
} else {
|
||||
useViewer.getState().setProjectId(null)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => {
|
||||
document.body.classList.add('dark')
|
||||
return () => {
|
||||
document.body.classList.remove('dark')
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="w-full h-full dark text-foreground">
|
||||
{isLoading && <SceneLoader />}
|
||||
|
||||
{isPreviewMode ? (
|
||||
<ViewerOverlay
|
||||
projectName={activeProject?.name}
|
||||
onBack={() => useEditor.getState().setPreviewMode(false)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ActionMenu />
|
||||
<PanelManager />
|
||||
<HelperManager />
|
||||
|
||||
{/* Top-right controls */}
|
||||
<div className="pointer-events-none fixed top-4 right-4 z-50 flex items-start gap-2">
|
||||
<div className="pointer-events-auto">
|
||||
<PreviewButton />
|
||||
</div>
|
||||
<div className="pointer-events-auto">
|
||||
<PascalRadio />
|
||||
</div>
|
||||
<div className="pointer-events-auto">
|
||||
<FeedbackDialog projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SidebarProvider className="fixed z-20">
|
||||
<AppSidebar />
|
||||
</SidebarProvider>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ErrorBoundary key={projectId} fallback={<EditorSceneCrashFallback />}>
|
||||
<Viewer selectionManager={isPreviewMode ? 'default' : 'custom'}>
|
||||
{!isPreviewMode && <SelectionManager />}
|
||||
{!isPreviewMode && <FloatingActionMenu />}
|
||||
<ExportManager />
|
||||
{/* Swap zone systems: viewer drill-down vs editor layer toggle */}
|
||||
{isPreviewMode ? <ViewerZoneSystem /> : <ZoneSystem />}
|
||||
<CeilingSystem />
|
||||
{!isPreviewMode && (
|
||||
<Grid cellColor="#aaa" sectionColor="#ccc" fadeDistance={500} />
|
||||
)}
|
||||
{!isPreviewMode && <ToolManager />}
|
||||
<CustomCameraControls />
|
||||
<ThumbnailGenerator projectId={projectId} />
|
||||
<PresetThumbnailGenerator />
|
||||
{!isPreviewMode && <SiteEdgeLabels />}
|
||||
{isPreviewMode && <InteractiveSystem />}
|
||||
</Viewer>
|
||||
{!isPreviewMode && <ZoneLabelEditorSystem />}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,449 +0,0 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type BuildingNode,
|
||||
emitter,
|
||||
type ItemNode,
|
||||
type NodeEvent,
|
||||
resolveLevelId,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from "@pascal-app/core";
|
||||
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import { useEffect, useRef } from "react";
|
||||
import useEditor from "@/store/use-editor";
|
||||
|
||||
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
|
||||
const currentLevelId = useViewer.getState().selection.levelId;
|
||||
if (!currentLevelId) return true; // No level selected, allow all
|
||||
const nodeLevelId = resolveLevelId(node, useScene.getState().nodes);
|
||||
return nodeLevelId === currentLevelId;
|
||||
};
|
||||
|
||||
type SelectableNodeType = "wall" | "item" | "building" | "zone" | 'slab' | 'ceiling' | 'roof' | 'window' | 'door';
|
||||
|
||||
type ModifierKeys = {
|
||||
meta: boolean;
|
||||
ctrl: boolean;
|
||||
};
|
||||
|
||||
interface SelectionStrategy {
|
||||
types: SelectableNodeType[];
|
||||
handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void;
|
||||
handleDeselect: () => void;
|
||||
isValid: (node: AnyNode) => boolean;
|
||||
}
|
||||
|
||||
export const resolveBuildingId = (levelId: string, nodes: Record<string, AnyNode>): string | null => {
|
||||
const level = nodes[levelId];
|
||||
if (!level) return null;
|
||||
if (level.parentId && nodes[level.parentId]?.type === "building") {
|
||||
return level.parentId;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const computeNextIds = (
|
||||
node: AnyNode,
|
||||
selectedIds: string[],
|
||||
event?: any,
|
||||
modifierKeys?: ModifierKeys
|
||||
): string[] => {
|
||||
const isMeta = event?.metaKey || event?.nativeEvent?.metaKey || modifierKeys?.meta || false;
|
||||
const isCtrl = event?.ctrlKey || event?.nativeEvent?.ctrlKey || modifierKeys?.ctrl || false;
|
||||
|
||||
console.log("computeNextIds:", {
|
||||
nodeId: node.id,
|
||||
selectedIds,
|
||||
isMeta,
|
||||
isCtrl,
|
||||
eventMeta: event?.metaKey,
|
||||
nativeMeta: event?.nativeEvent?.metaKey,
|
||||
modMeta: modifierKeys?.meta
|
||||
});
|
||||
|
||||
if (isMeta || isCtrl) {
|
||||
if (selectedIds.includes(node.id)) {
|
||||
return selectedIds.filter((id) => id !== node.id);
|
||||
} else {
|
||||
return [...selectedIds, node.id];
|
||||
}
|
||||
}
|
||||
|
||||
// Not holding modifiers: select only this node
|
||||
return [node.id];
|
||||
};
|
||||
|
||||
const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
||||
site: {
|
||||
types: ["building"],
|
||||
handleSelect: (node) => {
|
||||
useViewer
|
||||
.getState()
|
||||
.setSelection({ buildingId: (node as BuildingNode).id });
|
||||
},
|
||||
handleDeselect: () => {
|
||||
useViewer.getState().setSelection({ buildingId: null });
|
||||
},
|
||||
isValid: (node) => node.type === "building",
|
||||
},
|
||||
|
||||
structure: {
|
||||
types: ["wall", "item", "zone", "slab", "ceiling", "roof", "window", "door"],
|
||||
handleSelect: (node, nativeEvent, modifierKeys) => {
|
||||
const { selection, setSelection } = useViewer.getState();
|
||||
const nodes = useScene.getState().nodes;
|
||||
const nodeLevelId = resolveLevelId(node, nodes);
|
||||
const buildingId = resolveBuildingId(nodeLevelId, nodes);
|
||||
|
||||
const updates: any = {};
|
||||
if (nodeLevelId !== "default" && nodeLevelId !== selection.levelId) {
|
||||
updates.levelId = nodeLevelId;
|
||||
}
|
||||
if (buildingId && buildingId !== selection.buildingId) {
|
||||
updates.buildingId = buildingId;
|
||||
}
|
||||
|
||||
if (node.type === 'zone') {
|
||||
updates.zoneId = node.id;
|
||||
// Don't reset selectedIds in structure phase for zone, but if we changed level, it might reset them via hierarchy guard.
|
||||
// Wait, the hierarchy guard resets zoneId if levelId changes. That's fine since we provide zoneId.
|
||||
setSelection(updates);
|
||||
} else {
|
||||
updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys);
|
||||
setSelection(updates);
|
||||
}
|
||||
},
|
||||
handleDeselect: () => {
|
||||
const structureLayer = useEditor.getState().structureLayer;
|
||||
if (structureLayer === "zones") {
|
||||
useViewer.getState().setSelection({ zoneId: null });
|
||||
} else {
|
||||
useViewer.getState().setSelection({ selectedIds: [] });
|
||||
}
|
||||
},
|
||||
isValid: (node) => {
|
||||
if (!isNodeInCurrentLevel(node)) return false;
|
||||
const structureLayer = useEditor.getState().structureLayer;
|
||||
if (structureLayer === "zones") {
|
||||
if (node.type === "zone") return true;
|
||||
return false;
|
||||
} else {
|
||||
if (node.type === "wall" || node.type === "slab" || node.type === "ceiling" || node.type === "roof") return true;
|
||||
if (node.type === "item") {
|
||||
return (
|
||||
(node as ItemNode).asset.category === "door" ||
|
||||
(node as ItemNode).asset.category === "window"
|
||||
);
|
||||
}
|
||||
if (node.type === "window" || node.type === "door") return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
furnish: {
|
||||
types: ["item"],
|
||||
handleSelect: (node, nativeEvent, modifierKeys) => {
|
||||
const { selection, setSelection } = useViewer.getState();
|
||||
const nodes = useScene.getState().nodes;
|
||||
const nodeLevelId = resolveLevelId(node, nodes);
|
||||
const buildingId = resolveBuildingId(nodeLevelId, nodes);
|
||||
|
||||
const updates: any = {};
|
||||
if (nodeLevelId !== "default" && nodeLevelId !== selection.levelId) {
|
||||
updates.levelId = nodeLevelId;
|
||||
}
|
||||
if (buildingId && buildingId !== selection.buildingId) {
|
||||
updates.buildingId = buildingId;
|
||||
}
|
||||
|
||||
updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys);
|
||||
setSelection(updates);
|
||||
},
|
||||
handleDeselect: () => {
|
||||
useViewer.getState().setSelection({ selectedIds: [] });
|
||||
},
|
||||
isValid: (node) => {
|
||||
if (!isNodeInCurrentLevel(node)) return false;
|
||||
if (node.type !== "item") return false;
|
||||
const item = node as ItemNode;
|
||||
return item.asset.category !== "door" && item.asset.category !== "window";
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SelectionManager = () => {
|
||||
const phase = useEditor((s) => s.phase);
|
||||
const mode = useEditor((s) => s.mode);
|
||||
const modifierKeysRef = useRef<ModifierKeys>({
|
||||
meta: false,
|
||||
ctrl: false,
|
||||
});
|
||||
const clickHandledRef = useRef(false);
|
||||
|
||||
const movingNode = useEditor((s) => s.movingNode);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Meta") modifierKeysRef.current.meta = true;
|
||||
if (event.key === "Control") modifierKeysRef.current.ctrl = true;
|
||||
};
|
||||
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === "Meta") modifierKeysRef.current.meta = false;
|
||||
if (event.key === "Control") modifierKeysRef.current.ctrl = false;
|
||||
};
|
||||
|
||||
const clearModifiers = () => {
|
||||
modifierKeysRef.current.meta = false;
|
||||
modifierKeysRef.current.ctrl = false;
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("keyup", onKeyUp);
|
||||
window.addEventListener("blur", clearModifiers);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("keyup", onKeyUp);
|
||||
window.removeEventListener("blur", clearModifiers);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "select") return;
|
||||
if (movingNode) return;
|
||||
|
||||
const onClick = (event: NodeEvent) => {
|
||||
const node = event.node;
|
||||
let currentPhase = useEditor.getState().phase;
|
||||
let targetPhase = currentPhase;
|
||||
|
||||
// Auto-switch between structure and furnish phases when clicking elements on the same level
|
||||
if (currentPhase === "structure" || currentPhase === "furnish") {
|
||||
if (isNodeInCurrentLevel(node)) {
|
||||
if (
|
||||
node.type === "wall" ||
|
||||
node.type === "slab" ||
|
||||
node.type === "ceiling" ||
|
||||
node.type === "roof" ||
|
||||
node.type === "window" ||
|
||||
node.type === "door"
|
||||
) {
|
||||
targetPhase = "structure";
|
||||
} else if (node.type === "item") {
|
||||
const item = node as ItemNode;
|
||||
if (item.asset.category === "door" || item.asset.category === "window") {
|
||||
targetPhase = "structure";
|
||||
} else {
|
||||
targetPhase = "furnish";
|
||||
}
|
||||
}
|
||||
|
||||
if (targetPhase !== currentPhase) {
|
||||
useEditor.getState().setPhase(targetPhase);
|
||||
if (targetPhase === "structure" && useEditor.getState().structureLayer === "zones") {
|
||||
useEditor.getState().setStructureLayer("elements");
|
||||
}
|
||||
currentPhase = targetPhase;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const activeStrategy = SELECTION_STRATEGIES[currentPhase];
|
||||
if (activeStrategy?.isValid(node)) {
|
||||
event.stopPropagation();
|
||||
clickHandledRef.current = true;
|
||||
|
||||
console.log("[SelectionManager] Valid click on:", node.type, node.id, "Shift:", event.nativeEvent.shiftKey);
|
||||
activeStrategy.handleSelect(node, event.nativeEvent, modifierKeysRef.current);
|
||||
|
||||
// Reset the handled flag after a short delay to allow grid:click to be ignored
|
||||
setTimeout(() => {
|
||||
clickHandledRef.current = false;
|
||||
}, 50);
|
||||
}
|
||||
};
|
||||
|
||||
const allTypes = ["wall", "item", "building", "zone", "slab", "ceiling", "roof", "window", "door"];
|
||||
allTypes.forEach((type) => {
|
||||
emitter.on(`${type}:click` as any, onClick as any);
|
||||
});
|
||||
|
||||
const onGridClick = () => {
|
||||
if (clickHandledRef.current) return;
|
||||
console.log("onGridClick triggered! Deselecting.");
|
||||
const activeStrategy = SELECTION_STRATEGIES[useEditor.getState().phase];
|
||||
if (activeStrategy) activeStrategy.handleDeselect();
|
||||
};
|
||||
emitter.on("grid:click", onGridClick);
|
||||
|
||||
return () => {
|
||||
allTypes.forEach((type) => {
|
||||
emitter.off(`${type}:click` as any, onClick as any);
|
||||
});
|
||||
emitter.off("grid:click", onGridClick);
|
||||
};
|
||||
}, [mode, movingNode]);
|
||||
|
||||
// Global double-click handler for auto-switching phases and cross-phase hover
|
||||
useEffect(() => {
|
||||
if (mode !== "select") return;
|
||||
if (movingNode) return;
|
||||
|
||||
const onEnter = (event: NodeEvent) => {
|
||||
const node = event.node;
|
||||
const currentPhase = useEditor.getState().phase;
|
||||
|
||||
// Ignore site/building if we are already inside a building
|
||||
if (node.type === "building" || node.type === "site") {
|
||||
if (currentPhase === "structure" || currentPhase === "furnish") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore zones unless specifically in zones layer
|
||||
if (node.type === "zone") {
|
||||
if (currentPhase !== "structure" || useEditor.getState().structureLayer !== "zones") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check level constraint for interior nodes
|
||||
if (currentPhase === "structure" || currentPhase === "furnish") {
|
||||
if (!isNodeInCurrentLevel(node)) return;
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
useViewer.setState({ hoveredId: node.id });
|
||||
};
|
||||
|
||||
const onLeave = (event: NodeEvent) => {
|
||||
if (useViewer.getState().hoveredId === event.node.id) {
|
||||
useViewer.setState({ hoveredId: null });
|
||||
}
|
||||
};
|
||||
|
||||
const onDoubleClick = (event: NodeEvent) => {
|
||||
const node = event.node;
|
||||
const currentPhase = useEditor.getState().phase;
|
||||
|
||||
let targetPhase: "site" | "structure" | "furnish" | null = null;
|
||||
|
||||
if (node.type === "building" || node.type === "site") {
|
||||
if (currentPhase === "structure" || currentPhase === "furnish") {
|
||||
return; // Ignore building/site double clicks if we are already inside a building
|
||||
}
|
||||
if (node.type === "building") {
|
||||
targetPhase = "structure";
|
||||
}
|
||||
} else if (
|
||||
node.type === "wall" ||
|
||||
node.type === "slab" ||
|
||||
node.type === "ceiling" ||
|
||||
node.type === "roof" ||
|
||||
node.type === "window" ||
|
||||
node.type === "door"
|
||||
) {
|
||||
targetPhase = "structure";
|
||||
} else if (node.type === "item") {
|
||||
const item = node as ItemNode;
|
||||
if (item.asset.category === "door" || item.asset.category === "window") {
|
||||
targetPhase = "structure";
|
||||
} else {
|
||||
targetPhase = "furnish";
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === "zone") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetPhase && targetPhase !== useEditor.getState().phase) {
|
||||
event.stopPropagation();
|
||||
|
||||
useEditor.getState().setPhase(targetPhase);
|
||||
|
||||
if (targetPhase === "structure" && useEditor.getState().structureLayer === "zones") {
|
||||
useEditor.getState().setStructureLayer("elements");
|
||||
}
|
||||
|
||||
const strategy = SELECTION_STRATEGIES[targetPhase];
|
||||
if (strategy) {
|
||||
strategy.handleSelect(node, event.nativeEvent, modifierKeysRef.current);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const allTypes = ["wall", "item", "building", "slab", "ceiling", "roof", "window", "door", "zone", "site"];
|
||||
allTypes.forEach((type) => {
|
||||
emitter.on(`${type}:enter` as any, onEnter as any);
|
||||
emitter.on(`${type}:leave` as any, onLeave as any);
|
||||
emitter.on(`${type}:double-click` as any, onDoubleClick as any);
|
||||
});
|
||||
|
||||
return () => {
|
||||
allTypes.forEach((type) => {
|
||||
emitter.off(`${type}:enter` as any, onEnter as any);
|
||||
emitter.off(`${type}:leave` as any, onLeave as any);
|
||||
emitter.off(`${type}:double-click` as any, onDoubleClick as any);
|
||||
});
|
||||
};
|
||||
}, [mode, movingNode]);
|
||||
|
||||
return <EditorOutlinerSync />;
|
||||
};
|
||||
|
||||
const EditorOutlinerSync = () => {
|
||||
const phase = useEditor((s) => s.phase);
|
||||
const selection = useViewer((s) => s.selection);
|
||||
const hoveredId = useViewer((s) => s.hoveredId);
|
||||
const outliner = useViewer((s) => s.outliner);
|
||||
|
||||
useEffect(() => {
|
||||
let idsToHighlight: string[] = [];
|
||||
|
||||
// 1. Determine what should be highlighted based on Phase
|
||||
switch (phase) {
|
||||
case "site":
|
||||
// Only highlight the building if one is selected
|
||||
if (selection.buildingId) idsToHighlight = [selection.buildingId];
|
||||
break;
|
||||
|
||||
case "structure":
|
||||
// Highlight selected items (walls/slabs)
|
||||
// We IGNORE buildingId even if it's set in the store
|
||||
idsToHighlight = selection.selectedIds;
|
||||
break;
|
||||
|
||||
case "furnish":
|
||||
// Highlight selected furniture/items
|
||||
idsToHighlight = selection.selectedIds;
|
||||
break;
|
||||
|
||||
default:
|
||||
// Pure Viewer mode: Highlight based on the "deepest" selection
|
||||
if (selection.selectedIds.length > 0)
|
||||
idsToHighlight = selection.selectedIds;
|
||||
else if (selection.levelId) idsToHighlight = [selection.levelId];
|
||||
else if (selection.buildingId) idsToHighlight = [selection.buildingId];
|
||||
}
|
||||
|
||||
// 2. Sync with the imperative outliner arrays (mutate in place to keep references)
|
||||
outliner.selectedObjects.length = 0;
|
||||
for (const id of idsToHighlight) {
|
||||
const obj = sceneRegistry.nodes.get(id);
|
||||
if (obj) outliner.selectedObjects.push(obj);
|
||||
}
|
||||
|
||||
outliner.hoveredObjects.length = 0;
|
||||
if (hoveredId) {
|
||||
const obj = sceneRegistry.nodes.get(hoveredId);
|
||||
if (obj) outliner.hoveredObjects.push(obj);
|
||||
}
|
||||
}, [phase, selection, hoveredId, outliner]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Eye } from 'lucide-react'
|
||||
import useEditor from '@/store/use-editor'
|
||||
|
||||
export function PreviewButton() {
|
||||
return (
|
||||
<button
|
||||
onClick={() => useEditor.getState().setPreviewMode(true)}
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 shadow-lg backdrop-blur-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
<Eye className="h-4 w-4 shrink-0" />
|
||||
<span className="hidden sm:inline whitespace-nowrap">Preview</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
import { emitter, type GridEvent, useScene, ZoneNode, type LevelNode } from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { BufferGeometry, DoubleSide, type Line, type Group, Shape, Vector3 } from "three";
|
||||
import { EDITOR_LAYER } from "@/lib/constants";
|
||||
import useEditor from "@/store/use-editor";
|
||||
import { CursorSphere } from "../shared/cursor-sphere";
|
||||
import { PALETTE_COLORS } from "@/components/ui/primitives/color-dot";
|
||||
|
||||
const Y_OFFSET = 0.02;
|
||||
|
||||
/**
|
||||
* Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
|
||||
*/
|
||||
const calculateSnapPoint = (
|
||||
lastPoint: [number, number],
|
||||
currentPoint: [number, number]
|
||||
): [number, number] => {
|
||||
const [x1, y1] = lastPoint;
|
||||
const [x, y] = currentPoint;
|
||||
|
||||
const dx = x - x1;
|
||||
const dy = y - y1;
|
||||
const absDx = Math.abs(dx);
|
||||
const absDy = Math.abs(dy);
|
||||
|
||||
// Calculate distances to horizontal, vertical, and diagonal lines
|
||||
const horizontalDist = absDy;
|
||||
const verticalDist = absDx;
|
||||
const diagonalDist = Math.abs(absDx - absDy);
|
||||
|
||||
// Find the minimum distance to determine which axis to snap to
|
||||
const minDist = Math.min(horizontalDist, verticalDist, diagonalDist);
|
||||
|
||||
if (minDist === diagonalDist) {
|
||||
// Snap to 45° diagonal
|
||||
const diagonalLength = Math.min(absDx, absDy);
|
||||
return [
|
||||
x1 + Math.sign(dx) * diagonalLength,
|
||||
y1 + Math.sign(dy) * diagonalLength,
|
||||
];
|
||||
} else if (minDist === horizontalDist) {
|
||||
// Snap to horizontal
|
||||
return [x, y1];
|
||||
} else {
|
||||
// Snap to vertical
|
||||
return [x1, y];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a zone with the given polygon points
|
||||
*/
|
||||
const commitZoneDrawing = (
|
||||
levelId: LevelNode["id"],
|
||||
points: Array<[number, number]>
|
||||
) => {
|
||||
const { createNode, nodes } = useScene.getState();
|
||||
|
||||
// Count existing zones for naming and color cycling
|
||||
const zoneCount = Object.values(nodes).filter((n) => n.type === "zone").length;
|
||||
const name = `Zone ${zoneCount + 1}`;
|
||||
|
||||
// Cycle through colors
|
||||
const color = PALETTE_COLORS[zoneCount % PALETTE_COLORS.length];
|
||||
|
||||
const zone = ZoneNode.parse({
|
||||
name,
|
||||
polygon: points,
|
||||
color,
|
||||
});
|
||||
|
||||
createNode(zone, levelId);
|
||||
|
||||
// Select the newly created zone
|
||||
useViewer.getState().setSelection({ zoneId: zone.id });
|
||||
};
|
||||
|
||||
type PreviewState = {
|
||||
points: Array<[number, number]>;
|
||||
cursorPoint: [number, number] | null;
|
||||
levelY: number;
|
||||
};
|
||||
|
||||
// Helper to validate point values (no NaN or Infinity)
|
||||
const isValidPoint = (
|
||||
pt: [number, number] | null | undefined
|
||||
): pt is [number, number] => {
|
||||
if (!pt) return false;
|
||||
return Number.isFinite(pt[0]) && Number.isFinite(pt[1]);
|
||||
};
|
||||
|
||||
export const ZoneTool: React.FC = () => {
|
||||
const cursorRef = useRef<Group>(null);
|
||||
const mainLineRef = useRef<Line>(null!);
|
||||
const closingLineRef = useRef<Line>(null!);
|
||||
const pointsRef = useRef<Array<[number, number]>>([]);
|
||||
const levelYRef = useRef(0); // Track current level Y position
|
||||
const currentLevelId = useViewer((state) => state.selection.levelId);
|
||||
const setTool = useEditor((state) => state.setTool);
|
||||
|
||||
// Preview state for reactive rendering (for shape and point markers)
|
||||
const [preview, setPreview] = useState<PreviewState>({
|
||||
points: [],
|
||||
cursorPoint: null,
|
||||
levelY: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentLevelId) return;
|
||||
|
||||
let cursorPosition: [number, number] = [0, 0];
|
||||
|
||||
// Initialize line geometries
|
||||
mainLineRef.current.geometry = new BufferGeometry();
|
||||
closingLineRef.current.geometry = new BufferGeometry();
|
||||
|
||||
const updateLines = () => {
|
||||
const points = pointsRef.current;
|
||||
const y = levelYRef.current + Y_OFFSET;
|
||||
|
||||
if (points.length === 0) {
|
||||
mainLineRef.current.visible = false;
|
||||
closingLineRef.current.visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Build main line points
|
||||
const linePoints: Vector3[] = points.map(
|
||||
([x, z]) => new Vector3(x, y, z)
|
||||
);
|
||||
|
||||
// Add cursor point
|
||||
const lastPoint = points[points.length - 1];
|
||||
if (lastPoint) {
|
||||
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
|
||||
if (isValidPoint(snapped)) {
|
||||
linePoints.push(new Vector3(snapped[0], y, snapped[1]));
|
||||
}
|
||||
}
|
||||
|
||||
// Update main line geometry
|
||||
if (linePoints.length >= 2) {
|
||||
mainLineRef.current.geometry.dispose();
|
||||
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints);
|
||||
mainLineRef.current.visible = true;
|
||||
} else {
|
||||
mainLineRef.current.visible = false;
|
||||
}
|
||||
|
||||
// Update closing line (from cursor back to first point)
|
||||
const firstPoint = points[0];
|
||||
if (points.length >= 2 && lastPoint && isValidPoint(firstPoint)) {
|
||||
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
|
||||
if (isValidPoint(snapped)) {
|
||||
const closingPoints = [
|
||||
new Vector3(snapped[0], y, snapped[1]),
|
||||
new Vector3(firstPoint[0], y, firstPoint[1]),
|
||||
];
|
||||
closingLineRef.current.geometry.dispose();
|
||||
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints);
|
||||
closingLineRef.current.visible = true;
|
||||
}
|
||||
} else {
|
||||
closingLineRef.current.visible = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updatePreview = () => {
|
||||
const points = pointsRef.current;
|
||||
const lastPoint = points[points.length - 1];
|
||||
|
||||
let cursorPt: [number, number] | null = null;
|
||||
if (lastPoint) {
|
||||
cursorPt = calculateSnapPoint(lastPoint, cursorPosition);
|
||||
} else if (points.length === 0) {
|
||||
cursorPt = cursorPosition;
|
||||
}
|
||||
|
||||
setPreview({ points: [...points], cursorPoint: cursorPt, levelY: levelYRef.current });
|
||||
updateLines();
|
||||
};
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (!cursorRef.current) return;
|
||||
|
||||
// Snap to 0.5 grid
|
||||
const gridX = Math.round(event.position[0] * 2) / 2;
|
||||
const gridZ = Math.round(event.position[2] * 2) / 2;
|
||||
cursorPosition = [gridX, gridZ];
|
||||
levelYRef.current = event.position[1];
|
||||
|
||||
// If we have points, snap to axis from last point
|
||||
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
|
||||
if (lastPoint) {
|
||||
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
|
||||
cursorRef.current.position.set(snapped[0], event.position[1], snapped[1]);
|
||||
} else {
|
||||
cursorRef.current.position.set(gridX, event.position[1], gridZ);
|
||||
}
|
||||
|
||||
updatePreview();
|
||||
};
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (!currentLevelId) return;
|
||||
|
||||
const gridX = Math.round(event.position[0] * 2) / 2;
|
||||
const gridZ = Math.round(event.position[2] * 2) / 2;
|
||||
let clickPoint: [number, number] = [gridX, gridZ];
|
||||
|
||||
// Snap to axis from last point
|
||||
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
|
||||
if (lastPoint) {
|
||||
clickPoint = calculateSnapPoint(lastPoint, clickPoint);
|
||||
}
|
||||
|
||||
// Check if clicking on the first point to close the shape
|
||||
const firstPoint = pointsRef.current[0];
|
||||
if (
|
||||
pointsRef.current.length >= 3 &&
|
||||
firstPoint &&
|
||||
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
|
||||
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
|
||||
) {
|
||||
// Create the zone
|
||||
commitZoneDrawing(currentLevelId, pointsRef.current);
|
||||
|
||||
// Reset state
|
||||
pointsRef.current = [];
|
||||
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
|
||||
mainLineRef.current.visible = false;
|
||||
closingLineRef.current.visible = false;
|
||||
} else {
|
||||
// Add point to polygon
|
||||
pointsRef.current = [...pointsRef.current, clickPoint];
|
||||
updatePreview();
|
||||
}
|
||||
};
|
||||
|
||||
const onGridDoubleClick = (_event: GridEvent) => {
|
||||
if (!currentLevelId) return;
|
||||
|
||||
// Need at least 3 points to form a polygon
|
||||
if (pointsRef.current.length >= 3) {
|
||||
commitZoneDrawing(currentLevelId, pointsRef.current);
|
||||
|
||||
// Reset state
|
||||
pointsRef.current = [];
|
||||
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
|
||||
mainLineRef.current.visible = false;
|
||||
closingLineRef.current.visible = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Subscribe to events
|
||||
emitter.on("grid:move", onGridMove);
|
||||
emitter.on("grid:click", onGridClick);
|
||||
emitter.on("grid:double-click", onGridDoubleClick);
|
||||
|
||||
return () => {
|
||||
emitter.off("grid:move", onGridMove);
|
||||
emitter.off("grid:click", onGridClick);
|
||||
emitter.off("grid:double-click", onGridDoubleClick);
|
||||
|
||||
// Reset state on unmount
|
||||
pointsRef.current = [];
|
||||
};
|
||||
}, [currentLevelId, setTool]);
|
||||
|
||||
const { points, cursorPoint, levelY } = preview;
|
||||
|
||||
// Create preview shape when we have 3+ points
|
||||
const previewShape = useMemo(() => {
|
||||
if (points.length < 3) return null;
|
||||
|
||||
const allPoints = [...points];
|
||||
if (isValidPoint(cursorPoint)) {
|
||||
allPoints.push(cursorPoint);
|
||||
}
|
||||
|
||||
// THREE.Shape is in X-Y plane. After rotation of -PI/2 around X:
|
||||
// - Shape X -> World X
|
||||
// - Shape Y -> World -Z (so we negate Z to get correct orientation)
|
||||
const firstPt = allPoints[0];
|
||||
if (!isValidPoint(firstPt)) return null;
|
||||
|
||||
const shape = new Shape();
|
||||
shape.moveTo(firstPt[0], -firstPt[1]);
|
||||
|
||||
for (let i = 1; i < allPoints.length; i++) {
|
||||
const pt = allPoints[i];
|
||||
if (isValidPoint(pt)) {
|
||||
shape.lineTo(pt[0], -pt[1]);
|
||||
}
|
||||
}
|
||||
shape.closePath();
|
||||
|
||||
return shape;
|
||||
}, [points, cursorPoint]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Cursor */}
|
||||
<CursorSphere ref={cursorRef} />
|
||||
|
||||
{/* Preview fill */}
|
||||
{previewShape && (
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
position={[0, levelY + Y_OFFSET, 0]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
>
|
||||
<shapeGeometry args={[previewShape]} />
|
||||
<meshBasicMaterial
|
||||
color="#818cf8"
|
||||
depthTest={false}
|
||||
opacity={0.15}
|
||||
side={DoubleSide}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{/* Main line - uses native line element with TSL-compatible material */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial
|
||||
color="#818cf8"
|
||||
linewidth={3}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</line>
|
||||
|
||||
{/* Closing line - uses native line element with TSL-compatible material */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial
|
||||
color="#818cf8"
|
||||
linewidth={2}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={0.5}
|
||||
transparent
|
||||
/>
|
||||
</line>
|
||||
|
||||
{/* Point markers */}
|
||||
{points.map(([x, z], index) =>
|
||||
isValidPoint([x, z]) ? (
|
||||
<CursorSphere key={index} position={[x, levelY + Y_OFFSET + 0.01, z]} color="#818cf8" showTooltip={false} height={0} />
|
||||
) : null
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
};
|
||||
@@ -1,136 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { ActionButton } from "./action-button";
|
||||
import { Pencil, Trash2, type LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import useEditor, { Mode, Phase } from "@/store/use-editor";
|
||||
|
||||
type ModeConfig = {
|
||||
id: Mode;
|
||||
icon?: LucideIcon;
|
||||
imageSrc?: string;
|
||||
label: string;
|
||||
shortcut: string;
|
||||
color: string;
|
||||
activeColor: string;
|
||||
};
|
||||
|
||||
// All available control modes
|
||||
const allModes: ModeConfig[] = [
|
||||
{
|
||||
id: "select",
|
||||
imageSrc: "/icons/select.png",
|
||||
label: "Select",
|
||||
shortcut: "V",
|
||||
color: "hover:bg-blue-500/20 hover:text-blue-400",
|
||||
activeColor: "bg-blue-500/20 text-blue-400",
|
||||
},
|
||||
{
|
||||
id: "edit",
|
||||
icon: Pencil,
|
||||
label: "Edit",
|
||||
shortcut: "E",
|
||||
color: "hover:bg-orange-500/20 hover:text-orange-400",
|
||||
activeColor: "bg-orange-500/20 text-orange-400",
|
||||
},
|
||||
{
|
||||
id: "build",
|
||||
imageSrc: "/icons/build.png",
|
||||
label: "Build",
|
||||
shortcut: "B",
|
||||
color: "hover:bg-green-500/20 hover:text-green-400",
|
||||
activeColor: "bg-green-500/20 text-green-400",
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
icon: Trash2,
|
||||
label: "Delete",
|
||||
shortcut: "D",
|
||||
color: "hover:bg-red-500/20 hover:text-red-400",
|
||||
activeColor: "bg-red-500/20 text-red-400",
|
||||
},
|
||||
// {
|
||||
// id: 'painting',
|
||||
// icon: Paintbrush,
|
||||
// label: 'Painting',
|
||||
// shortcut: 'P',
|
||||
// color: 'hover:bg-cyan-500/20 hover:text-cyan-400',
|
||||
// activeColor: 'bg-cyan-500/20 text-cyan-400',
|
||||
// },
|
||||
// {
|
||||
// id: 'guide',
|
||||
// icon: Image,
|
||||
// label: 'Guide',
|
||||
// shortcut: 'G',
|
||||
// color: 'hover:bg-purple-500/20 hover:text-purple-400',
|
||||
// activeColor: 'bg-purple-500/20 text-purple-400',
|
||||
// },
|
||||
];
|
||||
|
||||
// Define which modes are available in each editor mode
|
||||
const modesByPhase: Record<Phase, Mode[]> = {
|
||||
site: ["select", "edit"],
|
||||
structure: ["select", "delete", "build"],
|
||||
furnish: ["select", "delete", "build"],
|
||||
};
|
||||
|
||||
export function ControlModes() {
|
||||
const mode = useEditor((state) => state.mode);
|
||||
const phase = useEditor((state) => state.phase);
|
||||
const setMode = useEditor((state) => state.setMode);
|
||||
|
||||
const availableModeIds = modesByPhase[phase];
|
||||
const availableModes = allModes.filter((m) =>
|
||||
availableModeIds.includes(m.id)
|
||||
);
|
||||
|
||||
const handleModeClick = (mode: Mode) => {
|
||||
setMode(mode);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{availableModes.map((m) => {
|
||||
const Icon = m.icon;
|
||||
const isActive = mode === m.id;
|
||||
const isImageMode = Boolean(m.imageSrc);
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
key={m.id}
|
||||
label={m.label}
|
||||
shortcut={m.shortcut}
|
||||
className={cn(
|
||||
"text-muted-foreground",
|
||||
!isImageMode && !isActive && m.color,
|
||||
!isImageMode && isActive && m.activeColor,
|
||||
isImageMode && isActive && "bg-white/10 hover:bg-white/10",
|
||||
isImageMode && !isActive && "hover:bg-white/5"
|
||||
)}
|
||||
onClick={() => handleModeClick(m.id)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
{m.imageSrc ? (
|
||||
<Image
|
||||
alt={m.label}
|
||||
className={cn(
|
||||
"h-[28px] w-[28px] object-contain transition-[opacity,filter] duration-200",
|
||||
!isActive && "opacity-60 grayscale",
|
||||
isActive && "opacity-100 grayscale-0"
|
||||
)}
|
||||
height={28}
|
||||
src={m.imageSrc}
|
||||
width={28}
|
||||
/>
|
||||
) : (
|
||||
Icon && <Icon className="h-5 w-5" />
|
||||
)}
|
||||
</ActionButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import NextImage from "next/image";
|
||||
import { ActionButton } from "./action-button";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import useEditor, { CatalogCategory } from "@/store/use-editor";
|
||||
|
||||
export type FurnishToolConfig = {
|
||||
id: "item";
|
||||
iconSrc: string;
|
||||
label: string;
|
||||
catalogCategory: CatalogCategory;
|
||||
};
|
||||
|
||||
// Furnish mode tools: furniture, appliances, decoration (painting is now a control mode)
|
||||
export const furnishTools: FurnishToolConfig[] = [
|
||||
{
|
||||
id: "item",
|
||||
iconSrc: "/icons/couch.png",
|
||||
label: "Furniture",
|
||||
catalogCategory: "furniture",
|
||||
},
|
||||
{
|
||||
id: "item",
|
||||
iconSrc: "/icons/appliance.png",
|
||||
label: "Appliance",
|
||||
catalogCategory: "appliance",
|
||||
},
|
||||
{
|
||||
id: "item",
|
||||
iconSrc: "/icons/kitchen.png",
|
||||
label: "Kitchen",
|
||||
catalogCategory: "kitchen",
|
||||
},
|
||||
{
|
||||
id: "item",
|
||||
iconSrc: "/icons/bathroom.png",
|
||||
label: "Bathroom",
|
||||
catalogCategory: "bathroom",
|
||||
},
|
||||
{
|
||||
id: "item",
|
||||
iconSrc: "/icons/tree.png",
|
||||
label: "Outdoor",
|
||||
catalogCategory: "outdoor",
|
||||
},
|
||||
];
|
||||
|
||||
export function FurnishTools() {
|
||||
const mode = useEditor((state) => state.mode);
|
||||
const activeTool = useEditor((state) => state.tool);
|
||||
const setActiveTool = useEditor((state) => state.setTool);
|
||||
const setMode = useEditor((state) => state.setMode);
|
||||
const catalogCategory = useEditor((state) => state.catalogCategory);
|
||||
const setCatalogCategory = useEditor((state) => state.setCatalogCategory);
|
||||
|
||||
const hasActiveTool = furnishTools.some((tool) =>
|
||||
mode === "build" &&
|
||||
activeTool === "item" &&
|
||||
catalogCategory === tool.catalogCategory
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-1">
|
||||
{furnishTools.map((tool, index) => {
|
||||
// For item tools with catalog category, check both tool and category match
|
||||
const isActive =
|
||||
mode === "build" &&
|
||||
activeTool === "item" &&
|
||||
catalogCategory === tool.catalogCategory;
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
key={`${tool.id}-${tool.catalogCategory ?? index}`}
|
||||
label={tool.label}
|
||||
className={cn(
|
||||
"rounded-lg duration-300",
|
||||
isActive ? "bg-black/40 hover:bg-black/40 scale-110 z-10" : "bg-transparent opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-black/20 scale-95",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isActive) {
|
||||
setCatalogCategory(tool.catalogCategory);
|
||||
setActiveTool("item");
|
||||
if (mode !== "build") {
|
||||
setMode("build");
|
||||
}
|
||||
}
|
||||
}}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<NextImage
|
||||
alt={tool.label}
|
||||
className="size-full object-contain"
|
||||
height={28}
|
||||
src={tool.iconSrc}
|
||||
width={28}
|
||||
/>
|
||||
</ActionButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ActionButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
icon?: React.ReactNode
|
||||
label: string
|
||||
}
|
||||
|
||||
export function ActionButton({ icon, label, className, ...props }: ActionButtonProps) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={cn(
|
||||
"flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-xs font-medium text-foreground transition-colors hover:bg-[#3e3e3e] active:bg-[#3e3e3e]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function ActionGroup({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn("flex gap-1.5", className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
interface PanelSectionProps {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
defaultExpanded?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PanelSection({
|
||||
title,
|
||||
children,
|
||||
defaultExpanded = true,
|
||||
className,
|
||||
}: PanelSectionProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded)
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
transition={{ type: "spring", bounce: 0, duration: 0.4 }}
|
||||
className={cn("flex flex-col shrink-0 overflow-hidden border-b border-border/50", className)}
|
||||
>
|
||||
<motion.button
|
||||
layout="position"
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className={cn(
|
||||
"group/section flex items-center justify-between h-10 px-3 transition-all duration-200 shrink-0",
|
||||
isExpanded
|
||||
? "bg-accent/50 text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="font-medium text-sm truncate">{title}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform duration-200",
|
||||
isExpanded ? "rotate-180" : "rotate-0",
|
||||
isExpanded ? "text-foreground" : "opacity-0 group-hover/section:opacity-100"
|
||||
)}
|
||||
/>
|
||||
</motion.button>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ type: "spring", bounce: 0, duration: 0.4 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5 p-3 pt-2">
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Check } from 'lucide-react'
|
||||
|
||||
interface ToggleControlProps {
|
||||
label: string
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ToggleControl({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
className,
|
||||
}: ToggleControlProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn("group flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm transition-colors hover:bg-[#3e3e3e]", className)}
|
||||
onClick={() => onChange(!checked)}
|
||||
>
|
||||
<div className="text-muted-foreground transition-colors group-hover:text-foreground select-none">
|
||||
{label}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-5 w-5 items-center justify-center rounded-[4px] border transition-all duration-200",
|
||||
checked
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-black/20 text-transparent group-hover:border-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" strokeWidth={3} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,712 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||
import { IconRail, type PanelId } from "./icon-rail";
|
||||
import { CommandPalette } from "@/components/ui/command-palette";
|
||||
import {
|
||||
ArrowUpCircle,
|
||||
ChevronDown,
|
||||
Clock3,
|
||||
RotateCcw,
|
||||
Save,
|
||||
} from "lucide-react";
|
||||
import { useScene } from "@pascal-app/core";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarHeader,
|
||||
useSidebarStore,
|
||||
} from "@/components/ui/primitives/sidebar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/primitives/popover";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/primitives/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SettingsPanel } from "./panels/settings-panel";
|
||||
import { SitePanel } from "./panels/site-panel";
|
||||
import {
|
||||
getProjectModel,
|
||||
getProjectVersionById,
|
||||
getProjectVersionList,
|
||||
getProjectVersionStatus,
|
||||
publishProjectModel,
|
||||
saveProjectModel,
|
||||
saveProjectVersion,
|
||||
type SceneGraph,
|
||||
type ProjectVersionListItem,
|
||||
type ProjectVersionStatus,
|
||||
} from "@/features/community/lib/models/actions";
|
||||
import { useProjectStore } from "@/features/community/lib/projects/store";
|
||||
import { updateProjectName } from "@/features/community/lib/projects/actions";
|
||||
import { applySceneGraphToEditor } from "@/features/community/lib/models/hooks";
|
||||
|
||||
function formatRelativeTime(value: string): string {
|
||||
const target = new Date(value).getTime();
|
||||
const now = Date.now();
|
||||
const diffSeconds = Math.max(1, Math.floor((now - target) / 1000));
|
||||
|
||||
if (diffSeconds < 60) return `${diffSeconds}s ago`;
|
||||
|
||||
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||
if (diffMinutes < 60) return `${diffMinutes}min ago`;
|
||||
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays < 30) return `${diffDays}d ago`;
|
||||
|
||||
const diffMonths = Math.floor(diffDays / 30);
|
||||
if (diffMonths < 12) return `${diffMonths}mo ago`;
|
||||
|
||||
const diffYears = Math.floor(diffMonths / 12);
|
||||
return `${diffYears}y ago`;
|
||||
}
|
||||
|
||||
export function AppSidebar() {
|
||||
type VersionAction = "save" | "savePublish" | "publish";
|
||||
type VersionItemAction = "restore" | "publish";
|
||||
|
||||
const [activePanel, setActivePanel] = useState<PanelId>("site");
|
||||
const activeProject = useProjectStore((s) => s.activeProject);
|
||||
const isVersionPreviewMode = useProjectStore((s) => s.isVersionPreviewMode);
|
||||
const setIsVersionPreviewMode = useProjectStore((s) => s.setIsVersionPreviewMode);
|
||||
const setIsSceneLoading = useProjectStore((s) => s.setIsSceneLoading);
|
||||
const setAutosaveStatus = useProjectStore((s) => s.setAutosaveStatus);
|
||||
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const [titleValue, setTitleValue] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [versionStatus, setVersionStatus] = useState<ProjectVersionStatus | null>(null);
|
||||
const [versionList, setVersionList] = useState<ProjectVersionListItem[]>([]);
|
||||
const [isVersionsOpen, setIsVersionsOpen] = useState(false);
|
||||
const [isVersionListLoading, setIsVersionListLoading] = useState(false);
|
||||
const [previewVersion, setPreviewVersion] = useState<{
|
||||
id: string;
|
||||
version: number;
|
||||
} | null>(null);
|
||||
const [activeVersionAction, setActiveVersionAction] = useState<VersionAction | null>(null);
|
||||
const [activeVersionItemAction, setActiveVersionItemAction] = useState<{
|
||||
version: number;
|
||||
action: VersionItemAction;
|
||||
} | null>(null);
|
||||
const latestSceneSnapshotRef = useRef<SceneGraph | null>(null);
|
||||
const activeProjectId = activeProject?.id ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
// Widen default sidebar (288px → 432px) for better project title visibility
|
||||
const store = useSidebarStore.getState();
|
||||
if (store.width <= 288) {
|
||||
store.setWidth(432);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditingTitle) {
|
||||
setTitleValue(activeProject?.name || "Untitled Project");
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}, [isEditingTitle, activeProject?.name]);
|
||||
|
||||
const handleSaveTitle = useCallback(async () => {
|
||||
const trimmed = titleValue.trim();
|
||||
if (trimmed && activeProject && trimmed !== activeProject.name) {
|
||||
// Optimistic update
|
||||
useProjectStore.setState((state) => ({
|
||||
activeProject: state.activeProject ? { ...state.activeProject, name: trimmed } : null,
|
||||
projects: state.projects.map((p) => p.id === activeProject.id ? { ...p, name: trimmed } : p)
|
||||
}));
|
||||
// Server update
|
||||
try {
|
||||
await updateProjectName(activeProject.id, trimmed);
|
||||
} catch (error) {
|
||||
console.error("Failed to update project name:", error);
|
||||
}
|
||||
}
|
||||
setIsEditingTitle(false);
|
||||
}, [titleValue, activeProject]);
|
||||
|
||||
const applyVersionStatus = useCallback(
|
||||
(status: ProjectVersionStatus) => {
|
||||
if (!activeProjectId) return;
|
||||
|
||||
const publishedVersion = status.publishedVersion ?? null;
|
||||
setVersionStatus(status);
|
||||
useProjectStore.setState((state) => ({
|
||||
activeProject: state.activeProject
|
||||
? {
|
||||
...state.activeProject,
|
||||
published_model_version: publishedVersion,
|
||||
}
|
||||
: null,
|
||||
projects: state.projects.map((project) =>
|
||||
project.id === activeProjectId
|
||||
? {
|
||||
...project,
|
||||
published_model_version: publishedVersion,
|
||||
}
|
||||
: project,
|
||||
),
|
||||
}));
|
||||
},
|
||||
[activeProjectId],
|
||||
);
|
||||
|
||||
const refreshVersionStatus = useCallback(async () => {
|
||||
if (!activeProjectId) {
|
||||
setVersionStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const statusResult = await getProjectVersionStatus(activeProjectId);
|
||||
if (!statusResult.success || !statusResult.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (useProjectStore.getState().activeProject?.id !== activeProjectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyVersionStatus(statusResult.data);
|
||||
}, [activeProjectId, applyVersionStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeProjectId) {
|
||||
setVersionStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
refreshVersionStatus();
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
refreshVersionStatus();
|
||||
}, 12_000);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [activeProjectId, refreshVersionStatus]);
|
||||
|
||||
const loadVersionList = useCallback(async () => {
|
||||
if (!activeProjectId) {
|
||||
setVersionList([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsVersionListLoading(true);
|
||||
try {
|
||||
const result = await getProjectVersionList(activeProjectId);
|
||||
if (!result.success || !result.data) {
|
||||
setVersionList([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setVersionList(result.data);
|
||||
} finally {
|
||||
setIsVersionListLoading(false);
|
||||
}
|
||||
}, [activeProjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeProjectId) {
|
||||
setVersionList([]);
|
||||
setPreviewVersion(null);
|
||||
setIsVersionPreviewMode(false);
|
||||
latestSceneSnapshotRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
loadVersionList();
|
||||
setPreviewVersion(null);
|
||||
setIsVersionPreviewMode(false);
|
||||
latestSceneSnapshotRef.current = null;
|
||||
}, [activeProjectId, loadVersionList, setIsVersionPreviewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVersionsOpen) {
|
||||
loadVersionList();
|
||||
}
|
||||
}, [isVersionsOpen, loadVersionList]);
|
||||
|
||||
const applySceneWithoutAutosave = useCallback(
|
||||
(sceneGraph: Parameters<typeof applySceneGraphToEditor>[0], keepPreviewMode: boolean) => {
|
||||
setIsVersionPreviewMode(true);
|
||||
applySceneGraphToEditor(sceneGraph);
|
||||
requestAnimationFrame(() => {
|
||||
setIsVersionPreviewMode(keepPreviewMode);
|
||||
});
|
||||
},
|
||||
[setIsVersionPreviewMode],
|
||||
);
|
||||
|
||||
const snapshotCurrentSceneGraph = useCallback((): SceneGraph => {
|
||||
const { nodes, rootNodeIds } = useScene.getState();
|
||||
// Keep a local latest snapshot so preview toggles never drop unsaved work.
|
||||
return JSON.parse(JSON.stringify({ nodes, rootNodeIds })) as SceneGraph;
|
||||
}, []);
|
||||
|
||||
const handlePreviewVersion = useCallback(
|
||||
async (modelId: string, version: number) => {
|
||||
if (!activeProjectId) return;
|
||||
|
||||
if (!isVersionPreviewMode) {
|
||||
latestSceneSnapshotRef.current = snapshotCurrentSceneGraph();
|
||||
}
|
||||
|
||||
setIsSceneLoading(true);
|
||||
try {
|
||||
const result = await getProjectVersionById(activeProjectId, modelId);
|
||||
if (!result.success || !result.data?.scene_graph) {
|
||||
return;
|
||||
}
|
||||
|
||||
applySceneWithoutAutosave(result.data.scene_graph, true);
|
||||
setPreviewVersion({ id: modelId, version });
|
||||
} finally {
|
||||
setIsSceneLoading(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
activeProjectId,
|
||||
applySceneWithoutAutosave,
|
||||
isVersionPreviewMode,
|
||||
setIsSceneLoading,
|
||||
snapshotCurrentSceneGraph,
|
||||
],
|
||||
);
|
||||
|
||||
const handleBackToLatest = useCallback(async () => {
|
||||
if (!activeProjectId) return;
|
||||
|
||||
setIsSceneLoading(true);
|
||||
try {
|
||||
const latestSceneSnapshot = latestSceneSnapshotRef.current;
|
||||
if (latestSceneSnapshot) {
|
||||
applySceneWithoutAutosave(latestSceneSnapshot, false);
|
||||
setPreviewVersion(null);
|
||||
latestSceneSnapshotRef.current = null;
|
||||
|
||||
setAutosaveStatus("saving");
|
||||
const saveResult = await saveProjectModel(activeProjectId, latestSceneSnapshot);
|
||||
if (saveResult.success) {
|
||||
if (saveResult.data) {
|
||||
applyVersionStatus(saveResult.data);
|
||||
}
|
||||
setAutosaveStatus("saved");
|
||||
await loadVersionList();
|
||||
} else {
|
||||
setAutosaveStatus("pending");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await getProjectModel(activeProjectId);
|
||||
const sceneGraph = result.success ? result.data?.model?.scene_graph ?? null : null;
|
||||
applySceneWithoutAutosave(sceneGraph, false);
|
||||
setPreviewVersion(null);
|
||||
setAutosaveStatus("saved");
|
||||
} finally {
|
||||
setIsSceneLoading(false);
|
||||
}
|
||||
}, [
|
||||
activeProjectId,
|
||||
applySceneWithoutAutosave,
|
||||
applyVersionStatus,
|
||||
loadVersionList,
|
||||
setAutosaveStatus,
|
||||
setIsSceneLoading,
|
||||
]);
|
||||
|
||||
const handleRestoreVersion = useCallback(
|
||||
async (modelId: string, version: number) => {
|
||||
if (!activeProjectId || activeVersionItemAction) return;
|
||||
|
||||
setActiveVersionItemAction({ version, action: "restore" });
|
||||
setIsSceneLoading(true);
|
||||
try {
|
||||
const versionResult = await getProjectVersionById(activeProjectId, modelId);
|
||||
if (!versionResult.success || !versionResult.data?.scene_graph) {
|
||||
return;
|
||||
}
|
||||
|
||||
const saveResult = await saveProjectModel(activeProjectId, versionResult.data.scene_graph, {
|
||||
restoredFromVersion: version,
|
||||
});
|
||||
if (!saveResult.success) {
|
||||
console.error("Failed to restore version:", saveResult.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (saveResult.data) {
|
||||
applyVersionStatus(saveResult.data);
|
||||
}
|
||||
|
||||
applySceneWithoutAutosave(versionResult.data.scene_graph, false);
|
||||
setPreviewVersion(null);
|
||||
latestSceneSnapshotRef.current = null;
|
||||
setAutosaveStatus("saved");
|
||||
await loadVersionList();
|
||||
} finally {
|
||||
setIsSceneLoading(false);
|
||||
setActiveVersionItemAction(null);
|
||||
refreshVersionStatus();
|
||||
}
|
||||
},
|
||||
[
|
||||
activeProjectId,
|
||||
activeVersionItemAction,
|
||||
applySceneWithoutAutosave,
|
||||
applyVersionStatus,
|
||||
loadVersionList,
|
||||
refreshVersionStatus,
|
||||
setAutosaveStatus,
|
||||
setIsSceneLoading,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePublishVersion = useCallback(
|
||||
async (version: number) => {
|
||||
if (!activeProjectId || activeVersionItemAction) return;
|
||||
|
||||
setActiveVersionItemAction({ version, action: "publish" });
|
||||
try {
|
||||
const result = await publishProjectModel(activeProjectId, { version });
|
||||
if (!result.success || !result.data) {
|
||||
console.error("Failed to publish version:", result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
applyVersionStatus(result.data);
|
||||
await loadVersionList();
|
||||
} finally {
|
||||
setActiveVersionItemAction(null);
|
||||
refreshVersionStatus();
|
||||
}
|
||||
},
|
||||
[
|
||||
activeProjectId,
|
||||
activeVersionItemAction,
|
||||
applyVersionStatus,
|
||||
loadVersionList,
|
||||
refreshVersionStatus,
|
||||
],
|
||||
);
|
||||
|
||||
const runVersionAction = useCallback(
|
||||
async (action: VersionAction) => {
|
||||
if (!activeProjectId || activeVersionAction || isVersionPreviewMode) return;
|
||||
|
||||
setActiveVersionAction(action);
|
||||
|
||||
try {
|
||||
const { nodes, rootNodeIds } = useScene.getState();
|
||||
const sceneGraph = { nodes, rootNodeIds };
|
||||
|
||||
// Flush latest in-memory scene into the current draft before version actions.
|
||||
const saveDraftResult = await saveProjectModel(activeProjectId, sceneGraph);
|
||||
if (!saveDraftResult.success) {
|
||||
console.error("Failed to save draft:", saveDraftResult.error);
|
||||
return;
|
||||
}
|
||||
if (saveDraftResult.data) {
|
||||
applyVersionStatus(saveDraftResult.data);
|
||||
}
|
||||
|
||||
const versionResult = await saveProjectVersion(activeProjectId, {
|
||||
publish: action !== "save",
|
||||
});
|
||||
if (!versionResult.success || !versionResult.data) {
|
||||
console.error("Failed to save/publish version:", versionResult.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (useProjectStore.getState().activeProject?.id !== activeProjectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyVersionStatus(versionResult.data);
|
||||
await loadVersionList();
|
||||
} catch (error) {
|
||||
console.error("Failed to run version action:", error);
|
||||
} finally {
|
||||
setActiveVersionAction(null);
|
||||
refreshVersionStatus();
|
||||
}
|
||||
},
|
||||
[
|
||||
activeProjectId,
|
||||
activeVersionAction,
|
||||
applyVersionStatus,
|
||||
isVersionPreviewMode,
|
||||
loadVersionList,
|
||||
refreshVersionStatus,
|
||||
],
|
||||
);
|
||||
|
||||
const isVersionActionRunning = activeVersionAction !== null;
|
||||
const isVersionActionsDisabled = isVersionActionRunning || isVersionPreviewMode;
|
||||
const isQuickSaveDisabled = isVersionActionsDisabled;
|
||||
const quickSaveLabel = activeVersionAction === "save" ? "Saving..." : "Save";
|
||||
const quickSaveDescription = isVersionPreviewMode
|
||||
? "Back to latest to save"
|
||||
: "Save a new version";
|
||||
|
||||
const triggerVersionLabel = useMemo(() => {
|
||||
if (isVersionPreviewMode && previewVersion !== null) {
|
||||
return `v${previewVersion.version}`;
|
||||
}
|
||||
|
||||
if (versionStatus?.draftVersion !== null && versionStatus?.draftVersion !== undefined) {
|
||||
return "Latest";
|
||||
}
|
||||
|
||||
if (versionStatus?.latestSavedVersion !== null && versionStatus?.latestSavedVersion !== undefined) {
|
||||
return "Latest";
|
||||
}
|
||||
|
||||
return "Versions";
|
||||
}, [
|
||||
isVersionPreviewMode,
|
||||
previewVersion,
|
||||
versionStatus?.draftVersion,
|
||||
versionStatus?.latestSavedVersion,
|
||||
]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSaveTitle();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setIsEditingTitle(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderPanelContent = () => {
|
||||
switch (activePanel) {
|
||||
case "site":
|
||||
return <SitePanel />;
|
||||
case "settings":
|
||||
return <SettingsPanel />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar className={cn("dark text-white ")} variant="floating">
|
||||
<div className="flex h-full">
|
||||
{/* Icon Rail */}
|
||||
<IconRail activePanel={activePanel} onPanelChange={setActivePanel} />
|
||||
|
||||
{/* Panel Content */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<SidebarHeader className="flex-col items-start justify-center px-3 py-3 gap-1 border-b border-border/50 relative">
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
{isEditingTitle ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={titleValue}
|
||||
onChange={(e) => setTitleValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleSaveTitle}
|
||||
placeholder="Untitled Project"
|
||||
className="w-full bg-transparent text-foreground outline-none border-b border-primary/50 focus:border-primary rounded-none px-0 py-0 m-0 h-7 font-semibold text-lg"
|
||||
/>
|
||||
) : (
|
||||
<h1
|
||||
className="font-semibold text-lg truncate cursor-text w-full h-7 border-b border-transparent hover:border-border/50 transition-colors leading-7"
|
||||
onClick={() => setIsEditingTitle(true)}
|
||||
>
|
||||
{activeProject?.name || "Untitled Project"}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("shrink-0 flex items-center gap-1 transition-all duration-200", isEditingTitle && "hidden")}>
|
||||
{activeProjectId && (
|
||||
<Popover open={isVersionsOpen} onOpenChange={setIsVersionsOpen}>
|
||||
<div className="inline-flex h-8 overflow-hidden rounded-full border border-border/50 bg-black/20">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runVersionAction("save")}
|
||||
disabled={isQuickSaveDisabled}
|
||||
className={cn(
|
||||
"group/save-trigger relative inline-flex h-full w-16 items-center border-r border-border/50 px-1.5 text-[10px] transition-colors",
|
||||
isQuickSaveDisabled
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: "hover:bg-black/30",
|
||||
)}
|
||||
>
|
||||
<span className="pointer-events-none inline-flex min-w-0 items-center gap-1 transition-opacity group-hover/save-trigger:opacity-0">
|
||||
<Clock3 className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 truncate text-left text-muted-foreground">
|
||||
{triggerVersionLabel}
|
||||
</span>
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-0 flex items-center justify-center gap-1 opacity-0 transition-opacity group-hover/save-trigger:opacity-100">
|
||||
<Save className="h-3 w-3 shrink-0 text-foreground" />
|
||||
<span className="font-medium text-foreground">{quickSaveLabel}</span>
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{quickSaveDescription}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-full w-6 items-center justify-center text-muted-foreground transition-colors hover:bg-black/30 hover:text-foreground data-[state=open]:bg-black/35"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-[min(320px,calc(var(--sidebar-width)-3rem),calc(100vw-2rem))] min-w-[230px] p-2"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="max-h-[280px] overflow-y-auto">
|
||||
{isVersionListLoading ? (
|
||||
<div className="px-2 py-3 text-xs text-muted-foreground">
|
||||
Loading versions...
|
||||
</div>
|
||||
) : versionList.length === 0 ? (
|
||||
<div className="px-2 py-3 text-xs text-muted-foreground">
|
||||
No versions found
|
||||
</div>
|
||||
) : (
|
||||
versionList.map((item) => {
|
||||
const isPublished = item.isPublished;
|
||||
const isCurrentlyViewed = isVersionPreviewMode
|
||||
? previewVersion?.id === item.id
|
||||
: item.isDraft;
|
||||
const isActionPending = activeVersionItemAction?.version === item.version;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"group/version-item relative mb-0.5 flex items-center gap-1 rounded-md px-2 py-1.5 transition-colors",
|
||||
isCurrentlyViewed ? "bg-accent/25" : "hover:bg-accent/20"
|
||||
)}
|
||||
>
|
||||
{isCurrentlyViewed && (
|
||||
<span className="pointer-events-none absolute right-0 top-1 bottom-1 w-0.5 rounded-full bg-primary/70" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
item.isDraft
|
||||
? handleBackToLatest()
|
||||
: handlePreviewVersion(item.id, item.version)
|
||||
}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-sm font-medium leading-none">
|
||||
{item.isDraft
|
||||
? "Latest"
|
||||
: `Version ${item.version}`}
|
||||
</span>
|
||||
{item.isDraft && item.restoredFromVersion !== null && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
restored from v{item.restoredFromVersion}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
{formatRelativeTime(item.updatedAt)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{!item.isDraft && (
|
||||
<div className="absolute right-1 top-1 flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleRestoreVersion(item.id, item.version);
|
||||
}}
|
||||
disabled={!!activeVersionItemAction}
|
||||
className={cn(
|
||||
"group/restore pointer-events-none inline-flex h-6 items-center rounded-md border border-border/50 bg-background/80 px-1.5 text-muted-foreground opacity-0 transition-all duration-150 group-hover/version-item:pointer-events-auto group-hover/version-item:opacity-100 hover:border-border hover:bg-accent/20 hover:text-foreground",
|
||||
isActionPending &&
|
||||
activeVersionItemAction?.action === "restore" &&
|
||||
"border-primary/40 text-primary"
|
||||
)}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="max-w-0 overflow-hidden whitespace-nowrap text-[10px] opacity-0 transition-all duration-150 group-hover/restore:ml-1 group-hover/restore:max-w-14 group-hover/restore:opacity-100">
|
||||
Restore
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isPublished ? (
|
||||
<span className="inline-flex h-6 items-center rounded-md bg-emerald-500/15 px-2 text-[10px] font-medium text-emerald-400">
|
||||
Published
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handlePublishVersion(item.version);
|
||||
}}
|
||||
disabled={!!activeVersionItemAction}
|
||||
className={cn(
|
||||
"group/publish pointer-events-none inline-flex h-6 items-center rounded-md border border-sky-500/35 bg-sky-500/10 px-1.5 text-sky-300 opacity-0 transition-all duration-150 group-hover/version-item:pointer-events-auto group-hover/version-item:opacity-100 hover:border-sky-400/50 hover:bg-sky-500/20 hover:text-sky-200",
|
||||
isActionPending &&
|
||||
activeVersionItemAction?.action === "publish" &&
|
||||
"border-sky-300/60 text-sky-200"
|
||||
)}
|
||||
>
|
||||
<ArrowUpCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="max-w-0 overflow-hidden whitespace-nowrap text-[10px] opacity-0 transition-all duration-150 group-hover/publish:ml-1 group-hover/publish:max-w-14 group-hover/publish:opacity-100">
|
||||
Publish
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent
|
||||
className={cn("no-scrollbar flex flex-1 flex-col overflow-hidden")}
|
||||
>
|
||||
{renderPanelContent()}
|
||||
</SidebarContent>
|
||||
</div>
|
||||
</div>
|
||||
</Sidebar>
|
||||
<CommandPalette />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, Command, FolderOpen, Moon, Search, Sun } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/primitives/tooltip";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/primitives/popover";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/primitives/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import { useProjectStore } from "@/features/community/lib/projects/store";
|
||||
import { useCommandPalette } from "@/components/ui/command-palette";
|
||||
|
||||
function OpenProjectModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const projects = useProjectStore((s) => s.projects);
|
||||
const activeProject = useProjectStore((s) => s.activeProject);
|
||||
const fetchProjects = useProjectStore((s) => s.fetchProjects);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && projects.length === 0) {
|
||||
fetchProjects();
|
||||
}
|
||||
}, [open, projects.length, fetchProjects]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm p-0 gap-0 overflow-hidden">
|
||||
<DialogHeader className="px-4 pt-4 pb-3 border-b border-border/50">
|
||||
<DialogTitle className="text-sm font-medium">Open project</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="max-h-80 overflow-y-auto p-1.5">
|
||||
{projects.length === 0 ? (
|
||||
<p className="px-3 py-6 text-sm text-muted-foreground text-center">
|
||||
No projects found
|
||||
</p>
|
||||
) : (
|
||||
projects.map((project) => {
|
||||
const isActive = project.id === activeProject?.id;
|
||||
return (
|
||||
<button
|
||||
key={project.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors hover:bg-accent",
|
||||
isActive && "bg-accent/50"
|
||||
)}
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
router.push(`/editor/${project.id}`);
|
||||
}}
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded bg-muted overflow-hidden">
|
||||
{project.thumbnail_url ? (
|
||||
<img
|
||||
src={project.thumbnail_url}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<p className="flex-1 min-w-0 truncate text-sm font-medium">
|
||||
{project.name}
|
||||
</p>
|
||||
{isActive && (
|
||||
<div className="h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export type PanelId = "site" | "settings";
|
||||
|
||||
interface IconRailProps {
|
||||
activePanel: PanelId;
|
||||
onPanelChange: (panel: PanelId) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const panels: { id: PanelId; iconSrc: string; label: string }[] = [
|
||||
{ id: "site", iconSrc: "/icons/level.png", label: "Site" },
|
||||
{ id: "settings", iconSrc: "/icons/settings.png", label: "Settings" },
|
||||
];
|
||||
|
||||
export function IconRail({
|
||||
activePanel,
|
||||
onPanelChange,
|
||||
className,
|
||||
}: IconRailProps) {
|
||||
const theme = useViewer((state) => state.theme);
|
||||
const setTheme = useViewer((state) => state.setTheme);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [isOpenProjectOpen, setIsSwitchProjectOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const handleOpenProject = () => {
|
||||
setIsMenuOpen(false);
|
||||
setIsSwitchProjectOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-11 flex-col items-center gap-1 border-border/50 border-r py-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Pascal logo — app menu */}
|
||||
<Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg transition-all hover:bg-accent"
|
||||
>
|
||||
<Image
|
||||
src="/pascal-logo-shape.svg"
|
||||
alt="Pascal"
|
||||
width={24}
|
||||
height={24}
|
||||
className="h-6 w-6 dark:invert"
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="right" align="start" className="w-52 p-1" sideOffset={8}>
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
Back to community
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
onClick={handleOpenProject}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
Open project
|
||||
</button>
|
||||
<div className="my-1 h-px bg-border/50" />
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={() => { setIsMenuOpen(false); useCommandPalette.getState().setOpen(true); }}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 text-left">Actions...</span>
|
||||
<span className="flex items-center gap-0.5 rounded border border-border/60 bg-muted/60 px-1 py-0.5 text-[10px] leading-none text-muted-foreground">
|
||||
<Command className="h-2.5 w-2.5" />
|
||||
K
|
||||
</span>
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-8 h-px bg-border/50 mb-1" />
|
||||
|
||||
{panels.map((panel) => {
|
||||
const isActive = activePanel === panel.id;
|
||||
return (
|
||||
<Tooltip key={panel.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"flex h-9 w-9 items-center justify-center rounded-lg transition-all",
|
||||
isActive ? "bg-accent" : "hover:bg-accent",
|
||||
)}
|
||||
onClick={() => onPanelChange(panel.id)}
|
||||
type="button"
|
||||
>
|
||||
<img
|
||||
src={panel.iconSrc}
|
||||
alt={panel.label}
|
||||
className={cn(
|
||||
"h-6 w-6 transition-all object-contain",
|
||||
!isActive && "opacity-50 saturate-0"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{panel.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Theme Toggle */}
|
||||
{mounted && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/50 bg-accent/40 transition-all text-foreground hover:bg-accent mb-2"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
type="button"
|
||||
>
|
||||
<motion.div
|
||||
key={theme}
|
||||
initial={{ rotate: -90, opacity: 0 }}
|
||||
animate={{ rotate: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
>
|
||||
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</motion.div>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Toggle theme</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<OpenProjectModal open={isOpenProjectOpen} onOpenChange={setIsSwitchProjectOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { panels };
|
||||
-189
@@ -1,189 +0,0 @@
|
||||
import { Keyboard } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/primitives/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/primitives/dialog";
|
||||
|
||||
type Shortcut = {
|
||||
keys: string[];
|
||||
action: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
type ShortcutCategory = {
|
||||
title: string;
|
||||
shortcuts: Shortcut[];
|
||||
};
|
||||
|
||||
const KEY_DISPLAY_MAP: Record<string, string> = {
|
||||
"Arrow Up": "↑",
|
||||
"Arrow Down": "↓",
|
||||
Esc: "⎋",
|
||||
Shift: "⇧",
|
||||
Space: "␣",
|
||||
};
|
||||
|
||||
const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
|
||||
{
|
||||
title: "Editor Navigation",
|
||||
shortcuts: [
|
||||
{ keys: ["1"], action: "Switch to Site phase" },
|
||||
{ keys: ["2"], action: "Switch to Structure phase" },
|
||||
{ keys: ["3"], action: "Switch to Furnish phase" },
|
||||
{ keys: ["S"], action: "Switch to Structure layer" },
|
||||
{ keys: ["F"], action: "Switch to Furnish layer" },
|
||||
{ keys: ["Z"], action: "Switch to Zones layer" },
|
||||
{
|
||||
keys: ["Cmd/Ctrl", "Arrow Up"],
|
||||
action: "Select next level in the active building",
|
||||
},
|
||||
{
|
||||
keys: ["Cmd/Ctrl", "Arrow Down"],
|
||||
action: "Select previous level in the active building",
|
||||
},
|
||||
{ keys: ["Cmd/Ctrl", "B"], action: "Toggle sidebar" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Modes & History",
|
||||
shortcuts: [
|
||||
{ keys: ["V"], action: "Switch to Select mode" },
|
||||
{ keys: ["B"], action: "Switch to Build mode" },
|
||||
{
|
||||
keys: ["Esc"],
|
||||
action: "Cancel active tool, clear selection, and exit build mode",
|
||||
},
|
||||
{ keys: ["Delete / Backspace"], action: "Delete selected objects" },
|
||||
{ keys: ["Cmd/Ctrl", "Z"], action: "Undo" },
|
||||
{ keys: ["Cmd/Ctrl", "Shift", "Z"], action: "Redo" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Selection",
|
||||
shortcuts: [
|
||||
{
|
||||
keys: ["Cmd/Ctrl", "Click"],
|
||||
action: "Add or remove an object from multi-selection",
|
||||
note: "Works while in Select mode.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Drawing Tools",
|
||||
shortcuts: [
|
||||
{
|
||||
keys: ["Shift"],
|
||||
action: "Temporarily disable angle snapping while drawing walls, slabs, and ceilings",
|
||||
note: "Hold while drawing.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Item Placement",
|
||||
shortcuts: [
|
||||
{ keys: ["R"], action: "Rotate item clockwise by 90 degrees" },
|
||||
{ keys: ["T"], action: "Rotate item counter-clockwise by 90 degrees" },
|
||||
{
|
||||
keys: ["Shift"],
|
||||
action: "Temporarily bypass placement validation constraints",
|
||||
note: "Hold while placing.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Camera",
|
||||
shortcuts: [
|
||||
{
|
||||
keys: ["Space", "Drag"],
|
||||
action: "Pan camera",
|
||||
note: "Hold Space while dragging with the mouse.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function getDisplayKey(key: string, isMac: boolean): string {
|
||||
if (key === "Cmd/Ctrl") return isMac ? "⌘" : "Ctrl";
|
||||
if (key === "Delete / Backspace") return isMac ? "⌫" : "Backspace";
|
||||
return KEY_DISPLAY_MAP[key] ?? key;
|
||||
}
|
||||
|
||||
function ShortcutKeys({ keys }: { keys: string[] }) {
|
||||
const [isMac, setIsMac] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMac(navigator.platform.toUpperCase().indexOf("MAC") >= 0);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{keys.map((key, index) => (
|
||||
<div key={`${key}-${index}`} className="flex items-center gap-1">
|
||||
{index > 0 ? (
|
||||
<span className="text-[10px] text-muted-foreground">+</span>
|
||||
) : null}
|
||||
<kbd
|
||||
className="inline-flex h-6 items-center rounded border border-border bg-muted px-2 font-mono text-[11px] font-medium text-muted-foreground"
|
||||
title={key}
|
||||
>
|
||||
{getDisplayKey(key, isMac)}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsDialog() {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="w-full justify-start gap-2" variant="outline">
|
||||
<Keyboard className="size-4" />
|
||||
Keyboard Shortcuts
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[85vh] flex flex-col overflow-hidden p-0 sm:max-w-3xl">
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4">
|
||||
<DialogTitle>Keyboard Shortcuts</DialogTitle>
|
||||
<DialogDescription>
|
||||
Shortcuts are context-aware and depend on the current phase or tool.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-5">
|
||||
{SHORTCUT_CATEGORIES.map((category) => (
|
||||
<section key={category.title} className="space-y-2">
|
||||
<h3 className="font-medium text-sm">{category.title}</h3>
|
||||
<div className="overflow-hidden rounded-md border border-border/80">
|
||||
{category.shortcuts.map((shortcut, index) => (
|
||||
<div
|
||||
key={`${category.title}-${shortcut.action}`}
|
||||
className="grid grid-cols-[minmax(130px,220px)_1fr] gap-3 px-3 py-2"
|
||||
>
|
||||
<ShortcutKeys keys={shortcut.keys} />
|
||||
<div>
|
||||
<p className="text-sm">{shortcut.action}</p>
|
||||
{shortcut.note ? (
|
||||
<p className="text-muted-foreground text-xs">{shortcut.note}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{index < category.shortcuts.length - 1 ? (
|
||||
<div className="col-span-2 border-border/60 border-b" />
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { type AnyNodeId, CeilingNode, useScene } from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import Image from "next/image";
|
||||
import { useState, useEffect } from "react";
|
||||
import useEditor from "@/store/use-editor";
|
||||
import { InlineRenameInput } from "./inline-rename-input";
|
||||
import { TreeNode, TreeNodeWrapper, handleTreeSelection } from "./tree-node";
|
||||
import { TreeNodeActions } from "./tree-node-actions";
|
||||
|
||||
interface CeilingTreeNodeProps {
|
||||
node: CeilingNode;
|
||||
depth: number;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
export function CeilingTreeNode({ node, depth, isLast }: CeilingTreeNodeProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds);
|
||||
const isSelected = selectedIds.includes(node.id);
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
||||
const setSelection = useViewer((state) => state.setSelection);
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIds.length === 0) return;
|
||||
const nodes = useScene.getState().nodes;
|
||||
let isDescendant = false;
|
||||
for (const id of selectedIds) {
|
||||
let current = nodes[id as AnyNodeId];
|
||||
while (current && current.parentId) {
|
||||
if (current.parentId === node.id) {
|
||||
isDescendant = true;
|
||||
break;
|
||||
}
|
||||
current = nodes[current.parentId as AnyNodeId];
|
||||
}
|
||||
if (isDescendant) break;
|
||||
}
|
||||
if (isDescendant) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, [selectedIds, node.id]);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection);
|
||||
if (!handled && useEditor.getState().phase === "furnish") {
|
||||
useEditor.getState().setPhase("structure");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setHoveredId(node.id);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHoveredId(null);
|
||||
};
|
||||
|
||||
// Calculate approximate area from polygon
|
||||
const area = calculatePolygonArea(node.polygon).toFixed(1);
|
||||
const defaultName = `Ceiling (${area}m²)`;
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
nodeId={node.id}
|
||||
icon={<Image src="/icons/ceiling.png" alt="" width={14} height={14} className="object-contain" />}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
node={node}
|
||||
isEditing={isEditing}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
defaultName={defaultName}
|
||||
/>
|
||||
}
|
||||
depth={depth}
|
||||
hasChildren={node.children.length > 0}
|
||||
expanded={expanded}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
isSelected={isSelected}
|
||||
isHovered={isHovered}
|
||||
isVisible={node.visible !== false}
|
||||
isLast={isLast}
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
>
|
||||
{node.children.map((childId, index) => (
|
||||
<TreeNode key={childId} nodeId={childId} depth={depth + 1} isLast={index === node.children.length - 1} />
|
||||
))}
|
||||
</TreeNodeWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the area of a polygon using the shoelace formula
|
||||
*/
|
||||
function calculatePolygonArea(polygon: Array<[number, number]>): number {
|
||||
if (polygon.length < 3) return 0;
|
||||
|
||||
let area = 0;
|
||||
const n = polygon.length;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n;
|
||||
area += polygon[i]![0] * polygon[j]![1];
|
||||
area -= polygon[j]![0] * polygon[i]![1];
|
||||
}
|
||||
|
||||
return Math.abs(area) / 2;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,98 +0,0 @@
|
||||
import { useScene, type AnyNode } from "@pascal-app/core";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface InlineRenameInputProps {
|
||||
node: AnyNode;
|
||||
isEditing: boolean;
|
||||
onStopEditing: () => void;
|
||||
defaultName: string;
|
||||
className?: string;
|
||||
onStartEditing?: () => void;
|
||||
}
|
||||
|
||||
export function InlineRenameInput({
|
||||
node,
|
||||
isEditing,
|
||||
onStopEditing,
|
||||
defaultName,
|
||||
className,
|
||||
onStartEditing,
|
||||
}: InlineRenameInputProps) {
|
||||
const updateNode = useScene((s) => s.updateNode);
|
||||
const [value, setValue] = useState(node.name || "");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
setValue(node.name || "");
|
||||
// Focus and select all text after a short delay
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}, [isEditing, node.name]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed !== node.name) {
|
||||
updateNode(node.id, { name: trimmed || undefined });
|
||||
}
|
||||
onStopEditing();
|
||||
}, [value, node.id, node.name, updateNode, onStopEditing]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onStopEditing();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 group/rename min-w-0 h-5">
|
||||
<span
|
||||
className={cn("truncate border-b border-transparent", className)}
|
||||
>
|
||||
{node.name || defaultName}
|
||||
</span>
|
||||
{onStartEditing && (
|
||||
<button
|
||||
className="opacity-0 group-hover/rename:opacity-100 transition-opacity text-muted-foreground hover:text-foreground shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStartEditing();
|
||||
}}
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleSave}
|
||||
placeholder={defaultName}
|
||||
className={cn(
|
||||
"flex-1 w-full bg-transparent text-foreground outline-none border-b border-primary/50 focus:border-primary rounded-none px-0 py-0 m-0 h-5 text-sm",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import { type AnyNodeId, ItemNode, useScene } from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import Image from "next/image";
|
||||
import { useState, useEffect } from "react";
|
||||
import useEditor from "@/store/use-editor";
|
||||
import { InlineRenameInput } from "./inline-rename-input";
|
||||
import { TreeNode, TreeNodeWrapper, handleTreeSelection } from "./tree-node";
|
||||
import { TreeNodeActions } from "./tree-node-actions";
|
||||
|
||||
const CATEGORY_ICONS: Record<string, string> = {
|
||||
door: "/icons/door.png",
|
||||
window: "/icons/window.png",
|
||||
furniture: "/icons/couch.png",
|
||||
appliance: "/icons/appliance.png",
|
||||
kitchen: "/icons/kitchen.png",
|
||||
bathroom: "/icons/bathroom.png",
|
||||
outdoor: "/icons/tree.png",
|
||||
};
|
||||
|
||||
interface ItemTreeNodeProps {
|
||||
node: ItemNode;
|
||||
depth: number;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
export function ItemTreeNode({ node, depth, isLast }: ItemTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const iconSrc = CATEGORY_ICONS[node.asset.category] || "/icons/couch.png";
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds);
|
||||
const isSelected = selectedIds.includes(node.id);
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
||||
const setSelection = useViewer((state) => state.setSelection);
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIds.length === 0) return;
|
||||
const nodes = useScene.getState().nodes;
|
||||
let isDescendant = false;
|
||||
for (const id of selectedIds) {
|
||||
let current = nodes[id as AnyNodeId];
|
||||
while (current && current.parentId) {
|
||||
if (current.parentId === node.id) {
|
||||
isDescendant = true;
|
||||
break;
|
||||
}
|
||||
current = nodes[current.parentId as AnyNodeId];
|
||||
}
|
||||
if (isDescendant) break;
|
||||
}
|
||||
if (isDescendant) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, [selectedIds, node.id]);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection);
|
||||
if (!handled && useEditor.getState().phase === "structure") {
|
||||
useEditor.getState().setPhase("furnish");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setHoveredId(node.id);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHoveredId(null);
|
||||
};
|
||||
|
||||
const defaultName = node.asset.name || "Item";
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
nodeId={node.id}
|
||||
icon={<Image src={iconSrc} alt="" width={14} height={14} className="object-contain" />}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
node={node}
|
||||
isEditing={isEditing}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
defaultName={defaultName}
|
||||
/>
|
||||
}
|
||||
depth={depth}
|
||||
hasChildren={hasChildren}
|
||||
expanded={expanded}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
isSelected={isSelected}
|
||||
isHovered={isHovered}
|
||||
isVisible={node.visible !== false}
|
||||
isLast={isLast}
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
>
|
||||
{hasChildren && node.children.map((childId, index) => (
|
||||
<TreeNode key={childId} nodeId={childId} depth={depth + 1} isLast={index === node.children.length - 1} />
|
||||
))}
|
||||
</TreeNodeWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { type AnyNode, type AnyNodeId, emitter, useScene } from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import { Camera, Eye, EyeOff, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/primitives/popover";
|
||||
|
||||
interface TreeNodeActionsProps {
|
||||
node: AnyNode;
|
||||
}
|
||||
|
||||
export function TreeNodeActions({ node }: TreeNodeActionsProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const updateNode = useScene((state) => state.updateNode);
|
||||
const updateNodes = useScene((state) => state.updateNodes);
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds);
|
||||
const hasCamera = !!node.camera;
|
||||
const isVisible = node.visible !== false;
|
||||
|
||||
const toggleVisibility = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const newVisibility = !isVisible;
|
||||
if (selectedIds && selectedIds.includes(node.id)) {
|
||||
updateNodes(
|
||||
selectedIds.map((id) => ({
|
||||
id: id as AnyNodeId,
|
||||
data: { visible: newVisibility },
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
updateNode(node.id, { visible: newVisibility });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCaptureCamera = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
emitter.emit("camera-controls:capture", { nodeId: node.id });
|
||||
setOpen(false);
|
||||
};
|
||||
const handleViewCamera = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
emitter.emit("camera-controls:view", { nodeId: node.id });
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleClearCamera = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
updateNode(node.id, { camera: undefined });
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
className="w-6 h-6 flex items-center justify-center rounded-md cursor-pointer hover:bg-black/5 dark:hover:bg-white/10 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={toggleVisibility}
|
||||
title={isVisible ? "Hide" : "Show"}
|
||||
>
|
||||
{isVisible ? (
|
||||
<Eye className="w-3 h-3" />
|
||||
) : (
|
||||
<EyeOff className="w-3 h-3 opacity-50" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="relative w-6 h-6 flex items-center justify-center rounded-md cursor-pointer hover:bg-black/5 dark:hover:bg-white/10 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="Camera snapshot"
|
||||
>
|
||||
<Camera className="w-3 h-3" />
|
||||
{hasCamera && (
|
||||
<span className="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="start"
|
||||
className="w-auto p-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{hasCamera && (
|
||||
<button
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
|
||||
onClick={handleViewCamera}
|
||||
>
|
||||
<Camera className="w-3.5 h-3.5" />
|
||||
View snapshot
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
|
||||
onClick={handleCaptureCamera}
|
||||
>
|
||||
<Camera className="w-3.5 h-3.5" />
|
||||
{hasCamera ? "Update snapshot" : "Take snapshot"}
|
||||
</button>
|
||||
{hasCamera && (
|
||||
<button
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-destructive hover:text-destructive-foreground text-left w-full"
|
||||
onClick={handleClearCamera}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
Clear snapshot
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
import { AnyNodeId, useScene } from "@pascal-app/core";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { forwardRef, useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
|
||||
export function handleTreeSelection(
|
||||
e: React.MouseEvent,
|
||||
nodeId: string,
|
||||
selectedIds: string[],
|
||||
setSelection: (s: any) => void
|
||||
) {
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
if (selectedIds.includes(nodeId)) {
|
||||
setSelection({ selectedIds: selectedIds.filter((id) => id !== nodeId) });
|
||||
} else {
|
||||
setSelection({ selectedIds: [...selectedIds, nodeId] });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.shiftKey && selectedIds.length > 0) {
|
||||
const lastSelectedId = selectedIds[selectedIds.length - 1];
|
||||
if (lastSelectedId) {
|
||||
const nodes = Array.from(document.querySelectorAll('[data-treenode-id]'));
|
||||
const nodeIds = nodes.map(n => n.getAttribute('data-treenode-id') as string);
|
||||
|
||||
const startIndex = nodeIds.indexOf(lastSelectedId);
|
||||
const endIndex = nodeIds.indexOf(nodeId);
|
||||
|
||||
if (startIndex !== -1 && endIndex !== -1) {
|
||||
const start = Math.min(startIndex, endIndex);
|
||||
const end = Math.max(startIndex, endIndex);
|
||||
const range = nodeIds.slice(start, end + 1);
|
||||
|
||||
// We can keep the previous selections that were outside the range if we want,
|
||||
// but standard file system shift-click replaces the selection with the range.
|
||||
setSelection({ selectedIds: range });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Fallback: if range selection fails (e.g. node not visible in tree), just add to selection
|
||||
if (!selectedIds.includes(nodeId)) {
|
||||
setSelection({ selectedIds: [...selectedIds, nodeId] });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
setSelection({ selectedIds: [nodeId] });
|
||||
return false;
|
||||
}
|
||||
import { BuildingTreeNode } from "./building-tree-node";
|
||||
import { CeilingTreeNode } from "./ceiling-tree-node";
|
||||
import { DoorTreeNode } from "./door-tree-node";
|
||||
import { ItemTreeNode } from "./item-tree-node";
|
||||
import { LevelTreeNode } from "./level-tree-node";
|
||||
import { RoofTreeNode } from "./roof-tree-node";
|
||||
import { SlabTreeNode } from "./slab-tree-node";
|
||||
import { WallTreeNode } from "./wall-tree-node";
|
||||
import { WindowTreeNode } from "./window-tree-node";
|
||||
import { ZoneTreeNode } from "./zone-tree-node";
|
||||
|
||||
interface TreeNodeProps {
|
||||
nodeId: AnyNodeId;
|
||||
depth?: number;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
export function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
|
||||
const node = useScene((state) => state.nodes[nodeId]);
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
switch (node.type) {
|
||||
case "building":
|
||||
return <BuildingTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
case "ceiling":
|
||||
return <CeilingTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
case "level":
|
||||
return <LevelTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
case "slab":
|
||||
return <SlabTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
case "wall":
|
||||
return <WallTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
case "roof":
|
||||
return <RoofTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
case "item":
|
||||
return <ItemTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
case "door":
|
||||
return <DoorTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
case "window":
|
||||
return <WindowTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
case "zone":
|
||||
return <ZoneTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface TreeNodeWrapperProps {
|
||||
nodeId?: string;
|
||||
icon: React.ReactNode;
|
||||
label: React.ReactNode;
|
||||
depth: number;
|
||||
hasChildren: boolean;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
onDoubleClick?: () => void;
|
||||
onMouseEnter?: () => void;
|
||||
onMouseLeave?: () => void;
|
||||
actions?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
isSelected?: boolean;
|
||||
isHovered?: boolean;
|
||||
isVisible?: boolean;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
|
||||
function TreeNodeWrapper(
|
||||
{
|
||||
nodeId,
|
||||
icon,
|
||||
label,
|
||||
depth,
|
||||
hasChildren,
|
||||
expanded,
|
||||
onToggle,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
actions,
|
||||
children,
|
||||
isSelected,
|
||||
isHovered,
|
||||
isVisible = true,
|
||||
isLast,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected && rowRef.current) {
|
||||
rowRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}, [isSelected]);
|
||||
|
||||
return (
|
||||
<div ref={ref} data-treenode-id={nodeId}>
|
||||
<div
|
||||
ref={rowRef}
|
||||
className={cn(
|
||||
"relative flex items-center h-8 cursor-pointer group/row text-sm select-none border-b border-r border-border/50 border-r-transparent transition-all duration-200",
|
||||
isSelected
|
||||
? "bg-accent/50 text-foreground border-r-white border-r-3"
|
||||
: isHovered
|
||||
? "bg-accent/30 text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground",
|
||||
!isVisible && "opacity-50"
|
||||
)}
|
||||
style={{ paddingLeft: depth * 12 + 12, paddingRight: 12 }}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{/* Vertical tree line */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute w-px bg-border/50 pointer-events-none",
|
||||
isLast ? "top-0 bottom-1/2" : "top-0 bottom-0"
|
||||
)}
|
||||
style={{ left: (depth - 1) * 12 + 20 }}
|
||||
/>
|
||||
{/* Horizontal branch line */}
|
||||
<div
|
||||
className="absolute top-1/2 h-px bg-border/50 pointer-events-none"
|
||||
style={{ left: (depth - 1) * 12 + 20, width: 4 }}
|
||||
/>
|
||||
{/* Line down to children */}
|
||||
{hasChildren && expanded && (
|
||||
<div
|
||||
className="absolute top-1/2 bottom-0 w-px bg-border/50 pointer-events-none"
|
||||
style={{ left: depth * 12 + 20 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="w-4 h-4 flex items-center justify-center shrink-0 z-10 bg-inherit"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
>
|
||||
{hasChildren ? (
|
||||
expanded ? (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
)
|
||||
) : null}
|
||||
</button>
|
||||
<div
|
||||
className="flex items-center gap-1.5 flex-1 min-w-0"
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
<span className={cn(
|
||||
"w-4 h-4 flex items-center justify-center shrink-0 transition-all duration-200",
|
||||
!isSelected && "opacity-60 grayscale"
|
||||
)}>
|
||||
{icon}
|
||||
</span>
|
||||
<div className={cn(
|
||||
"flex-1 min-w-0 truncate",
|
||||
!isVisible && "line-through text-muted-foreground"
|
||||
)}>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
{actions && (
|
||||
<div className={cn(
|
||||
"opacity-0 group-hover/row:opacity-100 pr-1 transition-opacity duration-200",
|
||||
!isVisible && "opacity-100"
|
||||
)}>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{expanded && children && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ type: "spring", bounce: 0, duration: 0.3 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -1,99 +0,0 @@
|
||||
import { type AnyNodeId, WallNode, useScene } from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import Image from "next/image";
|
||||
import { useState, useEffect } from "react";
|
||||
import useEditor from "@/store/use-editor";
|
||||
import { InlineRenameInput } from "./inline-rename-input";
|
||||
import { TreeNode, TreeNodeWrapper, handleTreeSelection } from "./tree-node";
|
||||
import { TreeNodeActions } from "./tree-node-actions";
|
||||
|
||||
interface WallTreeNodeProps {
|
||||
node: WallNode;
|
||||
depth: number;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
export function WallTreeNode({ node, depth, isLast }: WallTreeNodeProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds);
|
||||
const isSelected = selectedIds.includes(node.id);
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
||||
const setSelection = useViewer((state) => state.setSelection);
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIds.length === 0) return;
|
||||
const nodes = useScene.getState().nodes;
|
||||
let isDescendant = false;
|
||||
for (const id of selectedIds) {
|
||||
let current = nodes[id as AnyNodeId];
|
||||
while (current && current.parentId) {
|
||||
if (current.parentId === node.id) {
|
||||
isDescendant = true;
|
||||
break;
|
||||
}
|
||||
current = nodes[current.parentId as AnyNodeId];
|
||||
}
|
||||
if (isDescendant) break;
|
||||
}
|
||||
if (isDescendant) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, [selectedIds, node.id]);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection);
|
||||
if (!handled && useEditor.getState().phase === "furnish") {
|
||||
useEditor.getState().setPhase("structure");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setHoveredId(node.id);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHoveredId(null);
|
||||
};
|
||||
|
||||
const defaultName = "Wall";
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
nodeId={node.id}
|
||||
icon={<Image src="/icons/wall.png" alt="" width={14} height={14} className="object-contain" />}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
node={node}
|
||||
isEditing={isEditing}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
defaultName={defaultName}
|
||||
/>
|
||||
}
|
||||
depth={depth}
|
||||
hasChildren={node.children.length > 0}
|
||||
expanded={expanded}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
isSelected={isSelected}
|
||||
isHovered={isHovered}
|
||||
isVisible={node.visible !== false}
|
||||
isLast={isLast}
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
>
|
||||
{node.children.map((childId, index) => (
|
||||
<TreeNode key={childId} nodeId={childId} depth={depth + 1} isLast={index === node.children.length - 1} />
|
||||
))}
|
||||
</TreeNodeWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { ZoneNode, useScene } from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import { useState } from "react";
|
||||
import { ColorDot } from "@/components/ui/primitives/color-dot";
|
||||
import { InlineRenameInput } from "./inline-rename-input";
|
||||
import { TreeNodeWrapper } from "./tree-node";
|
||||
import { TreeNodeActions } from "./tree-node-actions";
|
||||
|
||||
interface ZoneTreeNodeProps {
|
||||
node: ZoneNode;
|
||||
depth: number;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
export function ZoneTreeNode({ node, depth, isLast }: ZoneTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const updateNode = useScene((state) => state.updateNode);
|
||||
const isSelected = useViewer((state) => state.selection.zoneId === node.id);
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
||||
const setSelection = useViewer((state) => state.setSelection);
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId);
|
||||
|
||||
const handleClick = () => {
|
||||
setSelection({ zoneId: node.id });
|
||||
};
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setHoveredId(node.id);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHoveredId(null);
|
||||
};
|
||||
|
||||
// Calculate approximate area from polygon
|
||||
const area = calculatePolygonArea(node.polygon).toFixed(1);
|
||||
const defaultName = `Zone (${area}m²)`;
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
icon={
|
||||
<ColorDot
|
||||
color={node.color}
|
||||
onChange={(color) => updateNode(node.id, { color })}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
node={node}
|
||||
isEditing={isEditing}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
defaultName={defaultName}
|
||||
/>
|
||||
}
|
||||
depth={depth}
|
||||
hasChildren={false}
|
||||
expanded={false}
|
||||
onToggle={() => {}}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
isSelected={isSelected}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the area of a polygon using the shoelace formula
|
||||
*/
|
||||
function calculatePolygonArea(polygon: Array<[number, number]>): number {
|
||||
if (polygon.length < 3) return 0;
|
||||
|
||||
let area = 0;
|
||||
const n = polygon.length;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n;
|
||||
area += polygon[i]![0] * polygon[j]![1];
|
||||
area -= polygon[j]![0] * polygon[i]![1];
|
||||
}
|
||||
|
||||
return Math.abs(area) / 2;
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
import { emitter, useScene, type ZoneNode } from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import { Camera, Hexagon, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import useEditor from "@/store/use-editor";
|
||||
import { ColorDot } from "@/components/ui/primitives/color-dot";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/primitives/popover";
|
||||
|
||||
function ZoneItem({ zone }: { zone: ZoneNode }) {
|
||||
const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false);
|
||||
const deleteNode = useScene((state) => state.deleteNode);
|
||||
const updateNode = useScene((state) => state.updateNode);
|
||||
const selectedZoneId = useViewer((state) => state.selection.zoneId);
|
||||
const setSelection = useViewer((state) => state.setSelection);
|
||||
|
||||
const isSelected = selectedZoneId === zone.id;
|
||||
|
||||
const handleClick = () => {
|
||||
setSelection({ zoneId: zone.id });
|
||||
};
|
||||
|
||||
const handleDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
deleteNode(zone.id);
|
||||
if (isSelected) {
|
||||
setSelection({ zoneId: null });
|
||||
}
|
||||
};
|
||||
|
||||
const handleColorChange = (color: string) => {
|
||||
updateNode(zone.id, { color });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center h-8 cursor-pointer group/row text-sm px-2 mx-1 mb-0.5 select-none rounded-lg border transition-all duration-200",
|
||||
isSelected
|
||||
? "bg-white dark:bg-accent/50 border-neutral-200/60 dark:border-border/50 shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] ring-1 ring-white/50 dark:ring-white/10 ring-inset text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:bg-white/40 dark:hover:bg-accent/30 hover:border-neutral-200/50 dark:hover:border-border/40 hover:text-foreground"
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span className="mr-2">
|
||||
<ColorDot color={zone.color} onChange={handleColorChange} />
|
||||
</span>
|
||||
<Hexagon className="w-3.5 h-3.5 mr-1.5 shrink-0" />
|
||||
<span className="truncate flex-1">{zone.name}</span>
|
||||
{/* Camera snapshot button */}
|
||||
<Popover open={cameraPopoverOpen} onOpenChange={setCameraPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="relative opacity-0 group-hover/row:opacity-100 w-6 h-6 flex items-center justify-center rounded-md cursor-pointer hover:bg-black/5 dark:hover:bg-white/10 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="Camera snapshot"
|
||||
>
|
||||
<Camera className="w-3 h-3" />
|
||||
{zone.camera && (
|
||||
<span className="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="start"
|
||||
className="w-auto p-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{zone.camera && (
|
||||
<button
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
emitter.emit("camera-controls:view", { nodeId: zone.id });
|
||||
setCameraPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<Camera className="w-3.5 h-3.5" />
|
||||
View snapshot
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
emitter.emit("camera-controls:capture", { nodeId: zone.id });
|
||||
setCameraPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<Camera className="w-3.5 h-3.5" />
|
||||
{zone.camera ? "Update snapshot" : "Take snapshot"}
|
||||
</button>
|
||||
{zone.camera && (
|
||||
<button
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-destructive hover:text-destructive-foreground text-left w-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateNode(zone.id, { camera: undefined });
|
||||
setCameraPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
Clear snapshot
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<button
|
||||
className="opacity-0 group-hover/row:opacity-100 w-6 h-6 flex items-center justify-center rounded-md cursor-pointer hover:bg-black/5 dark:hover:bg-white/10 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ZonePanel() {
|
||||
const nodes = useScene((state) => state.nodes);
|
||||
const currentLevelId = useViewer((state) => state.selection.levelId);
|
||||
const setPhase = useEditor((state) => state.setPhase);
|
||||
const setMode = useEditor((state) => state.setMode);
|
||||
const setTool = useEditor((state) => state.setTool);
|
||||
|
||||
// Filter nodes to get zones for the current level
|
||||
const levelZones = Object.values(nodes).filter(
|
||||
(node): node is ZoneNode =>
|
||||
node.type === "zone" && node.parentId === currentLevelId
|
||||
);
|
||||
|
||||
const handleAddZone = () => {
|
||||
if (currentLevelId) {
|
||||
setPhase("structure");
|
||||
setMode("build");
|
||||
setTool("zone");
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentLevelId) {
|
||||
return (
|
||||
<div className="px-3 py-4 text-sm text-muted-foreground">
|
||||
Select a level to view and create zones
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-1">
|
||||
{levelZones.length === 0 ? (
|
||||
<div className="px-3 py-4 text-sm text-muted-foreground">
|
||||
No zones on this level.{" "}
|
||||
<button
|
||||
className="text-primary hover:underline cursor-pointer"
|
||||
onClick={handleAddZone}
|
||||
>
|
||||
Add one
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
levelZones.map((zone) => <ZoneItem key={zone.id} zone={zone} />)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
# Community Feature
|
||||
|
||||
This directory contains the **optional** community features (cloud synchronization and authentication) for the Pascal Editor. This feature is specific to the Pascal platform and can be safely removed if you're using the editor standalone.
|
||||
|
||||
## What This Does
|
||||
|
||||
The community feature provides:
|
||||
|
||||
- **Authentication** - Sign in with magic link via Better Auth
|
||||
- **Property Management** - Create and manage properties with Google Maps address search
|
||||
- **Scene Loading** - Automatically load property scenes from the database when a property is selected
|
||||
- **Auto-Save** - Automatically save scene changes to the database (2-second debounce)
|
||||
- **Database Sync** - Save and load editor state from a PostgreSQL database via Supabase
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
features/community/
|
||||
├── lib/
|
||||
│ ├── auth/
|
||||
│ │ ├── client.ts # Re-exports from @pascal-app/auth
|
||||
│ │ ├── server.ts # Server-side session handling
|
||||
│ │ └── hooks.ts # useAuth React hook
|
||||
│ ├── properties/
|
||||
│ │ ├── actions.ts # Server actions for CRUD operations
|
||||
│ │ ├── types.ts # TypeScript types for properties
|
||||
│ │ ├── hooks.ts # Property React hooks
|
||||
│ │ └── store.ts # Zustand store for property state
|
||||
│ ├── models/
|
||||
│ │ ├── actions.ts # Scene model CRUD operations
|
||||
│ │ └── hooks.ts # Scene loading and auto-save hooks
|
||||
│ ├── database/
|
||||
│ │ └── server.ts # Re-exports from @pascal-app/db
|
||||
│ └── utils/
|
||||
│ └── id-generator.ts # nanoid-based ID generation
|
||||
├── components/
|
||||
│ ├── cloud-save-button.tsx # Main UI entry point (top-right button)
|
||||
│ ├── sign-in-dialog.tsx # Magic link sign-in dialog
|
||||
│ ├── profile-dropdown.tsx # User profile menu
|
||||
│ ├── property-dropdown.tsx # Property selector dropdown
|
||||
│ ├── new-property-dialog.tsx # Create new property dialog
|
||||
│ └── google-address-search.tsx # Google Maps autocomplete
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Authentication Flow
|
||||
1. User clicks "Save to cloud" button
|
||||
2. Signs in with magic link (email-based, no password)
|
||||
3. Better Auth session is stored in cookies
|
||||
4. Server actions validate session using Better Auth API
|
||||
|
||||
### Property Management
|
||||
1. User creates a property with a real-world address (Google Maps)
|
||||
2. Address and property are saved to PostgreSQL via Supabase
|
||||
3. Properties are associated with the authenticated user
|
||||
4. User can switch between properties
|
||||
|
||||
### Scene Management
|
||||
1. When a property is selected, its scene is loaded from `properties_models` table
|
||||
2. If no scene exists, loads default empty scene
|
||||
3. Scene changes are auto-saved every 2 seconds (debounced)
|
||||
4. Updates existing model (highest version) instead of creating new ones
|
||||
5. Scene graph includes all nodes and hierarchy
|
||||
|
||||
### Database Integration
|
||||
- Uses Supabase (PostgreSQL) for database access
|
||||
- Better Auth manages authentication tables directly
|
||||
- Server actions use service role key to bypass RLS
|
||||
- Permissions enforced by filtering on `owner_id`
|
||||
- Tables: `users`, `sessions`, `properties`, `properties_addresses`, `properties_models`
|
||||
|
||||
## Required Environment Variables
|
||||
|
||||
```bash
|
||||
# Database Connection
|
||||
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
|
||||
|
||||
# Supabase Configuration
|
||||
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key_here
|
||||
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key_here
|
||||
|
||||
# Better Auth
|
||||
BETTER_AUTH_SECRET=<generate_with_openssl_rand_base64_32>
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
|
||||
# Google Maps API Key (for address search)
|
||||
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your_google_maps_key_here
|
||||
```
|
||||
|
||||
Generate `BETTER_AUTH_SECRET`:
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
The community feature requires these packages:
|
||||
|
||||
```json
|
||||
{
|
||||
"better-auth": "^1.4.18",
|
||||
"@supabase/supabase-js": "^2.95.3",
|
||||
"@react-google-maps/api": "^2.20.8",
|
||||
"nanoid": "^5.1.6"
|
||||
}
|
||||
```
|
||||
|
||||
## How to Remove (For Open Source Users)
|
||||
|
||||
If you want to use the editor without community features:
|
||||
|
||||
### 1. Delete this directory
|
||||
```bash
|
||||
rm -rf features/community
|
||||
```
|
||||
|
||||
### 2. Remove the CloudSaveButton from the editor
|
||||
Edit `components/editor/index.tsx`:
|
||||
```diff
|
||||
- import { CloudSaveButton } from '@/features/community/components/cloud-save-button'
|
||||
|
||||
export default function Editor() {
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<ActionMenu />
|
||||
<PanelManager />
|
||||
- <CloudSaveButton />
|
||||
```
|
||||
|
||||
### 3. Remove dependencies (optional)
|
||||
Edit `package.json`:
|
||||
```diff
|
||||
- "better-auth": "^1.4.18",
|
||||
- "@supabase/supabase-js": "^2.95.3",
|
||||
- "@react-google-maps/api": "^2.20.8",
|
||||
- "nanoid": "^5.1.6"
|
||||
```
|
||||
|
||||
### 4. Remove environment variables
|
||||
Delete from `.env.local` and `.env.example`:
|
||||
```diff
|
||||
- NEXT_PUBLIC_API_URL=...
|
||||
- NEXT_PUBLIC_SUPABASE_URL=...
|
||||
- NEXT_PUBLIC_SUPABASE_ANON_KEY=...
|
||||
- SUPABASE_SERVICE_ROLE_KEY=...
|
||||
- NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=...
|
||||
```
|
||||
|
||||
That's it! The editor will work as a standalone application without any cloud features.
|
||||
|
||||
## Backend Requirements
|
||||
|
||||
This feature requires:
|
||||
- Supabase local development instance
|
||||
- PostgreSQL database with the following tables:
|
||||
- `users` - User accounts (Better Auth)
|
||||
- `sessions` - Authentication sessions (Better Auth)
|
||||
- `verification_tokens` - Magic link tokens (Better Auth)
|
||||
- `properties` - Property records
|
||||
- `properties_addresses` - Property addresses
|
||||
- `properties_models` - Scene graph models
|
||||
- Database migrations are managed in `supabase/migrations/`
|
||||
|
||||
## Development
|
||||
|
||||
To work on this feature:
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
2. Start Supabase local development:
|
||||
```bash
|
||||
bun db:start
|
||||
```
|
||||
3. Run database migrations:
|
||||
```bash
|
||||
bun db:reset
|
||||
```
|
||||
4. Configure all environment variables in `apps/editor/.env.local`
|
||||
5. Run the editor: `bun dev`
|
||||
|
||||
The editor will be available at `http://localhost:3000`.
|
||||
|
||||
For detailed setup instructions, see [SETUP.md](../../../SETUP.md) in the root directory.
|
||||
|
||||
## Notes
|
||||
|
||||
- This feature uses **server actions** (Next.js App Router) for all database operations
|
||||
- Authentication is handled by **Better Auth** with magic link support
|
||||
- Better Auth server is configured in `packages/auth` and mounted at `/api/auth/*`
|
||||
- The editor queries the database directly using Supabase with service role key
|
||||
- IDs are generated using nanoid with custom alphabet
|
||||
- Scene state is managed with a **Zustand store** for reliable property switching
|
||||
- Scene changes are auto-saved with 2-second debouncing to the currently selected property
|
||||
@@ -1,57 +0,0 @@
|
||||
'use client'
|
||||
|
||||
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'
|
||||
|
||||
/**
|
||||
* CloudSaveButton - Shows authentication state and project management
|
||||
*
|
||||
* Guest: Shows "Home" button
|
||||
* Authenticated: Shows ProfileDropdown
|
||||
*/
|
||||
export function CloudSaveButton() {
|
||||
const { isAuthenticated, isLoading } = useAuth()
|
||||
const initialize = useProjectStore(state => state.initialize)
|
||||
const router = useRouter()
|
||||
|
||||
// Initialize project store when authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
initialize()
|
||||
}
|
||||
}, [isAuthenticated, initialize])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="pointer-events-auto">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 shadow-lg backdrop-blur-md">
|
||||
<div className="h-4 w-4 animate-pulse rounded-full bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
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={() => router.push('/')}
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
Home
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto">
|
||||
<ProfileDropdown />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAuth } from '../lib/auth/hooks'
|
||||
import { getPublicProjects, getUserProjects } from '../lib/projects/actions'
|
||||
import type { Project } from '../lib/projects/types'
|
||||
import { CreateProjectButton } from './create-project-button'
|
||||
import { HubFooter } from './hub-footer'
|
||||
import { NewProjectDialog } from './new-project-dialog'
|
||||
import { ProfileDropdown } from './profile-dropdown'
|
||||
import { ProjectGrid } from './project-grid'
|
||||
import { SignInDialog } from './sign-in-dialog'
|
||||
|
||||
export default function CommunityHub() {
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth()
|
||||
const router = useRouter()
|
||||
const [isSignInDialogOpen, setIsSignInDialogOpen] = useState(false)
|
||||
const [isNewProjectDialogOpen, setIsNewProjectDialogOpen] = useState(false)
|
||||
const [publicProjects, setPublicProjects] = useState<Project[]>([])
|
||||
const [userProjects, setUserProjects] = useState<Project[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function loadProjects() {
|
||||
setLoading(true)
|
||||
|
||||
const publicResult = await getPublicProjects()
|
||||
if (publicResult.success) {
|
||||
setPublicProjects(publicResult.data || [])
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
const userResult = await getUserProjects()
|
||||
if (userResult.success) {
|
||||
setUserProjects(userResult.data || [])
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
if (!authLoading) {
|
||||
loadProjects()
|
||||
}
|
||||
}, [isAuthenticated, authLoading])
|
||||
|
||||
const handleProjectCreated = (projectId: string) => {
|
||||
router.push(`/editor/${projectId}`)
|
||||
}
|
||||
|
||||
const handleProjectClick = (projectId: string) => {
|
||||
router.push(`/editor/${projectId}`)
|
||||
}
|
||||
|
||||
const handleViewProject = (projectId: string) => {
|
||||
router.push(`/viewer/${projectId}`)
|
||||
}
|
||||
|
||||
if (authLoading || loading) {
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center">
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="border-b border-border bg-background/95 backdrop-blur sticky top-0 z-10">
|
||||
<div className="container mx-auto px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Image
|
||||
src="/pascal-logo-shape.svg"
|
||||
alt="Pascal"
|
||||
width={64}
|
||||
height={64}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
<h1 className="text-2xl font-bold">Pascal Editor</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="https://github.com/pascalorg/editor"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-muted-foreground transition-colors hover:border-foreground/20 hover:text-foreground"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline text-sm font-medium">Open Source</span>
|
||||
</a>
|
||||
{!isAuthenticated ? (
|
||||
<button
|
||||
onClick={() => setIsSignInDialogOpen(true)}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
) : (
|
||||
<ProfileDropdown />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto px-6 py-8 space-y-12">
|
||||
{/* User's Projects Section */}
|
||||
{isAuthenticated && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold">My Projects</h2>
|
||||
<CreateProjectButton onCreateProject={() => setIsNewProjectDialogOpen(true)} />
|
||||
</div>
|
||||
{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}
|
||||
onProjectClick={handleProjectClick}
|
||||
onViewClick={handleViewProject}
|
||||
showOwner={false}
|
||||
canEdit
|
||||
onUpdate={() => {
|
||||
if (!authLoading) {
|
||||
getUserProjects().then((result) => {
|
||||
if (result.success) {
|
||||
setUserProjects(result.data || [])
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Public Projects Section */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-6">Community Projects</h2>
|
||||
{publicProjects.length > 0 ? (
|
||||
<ProjectGrid
|
||||
projects={publicProjects}
|
||||
onProjectClick={handleViewProject}
|
||||
showOwner
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">No public projects yet</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<HubFooter />
|
||||
|
||||
<SignInDialog open={isSignInDialogOpen} onOpenChange={setIsSignInDialogOpen} />
|
||||
<NewProjectDialog
|
||||
open={isNewProjectDialogOpen}
|
||||
onOpenChange={setIsNewProjectDialogOpen}
|
||||
onSuccess={handleProjectCreated}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Plus } from 'lucide-react'
|
||||
|
||||
interface CreateProjectButtonProps {
|
||||
onCreateProject: () => void
|
||||
}
|
||||
|
||||
export function CreateProjectButton({ onCreateProject }: CreateProjectButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onCreateProject}
|
||||
className="flex items-center gap-2 rounded-full bg-primary px-5 py-2 text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Create Project</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Autocomplete, LoadScript } from '@react-google-maps/api'
|
||||
import { MapPin } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
const libraries: ('places')[] = ['places']
|
||||
|
||||
interface AddressComponents {
|
||||
streetNumber?: string
|
||||
route?: string
|
||||
city?: string
|
||||
state?: string
|
||||
postalCode?: string
|
||||
country?: string
|
||||
center: [number, number]
|
||||
formattedAddress: string
|
||||
}
|
||||
|
||||
interface GoogleAddressSearchProps {
|
||||
onAddressSelect: (address: AddressComponents) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function GoogleAddressSearch({ onAddressSelect, disabled }: GoogleAddressSearchProps) {
|
||||
const [autocomplete, setAutocomplete] = useState<google.maps.places.Autocomplete | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
|
||||
|
||||
// Fix Google Maps autocomplete dropdown z-index and pointer events to work with dialog
|
||||
useEffect(() => {
|
||||
const style = document.createElement('style')
|
||||
style.textContent = `
|
||||
.pac-container {
|
||||
z-index: 9999 !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
return () => {
|
||||
document.head.removeChild(style)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!apiKey) {
|
||||
return (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
|
||||
<p className="font-medium">Google Maps API Key Missing</p>
|
||||
<p className="mt-1 text-xs">
|
||||
Add NEXT_PUBLIC_GOOGLE_MAPS_API_KEY to your .env.local file
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const onLoad = (autocompleteInstance: google.maps.places.Autocomplete) => {
|
||||
setAutocomplete(autocompleteInstance)
|
||||
}
|
||||
|
||||
const onPlaceChanged = () => {
|
||||
if (!autocomplete) return
|
||||
|
||||
const place = autocomplete.getPlace()
|
||||
if (!place.geometry?.location || !place.address_components) return
|
||||
|
||||
const components: AddressComponents = {
|
||||
center: [place.geometry.location.lng(), place.geometry.location.lat()],
|
||||
formattedAddress: place.formatted_address || '',
|
||||
}
|
||||
|
||||
// Parse address components
|
||||
for (const component of place.address_components) {
|
||||
const types = component.types
|
||||
|
||||
if (types.includes('street_number')) {
|
||||
components.streetNumber = component.long_name
|
||||
} else if (types.includes('route')) {
|
||||
components.route = component.long_name
|
||||
} else if (types.includes('locality')) {
|
||||
components.city = component.long_name
|
||||
} else if (types.includes('administrative_area_level_1')) {
|
||||
components.state = component.short_name
|
||||
} else if (types.includes('postal_code')) {
|
||||
components.postalCode = component.long_name
|
||||
} else if (types.includes('country')) {
|
||||
components.country = component.short_name
|
||||
}
|
||||
}
|
||||
|
||||
onAddressSelect(components)
|
||||
}
|
||||
|
||||
return (
|
||||
<LoadScript googleMapsApiKey={apiKey} libraries={libraries}>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 font-medium text-sm">
|
||||
<MapPin className="h-4 w-4" />
|
||||
Project Address
|
||||
</label>
|
||||
<Autocomplete onLoad={onLoad} onPlaceChanged={onPlaceChanged}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={disabled}
|
||||
placeholder="Search for an address..."
|
||||
type="text"
|
||||
/>
|
||||
</Autocomplete>
|
||||
</div>
|
||||
</LoadScript>
|
||||
)
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
function GitHubIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function NpmIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 256 256">
|
||||
<rect width="256" height="256" rx="0" fill="#C12127" />
|
||||
<polygon points="48,48 208,48 208,208 176,208 176,80 128,80 128,208 48,208" fill="#FFFFFF" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function HubFooter() {
|
||||
return (
|
||||
<footer className="border-t border-border mt-16">
|
||||
<div className="container mx-auto px-6 py-8">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Editor by{' '}
|
||||
<a
|
||||
href="https://pascal.app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
Pascal
|
||||
</a>
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<a
|
||||
href="https://github.com/pascalorg/editor"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors text-sm"
|
||||
>
|
||||
<GitHubIcon className="h-4 w-4" />
|
||||
GitHub
|
||||
</a>
|
||||
<a
|
||||
href="https://www.npmjs.com/package/@pascal-app/viewer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors text-sm"
|
||||
>
|
||||
<NpmIcon className="h-4 w-4" />
|
||||
Viewer
|
||||
</a>
|
||||
<a
|
||||
href="https://www.npmjs.com/package/@pascal-app/core"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors text-sm"
|
||||
>
|
||||
<NpmIcon className="h-4 w-4" />
|
||||
Core
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/primitives/dialog'
|
||||
import { Switch } from '@/components/ui/primitives/switch'
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import { createProject } from '../lib/projects/actions'
|
||||
|
||||
interface NewProjectDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess?: (projectId: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* NewProjectDialog - Dialog for creating a new project
|
||||
*/
|
||||
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)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
const name = projectName.trim() || 'Untitled Project'
|
||||
|
||||
setIsCreating(true)
|
||||
|
||||
try {
|
||||
// Get the default scene graph
|
||||
useScene.getState().clearScene()
|
||||
const { nodes, rootNodeIds } = useScene.getState()
|
||||
const sceneGraph = { nodes, rootNodeIds }
|
||||
|
||||
const result = await createProject({ name, isPrivate, sceneGraph })
|
||||
|
||||
if (result.success && result.data) {
|
||||
onOpenChange(false)
|
||||
setProjectName('')
|
||||
setIsPrivate(false)
|
||||
onSuccess?.(result.data.id)
|
||||
} else {
|
||||
setError(result.error || 'Failed to create project')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An unexpected error occurred')
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isCreating) {
|
||||
onOpenChange(false)
|
||||
setProjectName('')
|
||||
setIsPrivate(false)
|
||||
setError(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose} modal={false}>
|
||||
<DialogContent
|
||||
className="sm:max-w-125"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Project</DialogTitle>
|
||||
<button
|
||||
className="absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 disabled:pointer-events-none"
|
||||
disabled={isCreating}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</button>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
{/* Project Name */}
|
||||
<div>
|
||||
<label htmlFor="project-name" className="text-sm font-medium">
|
||||
Project Name
|
||||
</label>
|
||||
<input
|
||||
id="project-name"
|
||||
type="text"
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
placeholder="My Project"
|
||||
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={isCreating}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Privacy Toggle */}
|
||||
<div className="flex items-center justify-between rounded-md border border-border p-3">
|
||||
<div>
|
||||
<div className="font-medium text-sm">Privacy</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{isPrivate ? 'Only you can view this project' : 'Anyone can view this project'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Public</span>
|
||||
<Switch checked={!isPrivate} onCheckedChange={(checked) => setIsPrivate(!checked)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
className="rounded-md border border-input px-4 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
|
||||
disabled={isCreating}
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
disabled={isCreating}
|
||||
type="submit"
|
||||
>
|
||||
{isCreating ? 'Creating...' : 'Create Project'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useAuth } from '../lib/auth/hooks'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/primitives/dropdown-menu'
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* ProfileDropdown - User profile menu with avatar, settings, and sign out
|
||||
*/
|
||||
export function ProfileDropdown() {
|
||||
const { user, signOut } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut()
|
||||
}
|
||||
|
||||
const initials = user?.name ? getInitials(user.name) : user?.email?.[0]?.toUpperCase() || 'U'
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="flex h-9 w-9 items-center justify-center overflow-hidden rounded-full bg-muted font-medium text-xs shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_3px_rgba(0,0,0,0.1)] transition-opacity hover:opacity-80 focus:outline-none"
|
||||
type="button"
|
||||
>
|
||||
{user?.image ? (
|
||||
<Image
|
||||
src={user.image}
|
||||
alt={user.name || 'Profile'}
|
||||
width={36}
|
||||
height={36}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
initials
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<div className="flex items-center gap-3 px-2 py-2">
|
||||
{user?.image ? (
|
||||
<Image
|
||||
src={user.image}
|
||||
alt={user.name || 'Profile'}
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-8 w-8 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted font-medium text-xs">
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{user?.name && <div className="truncate font-medium text-sm">{user.name}</div>}
|
||||
{user?.email && (
|
||||
<div className="truncate text-muted-foreground text-xs">{user.email}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="cursor-pointer" onClick={() => router.push('/settings')}>
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="cursor-pointer" variant="destructive" onClick={handleSignOut}>
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Check, ChevronDown, Home, Plus } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/primitives/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useProjectStore } from '../lib/projects/store'
|
||||
import { NewProjectDialog } from './new-project-dialog'
|
||||
|
||||
/**
|
||||
* ProjectDropdown - Shows active project and allows switching between projects
|
||||
* Note: useProjectScene() is called in the Editor component, not here.
|
||||
* 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)
|
||||
const isLoading = useProjectStore((state) => state.isLoading)
|
||||
const setActiveProject = useProjectStore((state) => state.setActiveProject)
|
||||
const fetchProjects = useProjectStore((state) => state.fetchProjects)
|
||||
|
||||
const [isNewProjectDialogOpen, setIsNewProjectDialogOpen] = useState(false)
|
||||
|
||||
const handleProjectSelect = async (projectId: string) => {
|
||||
await setActiveProject(projectId)
|
||||
}
|
||||
|
||||
const handleAddNew = () => {
|
||||
setIsNewProjectDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleProjectCreated = (projectId: string) => {
|
||||
router.push(`/editor/${projectId}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="flex h-9 items-center gap-2 rounded-lg border border-border bg-background/95 px-3 text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50 focus:outline-none"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
<span className="max-w-[150px] truncate">
|
||||
{activeProject
|
||||
? activeProject.name
|
||||
: projects.length > 0
|
||||
? 'Select Project'
|
||||
: 'Add Project'}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[280px]">
|
||||
{/* Project list */}
|
||||
{projects.length > 0 ? (
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{projects.map((project) => (
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
'cursor-pointer text-sm',
|
||||
activeProject?.id === project.id && 'cursor-default bg-accent',
|
||||
)}
|
||||
key={project.id}
|
||||
onClick={() =>
|
||||
activeProject?.id === project.id ? null : handleProjectSelect(project.id)
|
||||
}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="flex-1 truncate font-medium">{project.name}</div>
|
||||
{activeProject?.id === project.id && (
|
||||
<Check className="h-4 w-4 shrink-0 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-2 py-3 text-center text-muted-foreground text-sm">
|
||||
No projects yet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add new project option */}
|
||||
<DropdownMenuItem className="cursor-pointer" onClick={handleAddNew}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
<span>Add new project</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<NewProjectDialog
|
||||
open={isNewProjectDialogOpen}
|
||||
onOpenChange={setIsNewProjectDialogOpen}
|
||||
onSuccess={handleProjectCreated}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Eye, Heart, Settings } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
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[]
|
||||
onProjectClick: (id: string) => void
|
||||
onViewClick?: (id: string) => void
|
||||
showOwner: boolean
|
||||
canEdit?: boolean
|
||||
onUpdate?: () => void
|
||||
}
|
||||
|
||||
export function ProjectGrid({
|
||||
projects,
|
||||
onProjectClick,
|
||||
onViewClick,
|
||||
showOwner,
|
||||
canEdit = false,
|
||||
onUpdate,
|
||||
}: ProjectGridProps) {
|
||||
const { isAuthenticated } = useAuth()
|
||||
const [settingsProject, setSettingsProject] = useState<Project | null>(null)
|
||||
const [userLikes, setUserLikes] = useState<Record<string, boolean>>({})
|
||||
const [likeCounts, setLikeCounts] = useState<Record<string, number>>({})
|
||||
|
||||
useEffect(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
projects.forEach((proj) => {
|
||||
counts[proj.id] = proj.likes
|
||||
})
|
||||
setLikeCounts(counts)
|
||||
}, [projects])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
setUserLikes({})
|
||||
return
|
||||
}
|
||||
|
||||
const projectIds = projects.map((p) => p.id)
|
||||
if (projectIds.length === 0) return
|
||||
|
||||
getUserProjectLikes(projectIds).then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setUserLikes(result.data)
|
||||
}
|
||||
})
|
||||
}, [projects, isAuthenticated])
|
||||
|
||||
const handleSettingsClick = (e: React.MouseEvent, project: Project) => {
|
||||
e.stopPropagation()
|
||||
setSettingsProject(project)
|
||||
}
|
||||
|
||||
const handleViewClick = (e: React.MouseEvent, projectId: string) => {
|
||||
e.stopPropagation()
|
||||
onViewClick?.(projectId)
|
||||
}
|
||||
|
||||
const handleLikeClick = async (e: React.MouseEvent, projectId: string) => {
|
||||
e.stopPropagation()
|
||||
|
||||
if (!isAuthenticated) return
|
||||
|
||||
const wasLiked = userLikes[projectId] || false
|
||||
const currentCount = likeCounts[projectId] || 0
|
||||
|
||||
setUserLikes((prev) => ({ ...prev, [projectId]: !wasLiked }))
|
||||
setLikeCounts((prev) => ({
|
||||
...prev,
|
||||
[projectId]: wasLiked ? currentCount - 1 : currentCount + 1,
|
||||
}))
|
||||
|
||||
const result = await toggleProjectLike(projectId)
|
||||
|
||||
if (result.success && result.data) {
|
||||
const data = result.data
|
||||
setUserLikes((prev) => ({ ...prev, [projectId]: data.liked }))
|
||||
setLikeCounts((prev) => ({ ...prev, [projectId]: data.likes }))
|
||||
} else {
|
||||
setUserLikes((prev) => ({ ...prev, [projectId]: wasLiked }))
|
||||
setLikeCounts((prev) => ({ ...prev, [projectId]: currentCount }))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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 = project.owner
|
||||
|
||||
return (
|
||||
<div
|
||||
key={project.id}
|
||||
onClick={() => onProjectClick(project.id)}
|
||||
className="group text-left cursor-pointer"
|
||||
>
|
||||
{/* 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)]">
|
||||
{project.thumbnail_url ? (
|
||||
<img
|
||||
src={project.thumbnail_url}
|
||||
alt={project.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
No preview
|
||||
</div>
|
||||
)}
|
||||
{canEdit && (
|
||||
<div className="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{onViewClick && (
|
||||
<button
|
||||
onClick={(e) => handleViewClick(e, project.id)}
|
||||
className="bg-background/80 hover:bg-background rounded-md p-1.5"
|
||||
aria-label="View"
|
||||
title="View in viewer mode"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => handleSettingsClick(e, project)}
|
||||
className="bg-background/80 hover:bg-background rounded-md p-1.5"
|
||||
aria-label="Settings"
|
||||
title="Project settings"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info row below the card */}
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
{showOwner && owner ? (
|
||||
<Link
|
||||
href={owner.username ? `/u/${owner.username}` : '#'}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="shrink-0"
|
||||
>
|
||||
{owner.image ? (
|
||||
<img
|
||||
src={owner.image}
|
||||
alt={owner.name}
|
||||
className="w-9 h-9 rounded-full object-cover shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_3px_rgba(0,0,0,0.1)]"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-full bg-neutral-100 flex items-center justify-center text-sm font-medium shadow-[0_0_0_1px_rgba(0,0,0,0.06)]">
|
||||
{owner.name?.[0]?.toUpperCase() || '?'}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
<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">
|
||||
{showOwner && owner && (
|
||||
<>
|
||||
<Link
|
||||
href={owner.username ? `/u/${owner.username}` : '#'}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="hover:text-foreground transition-colors truncate"
|
||||
>
|
||||
{owner.username || owner.name}
|
||||
</Link>
|
||||
<span className="shrink-0">·</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>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{settingsProject && (
|
||||
<ProjectSettingsDialog
|
||||
project={settingsProject}
|
||||
open={!!settingsProject}
|
||||
onOpenChange={(open) => !open && setSettingsProject(null)}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={() => {
|
||||
setSettingsProject(null)
|
||||
onUpdate?.()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/primitives/dialog'
|
||||
import { Switch } from '@/components/ui/primitives/switch'
|
||||
import { updateProjectName, updateProjectVisibility, deleteProject } from '../lib/projects/actions'
|
||||
import type { Project } from '../lib/projects/types'
|
||||
|
||||
interface ProjectSettingsDialogProps {
|
||||
project: Project
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onUpdate?: () => void
|
||||
onDelete?: () => void
|
||||
}
|
||||
|
||||
export function ProjectSettingsDialog({
|
||||
project,
|
||||
open,
|
||||
onOpenChange,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: ProjectSettingsDialogProps) {
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [name, setName] = useState(project.name || '')
|
||||
const [isPrivate, setIsPrivate] = useState(project.is_private)
|
||||
const [showScansPublic, setShowScansPublic] = useState(project.show_scans_public ?? true)
|
||||
const [showGuidesPublic, setShowGuidesPublic] = useState(project.show_guides_public ?? true)
|
||||
const nameTimerRef = useRef<ReturnType<typeof setTimeout>>(null)
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value)
|
||||
if (nameTimerRef.current) clearTimeout(nameTimerRef.current)
|
||||
nameTimerRef.current = setTimeout(async () => {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed && trimmed !== (project.name || '')) {
|
||||
await updateProjectName(project.id, trimmed)
|
||||
onUpdate?.()
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const handleVisibilityChange = async (
|
||||
field: 'isPrivate' | 'showScansPublic' | 'showGuidesPublic',
|
||||
value: boolean,
|
||||
) => {
|
||||
if (field === 'isPrivate') setIsPrivate(value)
|
||||
if (field === 'showScansPublic') setShowScansPublic(value)
|
||||
if (field === 'showGuidesPublic') setShowGuidesPublic(value)
|
||||
|
||||
await updateProjectVisibility(project.id, { [field]: value })
|
||||
onUpdate?.()
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Are you sure you want to delete this project? This action cannot be undone.')) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const result = await deleteProject(project.id)
|
||||
if (result.success) {
|
||||
onDelete?.()
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
alert(`Failed to delete project: ${result.error}`)
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Failed to delete project')
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Project Settings</DialogTitle>
|
||||
<DialogDescription>Changes are saved automatically</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Project Name */}
|
||||
<div>
|
||||
<label htmlFor="project-name" className="font-medium text-sm">
|
||||
Project Name
|
||||
</label>
|
||||
<input
|
||||
id="project-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="My Project"
|
||||
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Privacy Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">Privacy</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isPrivate ? 'Only you can view this project' : 'Anyone can view this project'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Public</span>
|
||||
<Switch checked={!isPrivate} onCheckedChange={(checked) => handleVisibilityChange('isPrivate', !checked)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Public Visibility Toggles */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">Show 3D Scans</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Visible to public viewers
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={showScansPublic} onCheckedChange={(checked) => handleVisibilityChange('showScansPublic', checked)} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">Show Floorplans</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Visible to public viewers
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={showGuidesPublic} onCheckedChange={(checked) => handleVisibilityChange('showGuidesPublic', checked)} />
|
||||
</div>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="border-t border-border pt-6">
|
||||
<h3 className="font-medium text-destructive mb-2">Danger Zone</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Once you delete a project, there is no going back. Please be certain.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
className="rounded-md border border-destructive bg-destructive/10 px-4 py-2 text-sm text-destructive hover:bg-destructive/20"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete Project'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { ProjectGrid } from './project-grid'
|
||||
import { HubFooter } from './hub-footer'
|
||||
import type { Project } from '../lib/projects/types'
|
||||
|
||||
function GitHubIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function XIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function YouTubeIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
interface PublicProfilePageProps {
|
||||
profile: {
|
||||
id: string
|
||||
name: string
|
||||
image: string | null
|
||||
username: string
|
||||
githubUrl: string | null
|
||||
xUrl: string | null
|
||||
youtubeUrl: string | null
|
||||
}
|
||||
projects: Project[]
|
||||
}
|
||||
|
||||
export function PublicProfilePage({ profile, projects }: PublicProfilePageProps) {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header — same layout as the community home */}
|
||||
<header className="border-b border-border bg-background/95 backdrop-blur sticky top-0 z-10">
|
||||
<div className="container mx-auto px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<Image
|
||||
src="/pascal-logo-shape.svg"
|
||||
alt="Pascal"
|
||||
width={64}
|
||||
height={64}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
<span className="text-2xl font-bold">Pascal Editor</span>
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/pascalorg/editor"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-muted-foreground transition-colors hover:border-foreground/20 hover:text-foreground"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline text-sm font-medium">Open Source</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto max-w-4xl px-6 py-8 space-y-8">
|
||||
{/* Profile Header */}
|
||||
<div className="flex items-center gap-6">
|
||||
{profile.image ? (
|
||||
<Image
|
||||
src={profile.image}
|
||||
alt={profile.name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="h-20 w-20 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted font-bold text-2xl">
|
||||
{profile.name[0]?.toUpperCase() || '?'}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold">{profile.name}</h1>
|
||||
<p className="text-muted-foreground">@{profile.username}</p>
|
||||
{(profile.githubUrl || profile.xUrl || profile.youtubeUrl) && (
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
{profile.githubUrl && (
|
||||
<a
|
||||
href={profile.githubUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<GitHubIcon className="h-5 w-5" />
|
||||
</a>
|
||||
)}
|
||||
{profile.xUrl && (
|
||||
<a
|
||||
href={profile.xUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<XIcon className="h-5 w-5" />
|
||||
</a>
|
||||
)}
|
||||
{profile.youtubeUrl && (
|
||||
<a
|
||||
href={profile.youtubeUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<YouTubeIcon className="h-5 w-5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Projects */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4">Public Projects</h2>
|
||||
{projects.length > 0 ? (
|
||||
<ProjectGrid
|
||||
projects={projects}
|
||||
onProjectClick={(id) => router.push(`/viewer/${id}`)}
|
||||
showOwner={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
No public projects yet
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<HubFooter />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,440 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft, Pencil } from 'lucide-react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { authClient } from '../lib/auth/client'
|
||||
import { updateUsername, updateProfile, uploadAvatar, updateEmailNotifications } from '../lib/auth/actions'
|
||||
|
||||
function GoogleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
interface SettingsPageProps {
|
||||
user: {
|
||||
id: string
|
||||
name?: string | null
|
||||
email?: string | null
|
||||
image?: string | null
|
||||
}
|
||||
currentUsername: string | null
|
||||
currentGithubUrl: string | null
|
||||
currentXUrl: string | null
|
||||
currentYoutubeUrl: string | null
|
||||
currentEmailNotifications: boolean
|
||||
connectedAccounts: { providerId: string; accountId: string }[]
|
||||
}
|
||||
|
||||
export function SettingsPage({
|
||||
user,
|
||||
currentUsername,
|
||||
currentGithubUrl,
|
||||
currentXUrl,
|
||||
currentYoutubeUrl,
|
||||
currentEmailNotifications,
|
||||
connectedAccounts,
|
||||
}: SettingsPageProps) {
|
||||
const [username, setUsername] = useState(currentUsername ?? '')
|
||||
const [githubUrl, setGithubUrl] = useState(currentGithubUrl ?? '')
|
||||
const [xUrl, setXUrl] = useState(currentXUrl ?? '')
|
||||
const [youtubeUrl, setYoutubeUrl] = useState(currentYoutubeUrl ?? '')
|
||||
const [avatarUrl, setAvatarUrl] = useState(user.image)
|
||||
const [isSavingUsername, setIsSavingUsername] = useState(false)
|
||||
const [isSavingSocial, setIsSavingSocial] = useState(false)
|
||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
|
||||
const [isConnectingGoogle, setIsConnectingGoogle] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [usernameMessage, setUsernameMessage] = useState<{
|
||||
type: 'success' | 'error'
|
||||
text: string
|
||||
} | null>(null)
|
||||
const [socialMessage, setSocialMessage] = useState<{
|
||||
type: 'success' | 'error'
|
||||
text: string
|
||||
} | null>(null)
|
||||
const [emailNotifications, setEmailNotifications] = useState(currentEmailNotifications)
|
||||
const [isSavingNotifications, setIsSavingNotifications] = useState(false)
|
||||
|
||||
const isGoogleConnected = connectedAccounts.some((a) => a.providerId === 'google')
|
||||
const initials = currentUsername
|
||||
? currentUsername.slice(0, 2).toUpperCase()
|
||||
: user.name
|
||||
? user.name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
: user.email?.[0]?.toUpperCase() || 'U'
|
||||
|
||||
const handleAvatarClick = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setIsUploadingAvatar(true)
|
||||
const formData = new FormData()
|
||||
formData.append('avatar', file)
|
||||
|
||||
const result = await uploadAvatar(formData)
|
||||
if (result.success && result.imageUrl) {
|
||||
setAvatarUrl(result.imageUrl)
|
||||
}
|
||||
setIsUploadingAvatar(false)
|
||||
// Reset input so the same file can be selected again
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
|
||||
const handleConnectGoogle = async () => {
|
||||
setIsConnectingGoogle(true)
|
||||
try {
|
||||
await authClient.signIn.social({
|
||||
provider: 'google',
|
||||
callbackURL: '/settings',
|
||||
})
|
||||
} catch {
|
||||
setIsConnectingGoogle(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveUsername = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setUsernameMessage(null)
|
||||
setIsSavingUsername(true)
|
||||
|
||||
const result = await updateUsername(username)
|
||||
setUsernameMessage({
|
||||
type: result.success ? 'success' : 'error',
|
||||
text: result.success ? 'Username updated successfully' : (result.error ?? 'Failed'),
|
||||
})
|
||||
setIsSavingUsername(false)
|
||||
}
|
||||
|
||||
const handleSaveSocial = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSocialMessage(null)
|
||||
setIsSavingSocial(true)
|
||||
|
||||
const result = await updateProfile({
|
||||
githubUrl: githubUrl.trim() || null,
|
||||
xUrl: xUrl.trim() || null,
|
||||
youtubeUrl: youtubeUrl.trim() || null,
|
||||
})
|
||||
setSocialMessage({
|
||||
type: result.success ? 'success' : 'error',
|
||||
text: result.success
|
||||
? 'Social links updated successfully'
|
||||
: (result.error ?? 'Failed'),
|
||||
})
|
||||
setIsSavingSocial(false)
|
||||
}
|
||||
|
||||
const usernameChanged = username.trim() !== (currentUsername ?? '')
|
||||
const socialChanged =
|
||||
(githubUrl.trim() || '') !== (currentGithubUrl ?? '') ||
|
||||
(xUrl.trim() || '') !== (currentXUrl ?? '') ||
|
||||
(youtubeUrl.trim() || '') !== (currentYoutubeUrl ?? '')
|
||||
|
||||
const handleToggleEmailNotifications = async () => {
|
||||
const newValue = !emailNotifications
|
||||
setEmailNotifications(newValue)
|
||||
setIsSavingNotifications(true)
|
||||
await updateEmailNotifications(newValue)
|
||||
setIsSavingNotifications(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="border-b border-border bg-background/95 backdrop-blur sticky top-0 z-10">
|
||||
<div className="container mx-auto px-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-1 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="text-sm">Back</span>
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">Settings</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto max-w-2xl px-6 py-8 space-y-8">
|
||||
{/* Profile Section */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Profile</h2>
|
||||
<div className="rounded-lg border border-border p-6 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Avatar with upload */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAvatarClick}
|
||||
disabled={isUploadingAvatar}
|
||||
className="relative group shrink-0"
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt={user.name || 'Profile'}
|
||||
width={64}
|
||||
height={64}
|
||||
className="h-16 w-16 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted font-semibold text-lg">
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Pencil className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
{isUploadingAvatar && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<div>
|
||||
{user.name && <div className="font-medium">{user.name}</div>}
|
||||
{user.email && (
|
||||
<div className="text-muted-foreground text-sm">{user.email}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSaveUsername} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="username" className="font-medium text-sm">
|
||||
Public Username
|
||||
</label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Your public display name on the community hub.
|
||||
</p>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value)
|
||||
setUsernameMessage(null)
|
||||
}}
|
||||
placeholder="your-username"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isSavingUsername}
|
||||
minLength={3}
|
||||
maxLength={30}
|
||||
pattern="[a-zA-Z0-9_-]+"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
3-30 characters. Letters, numbers, hyphens, and underscores only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{usernameMessage && (
|
||||
<div
|
||||
className={`rounded-md border p-3 text-sm ${
|
||||
usernameMessage.type === 'success'
|
||||
? 'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900/20 dark:text-green-400'
|
||||
: 'border-destructive/50 bg-destructive/10 text-destructive'
|
||||
}`}
|
||||
>
|
||||
{usernameMessage.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSavingUsername || !usernameChanged || !username.trim()}
|
||||
className="rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{isSavingUsername ? 'Saving...' : 'Save Username'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Connected Accounts Section */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Connected Accounts</h2>
|
||||
<div className="rounded-lg border border-border p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<GoogleIcon className="h-5 w-5" />
|
||||
<div>
|
||||
<div className="text-sm font-medium">Google</div>
|
||||
{isGoogleConnected ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Connected
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Not connected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isGoogleConnected ? (
|
||||
<span className="text-xs text-green-600 dark:text-green-400 font-medium px-2 py-1 rounded-full bg-green-50 dark:bg-green-900/20">
|
||||
Connected
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConnectGoogle}
|
||||
disabled={isConnectingGoogle}
|
||||
className="rounded-md border border-input px-3 py-1.5 text-sm transition-colors hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
{isConnectingGoogle ? 'Connecting...' : 'Connect'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Notifications Section */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Notifications</h2>
|
||||
<div className="rounded-lg border border-border p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">Email notifications</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Receive emails about new features and updates.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={emailNotifications}
|
||||
onClick={handleToggleEmailNotifications}
|
||||
disabled={isSavingNotifications}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
emailNotifications ? 'bg-primary' : 'bg-input'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform ${
|
||||
emailNotifications ? 'translate-x-5' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Social Links Section */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Social Links</h2>
|
||||
<div className="rounded-lg border border-border p-6">
|
||||
<form onSubmit={handleSaveSocial} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="github" className="font-medium text-sm">
|
||||
GitHub
|
||||
</label>
|
||||
<input
|
||||
id="github"
|
||||
type="url"
|
||||
value={githubUrl}
|
||||
onChange={(e) => {
|
||||
setGithubUrl(e.target.value)
|
||||
setSocialMessage(null)
|
||||
}}
|
||||
placeholder="https://github.com/yourusername"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isSavingSocial}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="x" className="font-medium text-sm">
|
||||
X (Twitter)
|
||||
</label>
|
||||
<input
|
||||
id="x"
|
||||
type="url"
|
||||
value={xUrl}
|
||||
onChange={(e) => {
|
||||
setXUrl(e.target.value)
|
||||
setSocialMessage(null)
|
||||
}}
|
||||
placeholder="https://x.com/yourusername"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isSavingSocial}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="youtube" className="font-medium text-sm">
|
||||
YouTube
|
||||
</label>
|
||||
<input
|
||||
id="youtube"
|
||||
type="url"
|
||||
value={youtubeUrl}
|
||||
onChange={(e) => {
|
||||
setYoutubeUrl(e.target.value)
|
||||
setSocialMessage(null)
|
||||
}}
|
||||
placeholder="https://youtube.com/@yourchannel"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isSavingSocial}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{socialMessage && (
|
||||
<div
|
||||
className={`rounded-md border p-3 text-sm ${
|
||||
socialMessage.type === 'success'
|
||||
? 'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900/20 dark:text-green-400'
|
||||
: 'border-destructive/50 bg-destructive/10 text-destructive'
|
||||
}`}
|
||||
>
|
||||
{socialMessage.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSavingSocial || !socialChanged}
|
||||
className="rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{isSavingSocial ? 'Saving...' : 'Save Social Links'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Mail, X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { authClient } from '../lib/auth/client'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/primitives/dialog'
|
||||
|
||||
interface SignInDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
function GoogleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* SignInDialog - Authentication dialog with Google OAuth and magic link
|
||||
*/
|
||||
const LOGIN_METHOD_LABELS: Record<string, string> = {
|
||||
google: 'Google',
|
||||
'magic-link': 'email link',
|
||||
}
|
||||
|
||||
export function SignInDialog({ open, onOpenChange }: SignInDialogProps) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const lastMethod = authClient.getLastUsedLoginMethod?.()
|
||||
const lastMethodLabel = lastMethod ? LOGIN_METHOD_LABELS[lastMethod] ?? lastMethod : null
|
||||
|
||||
const handleGoogleSignIn = async () => {
|
||||
setError(null)
|
||||
setIsGoogleLoading(true)
|
||||
try {
|
||||
await authClient.signIn.social({
|
||||
provider: 'google',
|
||||
callbackURL: window.location.origin,
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to sign in with Google')
|
||||
setIsGoogleLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const result = await authClient.signIn.magicLink({
|
||||
email,
|
||||
callbackURL: window.location.origin,
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message || 'Failed to send magic link')
|
||||
} else {
|
||||
setSuccess(true)
|
||||
setEmail('')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An unexpected error occurred')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isLoading && !isGoogleLoading) {
|
||||
onOpenChange(false)
|
||||
setTimeout(() => {
|
||||
setEmail('')
|
||||
setError(null)
|
||||
setSuccess(false)
|
||||
}, 200)
|
||||
}
|
||||
}
|
||||
|
||||
const anyLoading = isLoading || isGoogleLoading
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sign in to Pascal</DialogTitle>
|
||||
<button
|
||||
className="absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 disabled:pointer-events-none"
|
||||
disabled={anyLoading}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</button>
|
||||
</DialogHeader>
|
||||
|
||||
{success ? (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/20">
|
||||
<Mail className="h-6 w-6 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-lg">Check your email</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
We've sent a magic link to <strong>{email}</strong>
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Click the link in the email to sign in to your account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="w-full rounded-md border border-input px-4 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{lastMethodLabel && (
|
||||
<p className="text-center text-muted-foreground text-xs">
|
||||
Last signed in with {lastMethodLabel}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Google Sign-In */}
|
||||
<button
|
||||
className="flex w-full items-center justify-center gap-2 rounded-md border border-input bg-background px-4 py-2.5 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
|
||||
disabled={anyLoading}
|
||||
onClick={handleGoogleSignIn}
|
||||
type="button"
|
||||
>
|
||||
{isGoogleLoading ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-foreground border-t-transparent" />
|
||||
) : (
|
||||
<GoogleIcon className="h-4 w-4" />
|
||||
)}
|
||||
Continue with Google
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t border-border" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Magic Link Form */}
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-sm" htmlFor="email">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
autoComplete="email"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={anyLoading}
|
||||
id="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="flex w-full items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
disabled={anyLoading || !email}
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent" />
|
||||
Sending magic link...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mail className="h-4 w-4" />
|
||||
Send magic link
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-muted-foreground text-xs">
|
||||
By signing in, you agree to our{' '}
|
||||
<Link href="/terms" className="underline hover:text-foreground">
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link href="/privacy" className="underline hover:text-foreground">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAuth } from '../lib/auth/hooks'
|
||||
import { getUsername } from '../lib/auth/actions'
|
||||
import { UsernameOnboardingDialog } from './username-onboarding-dialog'
|
||||
|
||||
export function UsernameGate({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth()
|
||||
const [needsUsername, setNeedsUsername] = useState(false)
|
||||
const [checking, setChecking] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return
|
||||
if (!isAuthenticated) {
|
||||
setChecking(false)
|
||||
setNeedsUsername(false)
|
||||
return
|
||||
}
|
||||
getUsername()
|
||||
.then((username) => {
|
||||
setNeedsUsername(!username)
|
||||
setChecking(false)
|
||||
})
|
||||
.catch(() => {
|
||||
setNeedsUsername(false)
|
||||
setChecking(false)
|
||||
})
|
||||
}, [isAuthenticated, isLoading])
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<UsernameOnboardingDialog
|
||||
open={needsUsername && !checking}
|
||||
onComplete={() => setNeedsUsername(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/primitives/dialog'
|
||||
import { updateUsername, checkUsernameAvailability } from '../lib/auth/actions'
|
||||
|
||||
interface UsernameOnboardingDialogProps {
|
||||
open: boolean
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
export function UsernameOnboardingDialog({ open, onComplete }: UsernameOnboardingDialogProps) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [availability, setAvailability] = useState<'idle' | 'checking' | 'available' | 'taken'>(
|
||||
'idle',
|
||||
)
|
||||
|
||||
const validate = (value: string): string | null => {
|
||||
if (value.length < 3) return 'Must be at least 3 characters'
|
||||
if (value.length > 30) return 'Must be at most 30 characters'
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(value))
|
||||
return 'Only letters, numbers, hyphens, and underscores'
|
||||
return null
|
||||
}
|
||||
|
||||
const checkAvailability = useCallback(async (value: string) => {
|
||||
const validationError = validate(value)
|
||||
if (validationError) {
|
||||
setAvailability('idle')
|
||||
return
|
||||
}
|
||||
setAvailability('checking')
|
||||
const result = await checkUsernameAvailability(value)
|
||||
setAvailability(result.available ? 'available' : 'taken')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!username.trim()) {
|
||||
setAvailability('idle')
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => checkAvailability(username.trim()), 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [username, checkAvailability])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const trimmed = username.trim()
|
||||
const validationError = validate(trimmed)
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
return
|
||||
}
|
||||
|
||||
setError(null)
|
||||
setIsSaving(true)
|
||||
|
||||
const result = await updateUsername(trimmed)
|
||||
if (result.success) {
|
||||
onComplete()
|
||||
} else {
|
||||
setError(result.error ?? 'Failed to set username')
|
||||
}
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
const validationError = username.trim() ? validate(username.trim()) : null
|
||||
const canSubmit = !isSaving && !validationError && availability === 'available'
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={() => {}}>
|
||||
<DialogContent className="sm:max-w-[420px] [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Choose your username</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Pick a public username for the community hub. This will be visible on projects you share.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm">
|
||||
@
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value)
|
||||
setError(null)
|
||||
}}
|
||||
placeholder="your-username"
|
||||
className="w-full rounded-md border border-input bg-background pl-7 pr-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isSaving}
|
||||
autoFocus
|
||||
minLength={3}
|
||||
maxLength={30}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status indicators */}
|
||||
{username.trim() && !validationError && (
|
||||
<div className="text-xs">
|
||||
{availability === 'checking' && (
|
||||
<span className="text-muted-foreground">Checking availability...</span>
|
||||
)}
|
||||
{availability === 'available' && (
|
||||
<span className="text-green-600 dark:text-green-400">Username is available</span>
|
||||
)}
|
||||
{availability === 'taken' && (
|
||||
<span className="text-destructive">Username is already taken</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{validationError && (
|
||||
<p className="text-destructive text-xs">{validationError}</p>
|
||||
)}
|
||||
{!username.trim() && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
3-30 characters. Letters, numbers, hyphens, and underscores only.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Setting username...' : 'Continue'}
|
||||
</button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
'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 CreateUploadUrlResult =
|
||||
| { success: true; signedUrl: string; storageKey: string; assetId: string }
|
||||
| { success: false; error: string }
|
||||
|
||||
export type ConfirmUploadResult =
|
||||
| { success: true; url: string }
|
||||
| { success: false; error: string }
|
||||
|
||||
export type DeleteAssetResult =
|
||||
| { 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 as any).from('project_assets').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',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a signed upload URL so the client can upload directly to Supabase Storage.
|
||||
* Bypasses Next.js body-size limits — supports files up to the bucket limit (500 MB).
|
||||
*/
|
||||
export async function createAssetUploadUrl(
|
||||
projectId: string,
|
||||
fileName: string,
|
||||
contentType: string,
|
||||
type: AssetType,
|
||||
): Promise<CreateUploadUrlResult> {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Not authenticated' }
|
||||
}
|
||||
|
||||
const supabase = await createServerSupabaseClient()
|
||||
|
||||
const { data: project, error: projectError } = await supabase
|
||||
.from('projects')
|
||||
.select('owner_id')
|
||||
.eq('id', projectId)
|
||||
.single()
|
||||
|
||||
if (projectError || !project) {
|
||||
return { success: false, error: 'Project not found' }
|
||||
}
|
||||
|
||||
if ((project as any).owner_id !== session.user.id) {
|
||||
return { success: false, error: 'Not authorized to upload to this project' }
|
||||
}
|
||||
|
||||
const ext = fileName.includes('.') ? fileName.split('.').pop()! : ''
|
||||
const assetId = createId('asset')
|
||||
const storageKey = ext ? `${projectId}/${assetId}.${ext}` : `${projectId}/${assetId}`
|
||||
|
||||
const { data, error } = await supabase.storage
|
||||
.from(BUCKET)
|
||||
.createSignedUploadUrl(storageKey)
|
||||
|
||||
if (error || !data) {
|
||||
return { success: false, error: `Failed to create upload URL: ${error?.message}` }
|
||||
}
|
||||
|
||||
return { success: true, signedUrl: data.signedUrl, storageKey, assetId }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create upload URL',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a successfully uploaded asset in the project_assets table.
|
||||
* Called after the client uploads the file directly to Supabase Storage.
|
||||
*/
|
||||
export async function confirmAssetUpload(
|
||||
projectId: string,
|
||||
assetId: string,
|
||||
storageKey: string,
|
||||
originalName: string,
|
||||
mimeType: string | null,
|
||||
type: AssetType,
|
||||
): Promise<ConfirmUploadResult> {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Not authenticated' }
|
||||
}
|
||||
|
||||
const supabase = await createServerSupabaseClient()
|
||||
|
||||
const { data: project, error: projectError } = await supabase
|
||||
.from('projects')
|
||||
.select('owner_id')
|
||||
.eq('id', projectId)
|
||||
.single()
|
||||
|
||||
if (projectError || !project) {
|
||||
return { success: false, error: 'Project not found' }
|
||||
}
|
||||
|
||||
if ((project as any).owner_id !== session.user.id) {
|
||||
return { success: false, error: 'Not authorized' }
|
||||
}
|
||||
|
||||
const { data: urlData } = supabase.storage
|
||||
.from(BUCKET)
|
||||
.getPublicUrl(storageKey)
|
||||
|
||||
const url = urlData.publicUrl
|
||||
|
||||
const { error: insertError } = await (supabase as any).from('project_assets').insert({
|
||||
id: assetId,
|
||||
project_id: projectId,
|
||||
storage_key: storageKey,
|
||||
url,
|
||||
type,
|
||||
original_name: originalName,
|
||||
mime_type: mimeType,
|
||||
})
|
||||
|
||||
if (insertError) {
|
||||
await supabase.storage.from(BUCKET).remove([storageKey])
|
||||
return { success: false, error: `Failed to record asset: ${insertError.message}` }
|
||||
}
|
||||
|
||||
return { success: true, url }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to confirm upload',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a project asset by its public URL.
|
||||
* 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 as any).from('project_assets')
|
||||
.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,311 +0,0 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { db, schema } from '@pascal-app/db'
|
||||
import { eq, and, ne, sql } from 'drizzle-orm'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { getSession } from './server'
|
||||
import { createServerSupabaseClient } from '../database/server'
|
||||
|
||||
/**
|
||||
* Sign in with a social provider (Google)
|
||||
*/
|
||||
export async function signInSocial(provider: 'google', callbackURL?: string) {
|
||||
const result = await auth.api.signInSocial({
|
||||
body: { provider, callbackURL: callbackURL ?? '/' },
|
||||
})
|
||||
revalidatePath('/')
|
||||
if (result.url && result.redirect) {
|
||||
redirect(result.url as '/')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current user's public username
|
||||
*/
|
||||
export async function updateUsername(
|
||||
username: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const session = await getSession()
|
||||
if (!session?.user) {
|
||||
return { success: false, error: 'Not authenticated' }
|
||||
}
|
||||
|
||||
// Validate username format
|
||||
const trimmed = username.trim()
|
||||
if (trimmed.length < 3) {
|
||||
return { success: false, error: 'Username must be at least 3 characters' }
|
||||
}
|
||||
if (trimmed.length > 30) {
|
||||
return { success: false, error: 'Username must be at most 30 characters' }
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Username can only contain letters, numbers, hyphens, and underscores',
|
||||
}
|
||||
}
|
||||
|
||||
// Check if username is already taken (case-insensitive)
|
||||
const existing = await db
|
||||
.select({ id: schema.users.id })
|
||||
.from(schema.users)
|
||||
.where(
|
||||
and(
|
||||
sql`lower(${schema.users.username}) = lower(${trimmed})`,
|
||||
ne(schema.users.id, session.user.id),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existing.length > 0) {
|
||||
return { success: false, error: 'Username is already taken' }
|
||||
}
|
||||
|
||||
const updated = await db
|
||||
.update(schema.users)
|
||||
.set({ username: trimmed })
|
||||
.where(eq(schema.users.id, session.user.id))
|
||||
.returning({ id: schema.users.id })
|
||||
|
||||
if (updated.length === 0) {
|
||||
return { success: false, error: 'User not found. Please sign out and sign in again.' }
|
||||
}
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user's username
|
||||
*/
|
||||
export async function getUsername(): Promise<string | null> {
|
||||
const session = await getSession()
|
||||
if (!session?.user) return null
|
||||
|
||||
const result = await db
|
||||
.select({ username: schema.users.username })
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.id, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
return result[0]?.username ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a username is available
|
||||
*/
|
||||
export async function checkUsernameAvailability(
|
||||
username: string,
|
||||
): Promise<{ available: boolean }> {
|
||||
const trimmed = username.trim()
|
||||
if (trimmed.length < 3 || !/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
||||
return { available: false }
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select({ id: schema.users.id })
|
||||
.from(schema.users)
|
||||
.where(sql`lower(${schema.users.username}) = lower(${trimmed})`)
|
||||
.limit(1)
|
||||
|
||||
return { available: existing.length === 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user's full profile
|
||||
*/
|
||||
export async function getUserProfile(): Promise<{
|
||||
username: string | null
|
||||
githubUrl: string | null
|
||||
xUrl: string | null
|
||||
youtubeUrl: string | null
|
||||
emailNotifications: boolean
|
||||
} | null> {
|
||||
const session = await getSession()
|
||||
if (!session?.user) return null
|
||||
|
||||
const result = await db
|
||||
.select({
|
||||
username: schema.users.username,
|
||||
githubUrl: schema.users.githubUrl,
|
||||
xUrl: schema.users.xUrl,
|
||||
youtubeUrl: schema.users.youtubeUrl,
|
||||
emailNotifications: schema.users.emailNotifications,
|
||||
})
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.id, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
return result[0] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current user's social profile links
|
||||
*/
|
||||
export async function updateProfile(data: {
|
||||
githubUrl?: string | null
|
||||
xUrl?: string | null
|
||||
youtubeUrl?: string | null
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const session = await getSession()
|
||||
if (!session?.user) {
|
||||
return { success: false, error: 'Not authenticated' }
|
||||
}
|
||||
|
||||
if (data.githubUrl && !/^https:\/\/(www\.)?github\.com\/.+/.test(data.githubUrl)) {
|
||||
return { success: false, error: 'Invalid GitHub URL' }
|
||||
}
|
||||
if (data.xUrl && !/^https:\/\/(www\.)?(x|twitter)\.com\/.+/.test(data.xUrl)) {
|
||||
return { success: false, error: 'Invalid X/Twitter URL' }
|
||||
}
|
||||
if (data.youtubeUrl && !/^https:\/\/(www\.)?(youtube\.com|youtu\.be)\/.+/.test(data.youtubeUrl)) {
|
||||
return { success: false, error: 'Invalid YouTube URL' }
|
||||
}
|
||||
|
||||
await db
|
||||
.update(schema.users)
|
||||
.set({
|
||||
githubUrl: data.githubUrl ?? null,
|
||||
xUrl: data.xUrl ?? null,
|
||||
youtubeUrl: data.youtubeUrl ?? null,
|
||||
})
|
||||
.where(eq(schema.users.id, session.user.id))
|
||||
|
||||
revalidatePath('/settings')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user's public profile by username
|
||||
*/
|
||||
export async function getPublicProfile(username: string): Promise<{
|
||||
success: boolean
|
||||
data?: {
|
||||
id: string
|
||||
name: string
|
||||
image: string | null
|
||||
username: string
|
||||
githubUrl: string | null
|
||||
xUrl: string | null
|
||||
youtubeUrl: string | null
|
||||
}
|
||||
error?: string
|
||||
}> {
|
||||
const result = await db
|
||||
.select({
|
||||
id: schema.users.id,
|
||||
name: schema.users.name,
|
||||
image: schema.users.image,
|
||||
username: schema.users.username,
|
||||
githubUrl: schema.users.githubUrl,
|
||||
xUrl: schema.users.xUrl,
|
||||
youtubeUrl: schema.users.youtubeUrl,
|
||||
})
|
||||
.from(schema.users)
|
||||
.where(sql`lower(${schema.users.username}) = lower(${username})`)
|
||||
.limit(1)
|
||||
|
||||
const user = result[0]
|
||||
if (!user || !user.username) {
|
||||
return { success: false, error: 'User not found' }
|
||||
}
|
||||
|
||||
return { success: true, data: user as typeof user & { username: string } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connected accounts for the current user
|
||||
*/
|
||||
export async function getConnectedAccounts(): Promise<
|
||||
{ providerId: string; accountId: string }[]
|
||||
> {
|
||||
const session = await getSession()
|
||||
if (!session?.user) return []
|
||||
|
||||
const result = await db
|
||||
.select({
|
||||
providerId: schema.accounts.providerId,
|
||||
accountId: schema.accounts.accountId,
|
||||
})
|
||||
.from(schema.accounts)
|
||||
.where(eq(schema.accounts.userId, session.user.id))
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload avatar image to Supabase Storage and update user record
|
||||
*/
|
||||
export async function uploadAvatar(
|
||||
formData: FormData,
|
||||
): Promise<{ success: boolean; imageUrl?: string; error?: string }> {
|
||||
const session = await getSession()
|
||||
if (!session?.user) {
|
||||
return { success: false, error: 'Not authenticated' }
|
||||
}
|
||||
|
||||
const file = formData.get('avatar') as File | null
|
||||
if (!file) {
|
||||
return { success: false, error: 'No file provided' }
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
return { success: false, error: 'File too large (max 5MB)' }
|
||||
}
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
return { success: false, error: 'File must be an image' }
|
||||
}
|
||||
|
||||
const supabase = await createServerSupabaseClient()
|
||||
const ext = file.name.split('.').pop() || 'png'
|
||||
const filename = `${session.user.id}/avatar.${ext}`
|
||||
|
||||
const { data: uploadData, error: uploadError } = await supabase.storage
|
||||
.from('avatars')
|
||||
.upload(filename, file, {
|
||||
contentType: file.type,
|
||||
upsert: true,
|
||||
})
|
||||
|
||||
if (uploadError) {
|
||||
return { success: false, error: `Upload failed: ${uploadError.message}` }
|
||||
}
|
||||
|
||||
const { data: urlData } = supabase.storage.from('avatars').getPublicUrl(uploadData.path)
|
||||
const imageUrl = `${urlData.publicUrl}?t=${Date.now()}`
|
||||
|
||||
// Update user image in database
|
||||
await db
|
||||
.update(schema.users)
|
||||
.set({ image: imageUrl })
|
||||
.where(eq(schema.users.id, session.user.id))
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings')
|
||||
return { success: true, imageUrl }
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current user's email notification preference
|
||||
*/
|
||||
export async function updateEmailNotifications(
|
||||
enabled: boolean,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const session = await getSession()
|
||||
if (!session?.user) {
|
||||
return { success: false, error: 'Not authenticated' }
|
||||
}
|
||||
|
||||
await db
|
||||
.update(schema.users)
|
||||
.set({ emailNotifications: enabled })
|
||||
.where(eq(schema.users.id, session.user.id))
|
||||
|
||||
revalidatePath('/settings')
|
||||
return { success: true }
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* Auth client for the editor using better-auth
|
||||
* Re-exports from @pascal-app/auth package
|
||||
*/
|
||||
|
||||
export { authClient } from '@pascal-app/auth/client'
|
||||
export type { AuthState, User, Session } from '@pascal-app/auth/client'
|
||||
@@ -1,20 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { authClient } from './client'
|
||||
|
||||
/**
|
||||
* Hook to access authentication state using better-auth
|
||||
* @returns Current auth state including user, session, and loading status
|
||||
*/
|
||||
export function useAuth() {
|
||||
const session = authClient.useSession()
|
||||
|
||||
return {
|
||||
user: session.data?.user ?? null,
|
||||
session: session.data?.session ?? null,
|
||||
isAuthenticated: !!session.data?.user && !!session.data?.session,
|
||||
isLoading: session.isPending,
|
||||
signOut: () => authClient.signOut(),
|
||||
signIn: authClient.signIn,
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { headers as nextHeaders } from 'next/headers'
|
||||
import { BASE_URL } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Get the current session from Better Auth backend (server-side)
|
||||
*/
|
||||
export async function getSession() {
|
||||
try {
|
||||
const headersList = await nextHeaders()
|
||||
|
||||
// Make authenticated request to the auth backend to get session
|
||||
const response = await fetch(`${BASE_URL}/api/auth/get-session`, {
|
||||
headers: {
|
||||
cookie: headersList.get('cookie') || '',
|
||||
},
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Better Auth returns the session data directly
|
||||
if (data?.user && data?.session) {
|
||||
return {
|
||||
user: data.user,
|
||||
session: data.session,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error('Failed to get session:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user from the session
|
||||
*/
|
||||
export async function getUser() {
|
||||
const session = await getSession()
|
||||
return session?.user ?? null
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
/**
|
||||
* Supabase server client for database access
|
||||
*/
|
||||
|
||||
import { supabaseAdmin } from '@/lib/supabase/server'
|
||||
|
||||
/**
|
||||
* Create a Supabase client for server-side use with service role key
|
||||
* This bypasses RLS and allows server actions to query the database directly
|
||||
* Authentication is handled by Better Auth, permissions enforced by filtering on user_id
|
||||
*/
|
||||
export async function createServerSupabaseClient() {
|
||||
return supabaseAdmin
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
'use server'
|
||||
|
||||
import { createId } from '@pascal-app/db'
|
||||
import { createServerSupabaseClient } from '../database/server'
|
||||
import { getSession } from '../auth/server'
|
||||
|
||||
const MAX_IMAGES = 5
|
||||
|
||||
/**
|
||||
* Create signed upload URLs so the client can upload images directly to
|
||||
* Supabase Storage — bypasses Vercel's 4.5 MB serverless body-size limit.
|
||||
*/
|
||||
export async function createImageUploadUrls(
|
||||
files: { name: string; type: string }[],
|
||||
): Promise<
|
||||
| { success: true; uploads: { path: string; signedUrl: string }[] }
|
||||
| { success: false; error: string }
|
||||
> {
|
||||
try {
|
||||
if (files.length > MAX_IMAGES) {
|
||||
return { success: false, error: `Maximum ${MAX_IMAGES} images allowed` }
|
||||
}
|
||||
|
||||
const supabase = await createServerSupabaseClient()
|
||||
const uploads: { path: string; signedUrl: string }[] = []
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.type.startsWith('image/')) continue
|
||||
|
||||
const ext = file.name.split('.').pop() || 'jpg'
|
||||
const path = `${createId('img')}.${ext}`
|
||||
|
||||
const { data, error } = await (
|
||||
supabase as ReturnType<typeof import('@supabase/supabase-js').createClient>
|
||||
).storage
|
||||
.from('feedback-images')
|
||||
.createSignedUploadUrl(path)
|
||||
|
||||
if (error || !data) {
|
||||
console.error(`Failed to create signed URL for ${file.name}:`, error)
|
||||
continue
|
||||
}
|
||||
|
||||
uploads.push({ path, signedUrl: data.signedUrl })
|
||||
}
|
||||
|
||||
return { success: true, uploads }
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : 'Failed to create upload URLs',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit feedback with pre-uploaded image paths.
|
||||
* Images are already in Supabase Storage — this just records the metadata.
|
||||
*/
|
||||
export async function submitFeedback(data: {
|
||||
message: string
|
||||
projectId?: string | null
|
||||
sceneGraph?: unknown
|
||||
imagePaths?: string[]
|
||||
}): Promise<{ success: true } | { success: false; error: string }> {
|
||||
try {
|
||||
const { message, projectId, sceneGraph, imagePaths } = data
|
||||
if (!message?.trim()) return { success: false, error: 'Message cannot be empty' }
|
||||
|
||||
const session = await getSession()
|
||||
const supabase = await createServerSupabaseClient()
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { error } = await (supabase as any).from('feedback').insert({
|
||||
id: createId('feedback'),
|
||||
user_id: session?.user?.id ?? null,
|
||||
project_id: projectId ?? null,
|
||||
message: message.trim(),
|
||||
images: imagePaths && imagePaths.length > 0 ? imagePaths : null,
|
||||
scene_graph: sceneGraph ?? null,
|
||||
})
|
||||
|
||||
if (error) return { success: false, error: error.message }
|
||||
|
||||
return { success: true }
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : 'Failed to submit feedback',
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,308 +0,0 @@
|
||||
/**
|
||||
* Hooks for project model (scene) loading and auto-saving
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import useEditor from '@/store/use-editor'
|
||||
import { useProjectStore } from '../projects/store'
|
||||
import { getProjectModel, saveProjectModel, type SceneGraph } from './actions'
|
||||
|
||||
/** Debounce interval for cloud auto-save (ms). */
|
||||
const AUTOSAVE_DEBOUNCE_MS = 1_000
|
||||
|
||||
function syncEditorSelectionFromCurrentScene() {
|
||||
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')
|
||||
useEditor.getState().setStructureLayer('elements')
|
||||
|
||||
// Auto-select the wall tool if the level is empty (e.g., brand new project)
|
||||
if (!firstLevel.children || firstLevel.children.length === 0) {
|
||||
useEditor.getState().setMode('build')
|
||||
useEditor.getState().setTool('wall')
|
||||
}
|
||||
} else {
|
||||
useEditor.getState().setPhase('site')
|
||||
useViewer.getState().setSelection({
|
||||
buildingId: null,
|
||||
levelId: null,
|
||||
selectedIds: [],
|
||||
zoneId: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) {
|
||||
if (sceneGraph?.nodes && sceneGraph.rootNodeIds) {
|
||||
const { nodes, rootNodeIds } = sceneGraph
|
||||
useScene.getState().setScene(nodes, rootNodeIds)
|
||||
} else {
|
||||
useScene.getState().clearScene()
|
||||
}
|
||||
|
||||
syncEditorSelectionFromCurrentScene()
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the scene when a project becomes active.
|
||||
* Saves changes automatically with debouncing.
|
||||
*
|
||||
* ⚠️ This hook must be mounted in exactly ONE component (the Editor).
|
||||
* Mounting it in multiple components causes duplicate save calls.
|
||||
*/
|
||||
export function useProjectScene() {
|
||||
// Subscribe to project store
|
||||
const activeProject = useProjectStore((state) => state.activeProject)
|
||||
const isLoadingProject = useProjectStore((state) => state.isLoading)
|
||||
const isVersionPreviewMode = useProjectStore((state) => state.isVersionPreviewMode)
|
||||
const setAutosaveStatus = useProjectStore((state) => state.setAutosaveStatus)
|
||||
|
||||
const lastProjectIdRef = useRef<string | null>(null)
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
|
||||
const isSavingRef = useRef(false)
|
||||
const currentProjectIdRef = useRef<string | null>(null)
|
||||
// Track whether the scene was just loaded from the server so we can skip
|
||||
// the first store update (which is the load itself, not a user edit).
|
||||
const isLoadingSceneRef = useRef(false)
|
||||
// Track whether there are pending changes that arrived while a save was
|
||||
// in-flight so we can coalesce them into one follow-up save.
|
||||
const pendingSaveRef = useRef(false)
|
||||
const executeSaveRef = useRef<(() => Promise<void>) | null>(null)
|
||||
|
||||
// Extract project ID for dependency tracking
|
||||
const projectId = activeProject?.id ?? null
|
||||
|
||||
// Load scene when active project changes
|
||||
useEffect(() => {
|
||||
if (isLoadingProject) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
useProjectStore.getState().setIsVersionPreviewMode(false)
|
||||
setAutosaveStatus('idle')
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if same project
|
||||
if (lastProjectIdRef.current === projectId) {
|
||||
return
|
||||
}
|
||||
|
||||
lastProjectIdRef.current = projectId
|
||||
|
||||
// Load the project's scene
|
||||
async function loadScene() {
|
||||
// Suppress auto-save for the store update caused by setScene/clearScene
|
||||
isLoadingSceneRef.current = true
|
||||
useProjectStore.getState().setIsVersionPreviewMode(false)
|
||||
setAutosaveStatus('idle')
|
||||
|
||||
useProjectStore.getState().setIsSceneLoading(true)
|
||||
|
||||
try {
|
||||
const result = await getProjectModel(projectId || '')
|
||||
|
||||
applySceneGraphToEditor(result.success ? result.data?.model?.scene_graph ?? null : null)
|
||||
} catch (error) {
|
||||
// Fall back to an empty scene while preserving editor selection sync.
|
||||
applySceneGraphToEditor(null)
|
||||
} finally {
|
||||
useProjectStore.getState().setIsSceneLoading(false)
|
||||
}
|
||||
|
||||
// Allow auto-save again after a tick (let the store update propagate)
|
||||
requestAnimationFrame(() => {
|
||||
isLoadingSceneRef.current = false
|
||||
setAutosaveStatus('saved')
|
||||
})
|
||||
}
|
||||
|
||||
loadScene()
|
||||
}, [projectId, isLoadingProject, setAutosaveStatus])
|
||||
|
||||
// Track whether there are unsaved changes (dirty flag for flush-on-exit).
|
||||
const hasDirtyChangesRef = useRef(false)
|
||||
|
||||
// Auto-save scene changes with debouncing
|
||||
useEffect(() => {
|
||||
if (!projectId) {
|
||||
currentProjectIdRef.current = null
|
||||
executeSaveRef.current = null
|
||||
setAutosaveStatus('idle')
|
||||
return
|
||||
}
|
||||
|
||||
currentProjectIdRef.current = projectId
|
||||
|
||||
// Use JSON stringification to detect node changes, not just count
|
||||
let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes)
|
||||
|
||||
const unsubscribe = useScene.subscribe((state) => {
|
||||
// Skip saves triggered by loading a scene from the server
|
||||
if (isLoadingSceneRef.current) {
|
||||
// Update the snapshot so the next real edit is compared correctly
|
||||
lastNodesSnapshot = JSON.stringify(state.nodes)
|
||||
return
|
||||
}
|
||||
|
||||
if (useProjectStore.getState().isVersionPreviewMode) {
|
||||
// Do not autosave preview scenes. Keep snapshot aligned so returning to
|
||||
// latest does not schedule a false-positive save.
|
||||
setAutosaveStatus('paused')
|
||||
lastNodesSnapshot = JSON.stringify(state.nodes)
|
||||
return
|
||||
}
|
||||
|
||||
const currentNodesSnapshot = JSON.stringify(state.nodes)
|
||||
|
||||
// Only trigger save if nodes actually changed
|
||||
if (currentNodesSnapshot === lastNodesSnapshot) {
|
||||
return
|
||||
}
|
||||
|
||||
lastNodesSnapshot = currentNodesSnapshot
|
||||
hasDirtyChangesRef.current = true
|
||||
setAutosaveStatus('pending')
|
||||
|
||||
// If a save is in-flight, mark pending so we do one follow-up save
|
||||
// instead of queuing unlimited concurrent saves.
|
||||
if (isSavingRef.current) {
|
||||
pendingSaveRef.current = true
|
||||
return
|
||||
}
|
||||
|
||||
// Clear existing timeout (debounce reset)
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current)
|
||||
}
|
||||
|
||||
// Debounce save
|
||||
saveTimeoutRef.current = setTimeout(() => {
|
||||
saveTimeoutRef.current = undefined
|
||||
executeSave()
|
||||
}, AUTOSAVE_DEBOUNCE_MS)
|
||||
})
|
||||
|
||||
async function executeSave() {
|
||||
const currentProjectId = currentProjectIdRef.current
|
||||
if (!currentProjectId) return
|
||||
|
||||
if (isLoadingSceneRef.current || useProjectStore.getState().isVersionPreviewMode) {
|
||||
// Save is paused while previewing older versions.
|
||||
pendingSaveRef.current = true
|
||||
setAutosaveStatus('paused')
|
||||
return
|
||||
}
|
||||
|
||||
const { nodes, rootNodeIds } = useScene.getState()
|
||||
const sceneGraph = { nodes, rootNodeIds }
|
||||
|
||||
isSavingRef.current = true
|
||||
pendingSaveRef.current = false
|
||||
setAutosaveStatus('saving')
|
||||
|
||||
try {
|
||||
await saveProjectModel(currentProjectId, sceneGraph)
|
||||
hasDirtyChangesRef.current = false
|
||||
setAutosaveStatus('saved')
|
||||
} finally {
|
||||
isSavingRef.current = false
|
||||
|
||||
// If changes arrived while we were saving, schedule one more save
|
||||
if (pendingSaveRef.current) {
|
||||
pendingSaveRef.current = false
|
||||
setAutosaveStatus('pending')
|
||||
saveTimeoutRef.current = setTimeout(() => {
|
||||
saveTimeoutRef.current = undefined
|
||||
executeSave()
|
||||
}, AUTOSAVE_DEBOUNCE_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
executeSaveRef.current = executeSave
|
||||
|
||||
// Flush unsaved changes when the user leaves the page / closes the tab.
|
||||
// Uses sendBeacon via keepalive fetch so the request survives page unload.
|
||||
function flushOnExit() {
|
||||
if (!hasDirtyChangesRef.current || !currentProjectIdRef.current) return
|
||||
|
||||
const { nodes, rootNodeIds } = useScene.getState()
|
||||
const sceneGraph = { nodes, rootNodeIds }
|
||||
|
||||
// Best-effort fire-and-forget save. We use the server action directly
|
||||
// (it's just a POST to a Next.js endpoint). If the browser kills it,
|
||||
// localStorage still has the data and will sync on next load.
|
||||
saveProjectModel(currentProjectIdRef.current, sceneGraph).catch(() => {
|
||||
// Swallow — nothing we can do during unload
|
||||
})
|
||||
hasDirtyChangesRef.current = false
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', flushOnExit)
|
||||
|
||||
return () => {
|
||||
executeSaveRef.current = null
|
||||
window.removeEventListener('beforeunload', flushOnExit)
|
||||
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current)
|
||||
}
|
||||
|
||||
// Flush on unmount (e.g. navigating away within the SPA)
|
||||
flushOnExit()
|
||||
|
||||
unsubscribe()
|
||||
}
|
||||
}, [projectId, setAutosaveStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return
|
||||
|
||||
if (isVersionPreviewMode) {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current)
|
||||
saveTimeoutRef.current = undefined
|
||||
}
|
||||
if (hasDirtyChangesRef.current) {
|
||||
pendingSaveRef.current = true
|
||||
}
|
||||
setAutosaveStatus('paused')
|
||||
return
|
||||
}
|
||||
|
||||
if (isSavingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (hasDirtyChangesRef.current) {
|
||||
setAutosaveStatus('pending')
|
||||
if (!saveTimeoutRef.current) {
|
||||
saveTimeoutRef.current = setTimeout(() => {
|
||||
saveTimeoutRef.current = undefined
|
||||
executeSaveRef.current?.()
|
||||
}, AUTOSAVE_DEBOUNCE_MS)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setAutosaveStatus('saved')
|
||||
}, [isVersionPreviewMode, projectId, setAutosaveStatus])
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { SceneGraph } from './actions'
|
||||
|
||||
const DEFAULT_NODE_TYPES = ['site', 'building', 'level']
|
||||
|
||||
export function isSceneGraphEmpty(sceneGraph: SceneGraph | any): boolean {
|
||||
if (!sceneGraph?.nodes) return true
|
||||
|
||||
const nodes = Object.values(sceneGraph.nodes) as any[]
|
||||
|
||||
if (nodes.length > 3) {
|
||||
return false
|
||||
}
|
||||
|
||||
const hasNonDefaultNodes = nodes.some((n) => !DEFAULT_NODE_TYPES.includes(n.type))
|
||||
if (hasNonDefaultNodes) {
|
||||
return false
|
||||
}
|
||||
|
||||
const levelNode = nodes.find((n) => n.type === 'level')
|
||||
if (Array.isArray(levelNode?.children) && levelNode.children.length > 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,122 +0,0 @@
|
||||
/**
|
||||
* Project store - Zustand store for project state management
|
||||
*/
|
||||
|
||||
import { create } from 'zustand'
|
||||
import type { Project } from './types'
|
||||
import {
|
||||
getActiveProject,
|
||||
getUserProjects,
|
||||
getProjectById,
|
||||
} from './actions'
|
||||
|
||||
interface ProjectStore {
|
||||
// Autosave lifecycle for the latest draft scene
|
||||
autosaveStatus: 'idle' | 'pending' | 'saving' | 'saved' | 'paused'
|
||||
|
||||
// State
|
||||
activeProject: Project | null
|
||||
projects: Project[]
|
||||
isLoading: boolean
|
||||
isSceneLoading: boolean
|
||||
isVersionPreviewMode: boolean
|
||||
error: string | null
|
||||
|
||||
// Actions
|
||||
fetchProjects: () => Promise<void>
|
||||
fetchActiveProject: () => Promise<void>
|
||||
setActiveProject: (projectId: string) => Promise<void>
|
||||
setIsSceneLoading: (loading: boolean) => void
|
||||
setIsVersionPreviewMode: (preview: boolean) => void
|
||||
setAutosaveStatus: (status: ProjectStore['autosaveStatus']) => void
|
||||
initialize: () => Promise<void>
|
||||
updateActiveThumbnail: (thumbnailUrl: string) => void
|
||||
}
|
||||
|
||||
export const useProjectStore = create<ProjectStore>((set, get) => ({
|
||||
// Initial state
|
||||
autosaveStatus: 'idle',
|
||||
activeProject: null,
|
||||
projects: [],
|
||||
isLoading: true,
|
||||
isSceneLoading: false,
|
||||
isVersionPreviewMode: false,
|
||||
error: null,
|
||||
|
||||
// Fetch all projects
|
||||
fetchProjects: async () => {
|
||||
const result = await getUserProjects()
|
||||
|
||||
if (result.success) {
|
||||
set({ projects: result.data || [], error: null })
|
||||
} else {
|
||||
set({ error: result.error || 'Failed to fetch projects', projects: [] })
|
||||
}
|
||||
},
|
||||
|
||||
// Fetch the active project from database
|
||||
fetchActiveProject: async () => {
|
||||
set({ isLoading: true })
|
||||
|
||||
const result = await getActiveProject()
|
||||
|
||||
if (result.success) {
|
||||
set({
|
||||
activeProject: result.data || null,
|
||||
isLoading: false,
|
||||
error: null
|
||||
})
|
||||
// Note: Auto-select logic removed - now using URL-based routing
|
||||
// The URL parameter determines which project to load
|
||||
} else {
|
||||
set({
|
||||
error: result.error || 'Failed to fetch active project',
|
||||
activeProject: null,
|
||||
isLoading: false
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// Set active project by fetching it directly by ID (URL-based, no session update)
|
||||
setActiveProject: async (projectId: string) => {
|
||||
set({ isLoading: true })
|
||||
|
||||
const result = await getProjectById(projectId)
|
||||
|
||||
if (result.success && result.data) {
|
||||
set({ activeProject: result.data, isLoading: false, error: null })
|
||||
} else {
|
||||
set({ isLoading: false, error: result.error || 'Project not found' })
|
||||
}
|
||||
},
|
||||
|
||||
setIsSceneLoading: (loading: boolean) => {
|
||||
set({ isSceneLoading: loading })
|
||||
},
|
||||
|
||||
setIsVersionPreviewMode: (preview: boolean) => {
|
||||
set({ isVersionPreviewMode: preview })
|
||||
},
|
||||
|
||||
setAutosaveStatus: (status) => {
|
||||
set({ autosaveStatus: status })
|
||||
},
|
||||
|
||||
// Patch the active project's thumbnail URL in place (no refetch)
|
||||
updateActiveThumbnail: (thumbnailUrl: string) => {
|
||||
set((state) => ({
|
||||
activeProject: state.activeProject
|
||||
? { ...state.activeProject, thumbnail_url: thumbnailUrl }
|
||||
: null,
|
||||
}))
|
||||
},
|
||||
|
||||
// Initialize - fetch both projects and active project
|
||||
initialize: async () => {
|
||||
set({ isLoading: true })
|
||||
await Promise.all([
|
||||
get().fetchProjects(),
|
||||
get().fetchActiveProject(),
|
||||
])
|
||||
},
|
||||
}))
|
||||
@@ -1,150 +0,0 @@
|
||||
/**
|
||||
* Project-related type definitions
|
||||
* Isolated from monorepo database schema
|
||||
*/
|
||||
|
||||
// Database table row types
|
||||
export type DbProject = {
|
||||
id: string
|
||||
name: string
|
||||
owner_id: string
|
||||
organization_id: string | null
|
||||
address_id: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
is_private: boolean
|
||||
is_empty: boolean
|
||||
show_scans_public: boolean
|
||||
show_guides_public: boolean
|
||||
views: number
|
||||
likes: number
|
||||
thumbnail_url: string | null
|
||||
published_model_version: number | null
|
||||
}
|
||||
|
||||
export type DbProjectAddress = {
|
||||
id: string
|
||||
street_number?: string
|
||||
route?: string
|
||||
city?: string
|
||||
state?: string
|
||||
postal_code?: string
|
||||
country?: string
|
||||
latitude?: string
|
||||
longitude?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type DbProjectModel = {
|
||||
id: string
|
||||
project_id: string
|
||||
version: number
|
||||
scene_graph: any
|
||||
created_at: string
|
||||
updated_at: string
|
||||
deleted_at: string | null
|
||||
}
|
||||
|
||||
export type DbProjectLike = {
|
||||
id: string
|
||||
project_id: string
|
||||
user_id: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// Database schema type for Supabase
|
||||
export type Database = {
|
||||
public: {
|
||||
Tables: {
|
||||
projects: {
|
||||
Row: DbProject
|
||||
Insert: Omit<DbProject, 'created_at' | 'updated_at' | 'views' | 'likes' | 'show_scans_public' | 'show_guides_public' | 'published_model_version'> & { show_scans_public?: boolean; show_guides_public?: boolean; published_model_version?: number | null }
|
||||
Update: Partial<Omit<DbProject, 'id' | 'created_at' | 'updated_at'>>
|
||||
}
|
||||
projects_addresses: {
|
||||
Row: DbProjectAddress
|
||||
Insert: Omit<DbProjectAddress, 'created_at' | 'updated_at'>
|
||||
Update: Partial<Omit<DbProjectAddress, 'id' | 'created_at' | 'updated_at'>>
|
||||
}
|
||||
projects_models: {
|
||||
Row: DbProjectModel
|
||||
Insert: Omit<DbProjectModel, 'created_at' | 'updated_at' | 'deleted_at'>
|
||||
Update: Partial<Omit<DbProjectModel, 'id' | 'created_at' | 'updated_at'>>
|
||||
}
|
||||
projects_likes: {
|
||||
Row: DbProjectLike
|
||||
Insert: Omit<DbProjectLike, 'created_at'>
|
||||
Update: Partial<Omit<DbProjectLike, 'id' | 'created_at'>>
|
||||
}
|
||||
}
|
||||
Functions: {
|
||||
increment_project_views: {
|
||||
Args: { project_id: string }
|
||||
Returns: undefined
|
||||
}
|
||||
get_project_like_count: {
|
||||
Args: { project_id: string }
|
||||
Returns: number
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type ProjectOwner = {
|
||||
id: string
|
||||
name: string
|
||||
username: string | null
|
||||
image: string | null
|
||||
}
|
||||
|
||||
export type Project = {
|
||||
id: string
|
||||
name: string
|
||||
owner_id: string
|
||||
organization_id: string | null
|
||||
address_id: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
// Community features
|
||||
is_private: boolean
|
||||
is_empty: boolean
|
||||
show_scans_public: boolean
|
||||
show_guides_public: boolean
|
||||
views: number
|
||||
likes: number
|
||||
thumbnail_url: string | null
|
||||
published_model_version: number | null
|
||||
address: {
|
||||
id: string
|
||||
street_number?: string
|
||||
route?: string
|
||||
city?: string
|
||||
state?: string
|
||||
postal_code?: string
|
||||
country?: string
|
||||
latitude?: string
|
||||
longitude?: string
|
||||
} | null
|
||||
owner?: ProjectOwner | null
|
||||
}
|
||||
|
||||
export type CreateProjectParams = {
|
||||
name: string
|
||||
center?: [number, number]
|
||||
streetNumber?: string
|
||||
route?: string
|
||||
routeShort?: string
|
||||
neighborhood?: string
|
||||
city?: string
|
||||
county?: string
|
||||
state?: string
|
||||
stateLong?: string
|
||||
postalCode?: string
|
||||
postalCodeSuffix?: string
|
||||
country?: string
|
||||
countryLong?: string
|
||||
rawJson?: Record<string, unknown>
|
||||
isPrivate?: boolean
|
||||
sceneGraph?: any
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { customAlphabet } from 'nanoid'
|
||||
|
||||
const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
||||
const nanoid = customAlphabet(alphabet, 16)
|
||||
|
||||
/**
|
||||
* Generate a unique ID with optional prefix (matches monorepo implementation)
|
||||
* @example createId('user') => 'user_Abc123...'
|
||||
*/
|
||||
export const createId = (prefix?: string) => {
|
||||
const id = nanoid()
|
||||
return prefix ? `${prefix}_${id}` : id
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
export async function register() {
|
||||
// Only run on the server
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
const { createClient } = await import('@supabase/supabase-js')
|
||||
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const key = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
if (!url || !key) return
|
||||
|
||||
const supabase = createClient(url, key)
|
||||
|
||||
const { data: buckets } = await supabase.storage.listBuckets()
|
||||
const bucketNames = new Set(buckets?.map((b) => b.name))
|
||||
|
||||
if (!bucketNames.has('avatars')) {
|
||||
await supabase.storage.createBucket('avatars', {
|
||||
public: true,
|
||||
fileSizeLimit: 5 * 1024 * 1024, // 5MB
|
||||
allowedMimeTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
|
||||
})
|
||||
console.log('Created "avatars" storage bucket')
|
||||
}
|
||||
|
||||
if (!bucketNames.has('project-thumbnails')) {
|
||||
await supabase.storage.createBucket('project-thumbnails', {
|
||||
public: true,
|
||||
fileSizeLimit: 10 * 1024 * 1024, // 10MB (matches uploadProjectThumbnail validation)
|
||||
allowedMimeTypes: ['image/png'],
|
||||
})
|
||||
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')
|
||||
}
|
||||
|
||||
if (!bucketNames.has('preset-thumbnails')) {
|
||||
await supabase.storage.createBucket('preset-thumbnails', {
|
||||
public: true,
|
||||
fileSizeLimit: 5 * 1024 * 1024, // 5MB
|
||||
allowedMimeTypes: ['image/png', 'image/jpeg', 'image/webp'],
|
||||
})
|
||||
console.log('Created "preset-thumbnails" storage bucket')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { createAuth } from '@pascal-app/auth/server'
|
||||
import { db } from '@pascal-app/db'
|
||||
import { Resend } from 'resend'
|
||||
import { env } from '@/env.mjs'
|
||||
import { BASE_URL } from './utils'
|
||||
|
||||
// Initialize Resend only if API key is available
|
||||
const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null
|
||||
|
||||
export const auth = createAuth({
|
||||
db,
|
||||
appName: 'Pascal Editor',
|
||||
baseURL: BASE_URL,
|
||||
secret: env.BETTER_AUTH_SECRET,
|
||||
googleClientId: env.GOOGLE_CLIENT_ID,
|
||||
googleClientSecret: env.GOOGLE_CLIENT_SECRET,
|
||||
sendMagicLink: async ({ email, url }) => {
|
||||
if (!resend) {
|
||||
console.log(`[DEV] Magic link for ${email}: ${url}`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await resend.emails.send({
|
||||
from: 'Pascal <noreply@pascal.app>',
|
||||
to: email,
|
||||
subject: 'Sign in to Pascal Editor',
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2>Sign in to Pascal Editor</h2>
|
||||
<p>Click the button below to sign in to your account:</p>
|
||||
<a href="${url}" style="display: inline-block; background-color: #000; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 6px; margin: 20px 0;">
|
||||
Sign In
|
||||
</a>
|
||||
<p style="color: #666; font-size: 14px;">This link will expire in 5 minutes.</p>
|
||||
<p style="color: #666; font-size: 14px;">If you didn't request this email, you can safely ignore it.</p>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
console.log(`✓ Magic link email sent to ${email}`)
|
||||
} catch (error) {
|
||||
console.error('Failed to send magic link email:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export type Session = typeof auth.$Infer.Session
|
||||
export type User = typeof auth.$Infer.Session.user
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Navigation helpers for project-based routing
|
||||
*/
|
||||
|
||||
export function getEditorUrl(projectId: string): string {
|
||||
return `/editor/${projectId}`
|
||||
}
|
||||
|
||||
export function getViewerUrl(projectId: string): string {
|
||||
return `/viewer/${projectId}`
|
||||
}
|
||||
|
||||
export function getHomeUrl(): string {
|
||||
return '/'
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import type { SupabaseDatabase } from '@pascal-app/db'
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
|
||||
/**
|
||||
* Supabase client for client-side use with anon key
|
||||
* Uses Row Level Security (RLS) policies
|
||||
*/
|
||||
export const supabase = createClient<SupabaseDatabase>(supabaseUrl, supabaseAnonKey)
|
||||
@@ -1,39 +0,0 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import type { SupabaseDatabase } from '@pascal-app/db'
|
||||
import { env } from '@/env.mjs'
|
||||
|
||||
/**
|
||||
* Safety check: warn loudly if a Vercel preview deployment is using
|
||||
* the production Supabase instance. This catches misconfigured branching.
|
||||
*/
|
||||
if (
|
||||
process.env.VERCEL_ENV === 'preview' &&
|
||||
process.env.SUPABASE_URL &&
|
||||
env.NEXT_PUBLIC_SUPABASE_URL === process.env.SUPABASE_URL
|
||||
) {
|
||||
// If the Supabase integration set a branch-specific SUPABASE_URL,
|
||||
// it should differ from NEXT_PUBLIC_SUPABASE_URL (which comes from the
|
||||
// generic env vars pointing at production). When they match, the
|
||||
// integration likely skipped branch creation for this PR.
|
||||
console.warn(
|
||||
'⚠️ [supabase] Preview deployment appears to be using the PRODUCTION ' +
|
||||
'Supabase instance. Supabase branching may not be configured for this PR. ' +
|
||||
'See: https://supabase.com/docs/guides/deployment/branching',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Supabase client for server-side use with service role key
|
||||
* Bypasses Row Level Security (RLS) - use with caution
|
||||
* Always filter by user_id to enforce permissions
|
||||
*/
|
||||
export const supabaseAdmin = createClient<SupabaseDatabase>(
|
||||
env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
env.SUPABASE_SERVICE_ROLE_KEY,
|
||||
{
|
||||
auth: {
|
||||
persistSession: false,
|
||||
autoRefreshToken: false,
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -1,123 +0,0 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
ScanNode as ScanNodeSchema,
|
||||
GuideNode as GuideNodeSchema,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
createAssetUploadUrl,
|
||||
confirmAssetUpload,
|
||||
type AssetType,
|
||||
} from '@/features/community/lib/assets/actions'
|
||||
import { useUploadStore } from '@/store/use-upload'
|
||||
import useEditor from '@/store/use-editor'
|
||||
|
||||
/**
|
||||
* Upload a file directly to Supabase Storage via signed URL with progress tracking.
|
||||
* Runs entirely outside React — survives component unmounts.
|
||||
*/
|
||||
export function uploadAssetWithProgress(
|
||||
projectId: string,
|
||||
levelId: string,
|
||||
file: File,
|
||||
assetType: AssetType,
|
||||
) {
|
||||
const store = useUploadStore.getState()
|
||||
store.startUpload(levelId, assetType, file.name)
|
||||
|
||||
// Run async work without blocking the caller
|
||||
doUpload(projectId, levelId, file, assetType).catch(() => {
|
||||
// errors are already recorded in the store by doUpload
|
||||
})
|
||||
}
|
||||
|
||||
async function doUpload(
|
||||
projectId: string,
|
||||
levelId: string,
|
||||
file: File,
|
||||
assetType: AssetType,
|
||||
) {
|
||||
const store = () => useUploadStore.getState()
|
||||
|
||||
// Phase 1: Get signed URL
|
||||
const urlResult = await createAssetUploadUrl(
|
||||
projectId,
|
||||
file.name,
|
||||
file.type || 'application/octet-stream',
|
||||
assetType,
|
||||
)
|
||||
|
||||
if (!urlResult.success) {
|
||||
store().setError(levelId, urlResult.error)
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 2: Upload directly to Supabase via XHR (for progress)
|
||||
store().setStatus(levelId, 'uploading')
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const pct = Math.round((e.loaded / e.total) * 100)
|
||||
useUploadStore.getState().setProgress(levelId, pct)
|
||||
}
|
||||
})
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(new Error(`Upload failed: HTTP ${xhr.status}`))
|
||||
}
|
||||
})
|
||||
|
||||
xhr.addEventListener('error', () => reject(new Error('Network error during upload')))
|
||||
xhr.addEventListener('abort', () => reject(new Error('Upload aborted')))
|
||||
|
||||
xhr.open('PUT', urlResult.signedUrl)
|
||||
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')
|
||||
xhr.send(file)
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Upload failed'
|
||||
store().setError(levelId, msg)
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 3: Confirm upload and record in DB
|
||||
store().setStatus(levelId, 'confirming')
|
||||
|
||||
const confirmResult = await confirmAssetUpload(
|
||||
projectId,
|
||||
urlResult.assetId,
|
||||
urlResult.storageKey,
|
||||
file.name,
|
||||
file.type || null,
|
||||
assetType,
|
||||
)
|
||||
|
||||
if (!confirmResult.success) {
|
||||
store().setError(levelId, confirmResult.error)
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 4: Create scene node (works even if component is unmounted)
|
||||
const Schema = assetType === 'scan' ? ScanNodeSchema : GuideNodeSchema
|
||||
const node = Schema.parse({
|
||||
url: confirmResult.url,
|
||||
name: file.name,
|
||||
parentId: levelId,
|
||||
})
|
||||
useScene.getState().createNode(node, levelId as AnyNodeId)
|
||||
useEditor.getState().setSelectedReferenceId(node.id)
|
||||
|
||||
store().setResult(levelId, confirmResult.url)
|
||||
|
||||
// Auto-clear after a short delay so the UI shows "done" briefly
|
||||
setTimeout(() => {
|
||||
useUploadStore.getState().clearUpload(levelId)
|
||||
}, 1500)
|
||||
}
|
||||
@@ -6,8 +6,7 @@ export function cn(...inputs: ClassValue[]) {
|
||||
}
|
||||
|
||||
export const isDevelopment =
|
||||
process.env.NODE_ENV === 'development' ||
|
||||
process.env.NEXT_PUBLIC_VERCEL_ENV === 'development'
|
||||
process.env.NODE_ENV === 'development' || process.env.NEXT_PUBLIC_VERCEL_ENV === 'development'
|
||||
|
||||
export const isProduction =
|
||||
process.env.NODE_ENV === 'production' || process.env.NEXT_PUBLIC_VERCEL_ENV === 'production'
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: ['three', '@pascal-app/viewer', '@pascal-app/core'],
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
transpilePackages: ['three', '@pascal-app/viewer', '@pascal-app/core', '@pascal-app/editor'],
|
||||
turbopack: {
|
||||
resolveAlias: {
|
||||
react: './node_modules/react',
|
||||
three: './node_modules/three',
|
||||
'@react-three/fiber': './node_modules/@react-three/fiber',
|
||||
'@react-three/drei': './node_modules/@react-three/drei',
|
||||
},
|
||||
},
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: '100mb',
|
||||
|
||||
@@ -1,66 +1,34 @@
|
||||
{
|
||||
"name": "web",
|
||||
"name": "editor",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "set -a && . ../../.env 2>/dev/null; set +a; next dev",
|
||||
"build": "next build",
|
||||
"dev": "dotenv -e ./.env.local --override -- next dev --port 3002",
|
||||
"build": "dotenv -e ./.env.local --override -- next build",
|
||||
"start": "next start",
|
||||
"lint": "biome lint",
|
||||
"check-types": "next typegen && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@number-flow/react": "^0.5.14",
|
||||
"@pascal-app/auth": "*",
|
||||
"@pascal-app/core": "*",
|
||||
"@pascal-app/db": "*",
|
||||
"@pascal-app/editor": "*",
|
||||
"@pascal-app/viewer": "*",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-google-maps/api": "^2.20.8",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@react-three/uikit-lucide": "^1.0.62",
|
||||
"@repo/ui": "*",
|
||||
"@supabase/supabase-js": "^2.98.0",
|
||||
"@t3-oss/env-nextjs": "^0.13.10",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@types/three": "^0.183.1",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"@vercel/speed-insights": "^1.3.1",
|
||||
"@vercel/toolbar": "^0.2.2",
|
||||
"@visual-json/react": "latest",
|
||||
"better-auth": "^1.5.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"howler": "^2.2.4",
|
||||
"lucide-react": "^0.562.0",
|
||||
"motion": "^12.34.3",
|
||||
"nanoid": "^5.1.6",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"next": "16.1.6",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"three": "^0.183.1",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11"
|
||||
"three": "^0.183.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/typescript-config": "*",
|
||||
"@pascal/typescript-config": "*",
|
||||
"@types/howler": "^2.2.12",
|
||||
"@types/node": "^22.19.12",
|
||||
"@types/react": "19.2.2",
|
||||
|
||||
+343
-1700
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/nextjs.json",
|
||||
"extends": "@pascal/typescript-config/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
+3
-10
@@ -12,14 +12,6 @@
|
||||
"check:fix": "biome check --write",
|
||||
"check-types": "turbo run check-types",
|
||||
"kill": "lsof -ti:3002 | xargs kill -9 2>/dev/null || echo 'No processes found on port 3002'",
|
||||
"db:generate": "bun run --cwd packages/db db:generate",
|
||||
"db:migrate": "bun run --cwd packages/db db:migrate",
|
||||
"db:push": "bun run --cwd packages/db db:push",
|
||||
"db:studio": "bun run --cwd packages/db db:studio",
|
||||
"db:start": "supabase start",
|
||||
"db:stop": "supabase stop",
|
||||
"db:reset": "supabase db reset",
|
||||
"db:status": "supabase status",
|
||||
"release": "gh workflow run release.yml -f package=both -f bump=patch",
|
||||
"release:viewer": "gh workflow run release.yml -f package=viewer -f bump=patch",
|
||||
"release:core": "gh workflow run release.yml -f package=core -f bump=patch",
|
||||
@@ -31,7 +23,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.6",
|
||||
"supabase": "2.76.15",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"turbo": "^2.8.15",
|
||||
"typescript": "5.9.3",
|
||||
"ultracite": "^7.2.5"
|
||||
@@ -42,6 +34,7 @@
|
||||
"packageManager": "bun@1.3.0",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
"packages/*",
|
||||
"tooling/*"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
# @pascal-app/auth
|
||||
|
||||
Authentication package for Pascal Editor using Better Auth.
|
||||
|
||||
## Features
|
||||
|
||||
- **Magic Link Authentication** - Passwordless email-based authentication
|
||||
- **Session Management** - Secure cookie-based sessions
|
||||
- **Supabase Integration** - Uses Supabase as the database adapter
|
||||
- **Type-safe** - Full TypeScript support with type inference
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Configure environment variables
|
||||
|
||||
Add these to `apps/editor/.env.local`:
|
||||
|
||||
```bash
|
||||
# Better Auth
|
||||
BETTER_AUTH_SECRET=<generate_with_openssl_rand_base64_32>
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
Generate a secret for `BETTER_AUTH_SECRET`:
|
||||
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
### 2. Ensure database is running
|
||||
|
||||
Make sure you have Supabase running with the auth tables created. See `@pascal-app/db` package for setup.
|
||||
|
||||
## Usage
|
||||
|
||||
### Server-side (API routes, server actions)
|
||||
|
||||
```typescript
|
||||
import { auth } from '@pascal-app/auth/server'
|
||||
|
||||
// Get session in server component or action
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
|
||||
if (!session) {
|
||||
return { error: 'Unauthorized' }
|
||||
}
|
||||
|
||||
// Access user data
|
||||
const userId = session.user.id
|
||||
const email = session.user.email
|
||||
```
|
||||
|
||||
### Client-side (React components)
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
|
||||
import { authClient } from '@pascal-app/auth/client'
|
||||
|
||||
function SignInButton() {
|
||||
const { signIn } = authClient
|
||||
|
||||
const handleSignIn = async (email: string) => {
|
||||
await signIn.magicLink({
|
||||
email,
|
||||
callbackURL: '/dashboard',
|
||||
})
|
||||
}
|
||||
|
||||
return <button onClick={() => handleSignIn('user@example.com')}>Sign In</button>
|
||||
}
|
||||
```
|
||||
|
||||
### Using the auth hook
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
|
||||
import { authClient } from '@pascal-app/auth/client'
|
||||
|
||||
function Profile() {
|
||||
const { data: session, isPending } = authClient.useSession()
|
||||
|
||||
if (isPending) return <div>Loading...</div>
|
||||
if (!session) return <div>Not signed in</div>
|
||||
|
||||
return <div>Signed in as {session.user.email}</div>
|
||||
}
|
||||
```
|
||||
|
||||
## API Routes
|
||||
|
||||
The auth package requires an API route handler in your Next.js app:
|
||||
|
||||
```typescript
|
||||
// app/api/auth/[...all]/route.ts
|
||||
import { auth } from '@pascal-app/auth/server'
|
||||
import { toNextJsHandler } from 'better-auth/next-js'
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth)
|
||||
```
|
||||
|
||||
This handles all Better Auth endpoints:
|
||||
- `/api/auth/sign-in/magic-link` - Send magic link
|
||||
- `/api/auth/sign-in/magic-link/verify` - Verify magic link
|
||||
- `/api/auth/sign-out` - Sign out
|
||||
- `/api/auth/session` - Get session
|
||||
- And more...
|
||||
|
||||
## Email Configuration
|
||||
|
||||
By default, magic links are logged to the console. To send actual emails, you'll need to configure an email provider in `packages/auth/src/server.ts`:
|
||||
|
||||
```typescript
|
||||
magicLink({
|
||||
sendMagicLink: async ({ email, url }) => {
|
||||
// Use Resend, SendGrid, or your preferred email service
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: 'Sign in to Pascal Editor',
|
||||
html: `Click here to sign in: <a href="${url}">${url}</a>`,
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
The auth package requires these tables (created by `@pascal-app/db` migrations):
|
||||
|
||||
- `users` - User accounts
|
||||
- `sessions` - Active sessions
|
||||
- `accounts` - OAuth provider accounts (for future use)
|
||||
- `verification_tokens` - Magic link tokens
|
||||
|
||||
## Security
|
||||
|
||||
- Session cookies are httpOnly and secure (in production)
|
||||
- Sessions expire after 7 days
|
||||
- Session cache is enabled for 5 minutes to reduce database queries
|
||||
- Magic link tokens expire after 15 minutes
|
||||
- All sensitive operations require valid session tokens
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"name": "@pascal-app/auth",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./server": {
|
||||
"types": "./src/server.ts",
|
||||
"default": "./src/server.ts"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./src/client.ts",
|
||||
"default": "./src/client.ts"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@pascal-app/db": "*",
|
||||
"better-auth": "^1.5.2",
|
||||
"resend": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/typescript-config": "*",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { lastLoginMethodClient, magicLinkClient } from 'better-auth/client/plugins'
|
||||
import { createAuthClient } from 'better-auth/react'
|
||||
|
||||
/**
|
||||
* Get the auth base URL
|
||||
* In development: use the editor URL (localhost:3000)
|
||||
* In production: use the same origin
|
||||
*/
|
||||
function getAuthURL(): string {
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.location.origin
|
||||
}
|
||||
|
||||
// SSR fallback - detect environment from Vercel variables
|
||||
const isDevelopment =
|
||||
process.env.NODE_ENV === 'development' ||
|
||||
process.env.NEXT_PUBLIC_VERCEL_ENV === 'development'
|
||||
const isPreview = process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview'
|
||||
const isProduction =
|
||||
process.env.NODE_ENV === 'production' || process.env.NEXT_PUBLIC_VERCEL_ENV === 'production'
|
||||
|
||||
if (isDevelopment) {
|
||||
return process.env.NEXT_PUBLIC_APP_URL || `http://localhost:${process.env.PORT || 3000}`
|
||||
}
|
||||
|
||||
if (isPreview && process.env.NEXT_PUBLIC_VERCEL_URL) {
|
||||
return `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
|
||||
}
|
||||
|
||||
if (isProduction) {
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_APP_URL ||
|
||||
(process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL
|
||||
? `https://${process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL}`
|
||||
: 'https://editor.pascal.app')
|
||||
)
|
||||
}
|
||||
|
||||
return 'http://localhost:3000'
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth client instance
|
||||
* Configured for magic link authentication
|
||||
*/
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: getAuthURL(),
|
||||
plugins: [magicLinkClient(), lastLoginMethodClient()],
|
||||
})
|
||||
|
||||
/**
|
||||
* Export types for use in components
|
||||
*/
|
||||
export type AuthState = {
|
||||
user: (typeof authClient)['$Infer']['Session']['user'] | null
|
||||
session: (typeof authClient)['$Infer']['Session']['session'] | null
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export type User = NonNullable<AuthState['user']>
|
||||
export type Session = NonNullable<AuthState['session']>
|
||||
@@ -1,99 +0,0 @@
|
||||
import type { Database } from '@pascal-app/db'
|
||||
import { schema } from '@pascal-app/db'
|
||||
import type { BetterAuthOptions } from 'better-auth'
|
||||
import { betterAuth } from 'better-auth'
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
||||
import { lastLoginMethod, magicLink } from 'better-auth/plugins'
|
||||
|
||||
export interface SendMagicLinkParams {
|
||||
email: string
|
||||
url: string
|
||||
token: string
|
||||
}
|
||||
|
||||
export interface AuthConfig {
|
||||
db: Database
|
||||
appName: string
|
||||
baseURL: string
|
||||
secret: string
|
||||
/** Google OAuth client ID */
|
||||
googleClientId?: string
|
||||
/** Google OAuth client secret */
|
||||
googleClientSecret?: string
|
||||
/** Callback to send magic link emails */
|
||||
sendMagicLink?: (params: SendMagicLinkParams) => Promise<void>
|
||||
/** Additional plugins to add (e.g., nextCookies for web) */
|
||||
additionalPlugins?: BetterAuthOptions['plugins']
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Better Auth instance with full configuration including:
|
||||
* - Magic link authentication
|
||||
* - Custom session with activePropertyId
|
||||
* - Session cookie caching
|
||||
*/
|
||||
export function createAuth(config: AuthConfig) {
|
||||
return betterAuth({
|
||||
appName: config.appName,
|
||||
baseURL: config.baseURL,
|
||||
secret: config.secret,
|
||||
basePath: '/api/auth',
|
||||
database: drizzleAdapter(config.db, {
|
||||
provider: 'pg',
|
||||
usePlural: true,
|
||||
schema,
|
||||
}),
|
||||
advanced: {
|
||||
database: {
|
||||
generateId: false, // Use our prefixed nanoid IDs from schema
|
||||
},
|
||||
},
|
||||
session: {
|
||||
// Session caching to reduce database queries
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 5 * 60, // Cache duration in seconds (5 minutes)
|
||||
},
|
||||
additionalFields: {
|
||||
// Additional fields for the session table
|
||||
activePropertyId: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
// Account linking — always enabled so magic link + Google users can share accounts
|
||||
account: {
|
||||
accountLinking: {
|
||||
enabled: true,
|
||||
trustedProviders: ['google', 'email'],
|
||||
},
|
||||
},
|
||||
// Google OAuth provider (only enabled when credentials are provided)
|
||||
...(config.googleClientId &&
|
||||
config.googleClientSecret && {
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: config.googleClientId,
|
||||
clientSecret: config.googleClientSecret,
|
||||
},
|
||||
},
|
||||
}),
|
||||
plugins: [
|
||||
...(config.additionalPlugins ?? []),
|
||||
// Track which login method was last used (e.g., "google", "magic-link")
|
||||
lastLoginMethod(),
|
||||
// Magic link authentication
|
||||
...(config.sendMagicLink
|
||||
? [
|
||||
magicLink({
|
||||
sendMagicLink: config.sendMagicLink,
|
||||
expiresIn: 300, // 5 minutes
|
||||
disableSignUp: false, // Allow new users to sign up via magic link
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
export type Auth = ReturnType<typeof createAuth>
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -39,7 +39,7 @@
|
||||
"zustand": "^5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/typescript-config": "*",
|
||||
"@pascal/typescript-config": "*",
|
||||
"@types/react": "^19.2.2",
|
||||
"typescript": "5.9.3",
|
||||
"@types/three": "^0.183.0"
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import type { ThreeEvent } from '@react-three/fiber'
|
||||
import mitt from 'mitt'
|
||||
import type { BuildingNode, CeilingNode, DoorNode, ItemNode, LevelNode, RoofNode, SiteNode, SlabNode, WallNode, WindowNode, ZoneNode } from '../schema'
|
||||
import type {
|
||||
BuildingNode,
|
||||
CeilingNode,
|
||||
DoorNode,
|
||||
ItemNode,
|
||||
LevelNode,
|
||||
RoofNode,
|
||||
SiteNode,
|
||||
SlabNode,
|
||||
WallNode,
|
||||
WindowNode,
|
||||
ZoneNode,
|
||||
} from '../schema'
|
||||
import type { AnyNode } from '../schema/types'
|
||||
|
||||
// Base event interfaces
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import type * as THREE from "three";
|
||||
import { useLayoutEffect } from 'react'
|
||||
import type * as THREE from 'three'
|
||||
|
||||
export const sceneRegistry = {
|
||||
// Master lookup: ID -> Object3D
|
||||
@@ -22,7 +22,7 @@ export const sceneRegistry = {
|
||||
window: new Set<string>(),
|
||||
door: new Set<string>(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useRegistry(
|
||||
id: string,
|
||||
@@ -30,19 +30,19 @@ export function useRegistry(
|
||||
ref: React.RefObject<THREE.Object3D>,
|
||||
) {
|
||||
useLayoutEffect(() => {
|
||||
const obj = ref.current;
|
||||
if (!obj) return;
|
||||
const obj = ref.current
|
||||
if (!obj) return
|
||||
|
||||
// 1. Add to master map
|
||||
sceneRegistry.nodes.set(id, obj);
|
||||
sceneRegistry.nodes.set(id, obj)
|
||||
|
||||
// 2. Add to type-specific set
|
||||
sceneRegistry.byType[type].add(id);
|
||||
sceneRegistry.byType[type].add(id)
|
||||
|
||||
// 4. Cleanup when component unmounts
|
||||
return () => {
|
||||
sceneRegistry.nodes.delete(id);
|
||||
sceneRegistry.byType[type].delete(id);
|
||||
};
|
||||
}, [id, type, ref]);
|
||||
sceneRegistry.nodes.delete(id)
|
||||
sceneRegistry.byType[type].delete(id)
|
||||
}
|
||||
}, [id, type, ref])
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getScaledDimensions } from '../../schema'
|
||||
import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'
|
||||
import { getScaledDimensions } from '../../schema'
|
||||
import { SpatialGrid } from './spatial-grid'
|
||||
import { WallSpatialGrid } from './wall-spatial-grid'
|
||||
|
||||
@@ -14,10 +14,12 @@ export function pointInPolygon(px: number, pz: number, polygon: Array<[number, n
|
||||
let inside = false
|
||||
const n = polygon.length
|
||||
for (let i = 0, j = n - 1; i < n; j = i++) {
|
||||
const xi = polygon[i]![0], zi = polygon[i]![1]
|
||||
const xj = polygon[j]![0], zj = polygon[j]![1]
|
||||
const xi = polygon[i]![0],
|
||||
zi = polygon[i]![1]
|
||||
const xj = polygon[j]![0],
|
||||
zj = polygon[j]![1]
|
||||
|
||||
if ((zi > pz) !== (zj > pz) && px < ((xj - xi) * (pz - zi)) / (zj - zi) + xi) {
|
||||
if (zi > pz !== zj > pz && px < ((xj - xi) * (pz - zi)) / (zj - zi) + xi) {
|
||||
inside = !inside
|
||||
}
|
||||
}
|
||||
@@ -53,8 +55,14 @@ function getItemFootprint(
|
||||
* Test if two line segments (a1->a2) and (b1->b2) intersect.
|
||||
*/
|
||||
function segmentsIntersect(
|
||||
ax1: number, az1: number, ax2: number, az2: number,
|
||||
bx1: number, bz1: number, bx2: number, bz2: number,
|
||||
ax1: number,
|
||||
az1: number,
|
||||
ax2: number,
|
||||
az2: number,
|
||||
bx1: number,
|
||||
bz1: number,
|
||||
bx2: number,
|
||||
bz2: number,
|
||||
): boolean {
|
||||
const cross = (ox: number, oz: number, ax: number, az: number, bx: number, bz: number) =>
|
||||
(ax - ox) * (bz - oz) - (az - oz) * (bx - ox)
|
||||
@@ -64,15 +72,16 @@ function segmentsIntersect(
|
||||
const d3 = cross(ax1, az1, ax2, az2, bx1, bz1)
|
||||
const d4 = cross(ax1, az1, ax2, az2, bx2, bz2)
|
||||
|
||||
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) &&
|
||||
((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {
|
||||
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Collinear touching cases
|
||||
const onSeg = (px: number, pz: number, qx: number, qz: number, rx: number, rz: number) =>
|
||||
Math.min(px, qx) <= rx && rx <= Math.max(px, qx) &&
|
||||
Math.min(pz, qz) <= rz && rz <= Math.max(pz, qz)
|
||||
Math.min(px, qx) <= rx &&
|
||||
rx <= Math.max(px, qx) &&
|
||||
Math.min(pz, qz) <= rz &&
|
||||
rz <= Math.max(pz, qz)
|
||||
|
||||
if (d1 === 0 && onSeg(bx1, bz1, bx2, bz2, ax1, az1)) return true
|
||||
if (d2 === 0 && onSeg(bx1, bz1, bx2, bz2, ax2, az2)) return true
|
||||
@@ -86,16 +95,27 @@ function segmentsIntersect(
|
||||
* Test if a line segment intersects any edge of a polygon.
|
||||
*/
|
||||
function segmentIntersectsPolygon(
|
||||
sx1: number, sz1: number, sx2: number, sz2: number,
|
||||
sx1: number,
|
||||
sz1: number,
|
||||
sx2: number,
|
||||
sz2: number,
|
||||
polygon: Array<[number, number]>,
|
||||
): boolean {
|
||||
const n = polygon.length
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n
|
||||
if (segmentsIntersect(
|
||||
sx1, sz1, sx2, sz2,
|
||||
polygon[i]![0], polygon[i]![1], polygon[j]![0], polygon[j]![1],
|
||||
)) {
|
||||
if (
|
||||
segmentsIntersect(
|
||||
sx1,
|
||||
sz1,
|
||||
sx2,
|
||||
sz2,
|
||||
polygon[i]![0],
|
||||
polygon[i]![1],
|
||||
polygon[j]![0],
|
||||
polygon[j]![1],
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -129,10 +149,16 @@ export function itemOverlapsPolygon(
|
||||
// Check if any item edge intersects any polygon edge
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const j = (i + 1) % 4
|
||||
if (segmentIntersectsPolygon(
|
||||
corners[i]![0], corners[i]![1], corners[j]![0], corners[j]![1],
|
||||
polygon,
|
||||
)) return true
|
||||
if (
|
||||
segmentIntersectsPolygon(
|
||||
corners[i]![0],
|
||||
corners[i]![1],
|
||||
corners[j]![0],
|
||||
corners[j]![1],
|
||||
polygon,
|
||||
)
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
@@ -144,8 +170,14 @@ export function itemOverlapsPolygon(
|
||||
* This prevents walls that just touch one point from being detected.
|
||||
*/
|
||||
function segmentsCollinearAndOverlap(
|
||||
ax1: number, az1: number, ax2: number, az2: number,
|
||||
bx1: number, bz1: number, bx2: number, bz2: number,
|
||||
ax1: number,
|
||||
az1: number,
|
||||
ax2: number,
|
||||
az2: number,
|
||||
bx1: number,
|
||||
bz1: number,
|
||||
bx2: number,
|
||||
bz2: number,
|
||||
): boolean {
|
||||
const EPSILON = 1e-6
|
||||
|
||||
@@ -159,8 +191,10 @@ function segmentsCollinearAndOverlap(
|
||||
|
||||
// Check if a point is on segment b
|
||||
const onSegment = (px: number, pz: number, qx: number, qz: number, rx: number, rz: number) =>
|
||||
Math.min(px, qx) - EPSILON <= rx && rx <= Math.max(px, qx) + EPSILON &&
|
||||
Math.min(pz, qz) - EPSILON <= rz && rz <= Math.max(pz, qz) + EPSILON
|
||||
Math.min(px, qx) - EPSILON <= rx &&
|
||||
rx <= Math.max(px, qx) + EPSILON &&
|
||||
Math.min(pz, qz) - EPSILON <= rz &&
|
||||
rz <= Math.max(pz, qz) + EPSILON
|
||||
|
||||
// BOTH endpoints of wall (a) must be on edge (b) for substantial overlap
|
||||
const a1OnB = onSegment(bx1, bz1, bx2, bz2, ax1, az1)
|
||||
@@ -314,7 +348,7 @@ export class SpatialGridManager {
|
||||
// position[1] is the bottom of the item
|
||||
this.getWallGrid(levelId).insert({
|
||||
itemId: item.id,
|
||||
wallId: wallId,
|
||||
wallId,
|
||||
tStart: t - halfW,
|
||||
tEnd: t + halfW,
|
||||
yStart: item.position[1],
|
||||
@@ -328,7 +362,12 @@ export class SpatialGridManager {
|
||||
// Ceiling item - use parentId as the ceiling ID
|
||||
const ceilingId = item.parentId
|
||||
if (ceilingId && this.ceilings.has(ceilingId)) {
|
||||
this.getCeilingGrid(ceilingId).insert(item.id, item.position, getScaledDimensions(item), item.rotation)
|
||||
this.getCeilingGrid(ceilingId).insert(
|
||||
item.id,
|
||||
item.position,
|
||||
getScaledDimensions(item),
|
||||
item.rotation,
|
||||
)
|
||||
this.itemCeilingMap.set(item.id, ceilingId)
|
||||
}
|
||||
} else if (!item.asset.attachTo) {
|
||||
@@ -367,7 +406,7 @@ export class SpatialGridManager {
|
||||
// position[1] is the bottom of the item
|
||||
this.getWallGrid(levelId).insert({
|
||||
itemId: item.id,
|
||||
wallId: wallId,
|
||||
wallId,
|
||||
tStart: t - halfW,
|
||||
tEnd: t + halfW,
|
||||
yStart: item.position[1],
|
||||
@@ -387,7 +426,12 @@ export class SpatialGridManager {
|
||||
// Insert into new ceiling grid
|
||||
const ceilingId = item.parentId
|
||||
if (ceilingId && this.ceilings.has(ceilingId)) {
|
||||
this.getCeilingGrid(ceilingId).insert(item.id, item.position, getScaledDimensions(item), item.rotation)
|
||||
this.getCeilingGrid(ceilingId).insert(
|
||||
item.id,
|
||||
item.position,
|
||||
getScaledDimensions(item),
|
||||
item.rotation,
|
||||
)
|
||||
this.itemCeilingMap.set(item.id, ceilingId)
|
||||
}
|
||||
} else if (!item.asset.attachTo) {
|
||||
@@ -530,9 +574,12 @@ export class SpatialGridManager {
|
||||
const slabMap = this.slabsByLevel.get(levelId)
|
||||
if (!slabMap) return 0
|
||||
|
||||
let maxElevation = -Infinity
|
||||
let maxElevation = Number.NEGATIVE_INFINITY
|
||||
for (const slab of slabMap.values()) {
|
||||
if (slab.polygon.length >= 3 && itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01)) {
|
||||
if (
|
||||
slab.polygon.length >= 3 &&
|
||||
itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01)
|
||||
) {
|
||||
// Check if item is entirely within a hole (if so, ignore this slab)
|
||||
// We consider it entirely in a hole if the item center is in the hole
|
||||
let inHole = false
|
||||
@@ -553,7 +600,7 @@ export class SpatialGridManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
return maxElevation === -Infinity ? 0 : maxElevation
|
||||
return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -561,15 +608,11 @@ export class SpatialGridManager {
|
||||
* Uses wallOverlapsPolygon which handles edge cases (points on boundary, collinear segments).
|
||||
* Returns the highest slab elevation found, or 0 if none.
|
||||
*/
|
||||
getSlabElevationForWall(
|
||||
levelId: string,
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
): number {
|
||||
getSlabElevationForWall(levelId: string, start: [number, number], end: [number, number]): number {
|
||||
const slabMap = this.slabsByLevel.get(levelId)
|
||||
if (!slabMap) return 0
|
||||
|
||||
let maxElevation = -Infinity
|
||||
let maxElevation = Number.NEGATIVE_INFINITY
|
||||
for (const slab of slabMap.values()) {
|
||||
if (slab.polygon.length < 3) continue
|
||||
if (!wallOverlapsPolygon(start, end, slab.polygon)) continue
|
||||
@@ -609,7 +652,7 @@ export class SpatialGridManager {
|
||||
if (elevation > maxElevation) maxElevation = elevation
|
||||
}
|
||||
}
|
||||
return maxElevation === -Infinity ? 0 : maxElevation
|
||||
return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { getScaledDimensions, type AnyNode, type AnyNodeId, type ItemNode, type SlabNode, type WallNode } from '../../schema'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
getScaledDimensions,
|
||||
type ItemNode,
|
||||
type SlabNode,
|
||||
type WallNode,
|
||||
} from '../../schema'
|
||||
import useScene from '../../store/use-scene'
|
||||
import { itemOverlapsPolygon, spatialGridManager, wallOverlapsPolygon } from './spatial-grid-manager'
|
||||
import {
|
||||
itemOverlapsPolygon,
|
||||
spatialGridManager,
|
||||
wallOverlapsPolygon,
|
||||
} from './spatial-grid-manager'
|
||||
|
||||
export function resolveLevelId(node: AnyNode, nodes: Record<string, AnyNode>): string {
|
||||
// If the node itself is a level
|
||||
@@ -13,10 +24,10 @@ export function resolveLevelId(node: AnyNode, nodes: Record<string, AnyNode>): s
|
||||
while (current) {
|
||||
if (current.type === 'level') return current.id
|
||||
// Find parent (you might need to add parentId to your schema or derive it)
|
||||
if (!current.parentId) {
|
||||
current = undefined
|
||||
} else {
|
||||
if (current.parentId) {
|
||||
current = nodes[current.parentId]
|
||||
} else {
|
||||
current = undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,9 +82,11 @@ export function initSpatialGridSync() {
|
||||
|
||||
if (node.type === 'item' && prev.type === 'item') {
|
||||
if (
|
||||
!arraysEqual(node.position, prev.position) ||
|
||||
!arraysEqual(node.rotation, prev.rotation) ||
|
||||
!arraysEqual(node.scale, prev.scale) ||
|
||||
!(
|
||||
arraysEqual(node.position, prev.position) &&
|
||||
arraysEqual(node.rotation, prev.rotation) &&
|
||||
arraysEqual(node.scale, prev.scale)
|
||||
) ||
|
||||
node.parentId !== prev.parentId ||
|
||||
node.side !== prev.side
|
||||
) {
|
||||
@@ -85,7 +98,11 @@ export function initSpatialGridSync() {
|
||||
}
|
||||
}
|
||||
} else if (node.type === 'slab' && prev.type === 'slab') {
|
||||
if (node.polygon !== prev.polygon || node.elevation !== prev.elevation || node.holes !== prev.holes) {
|
||||
if (
|
||||
node.polygon !== prev.polygon ||
|
||||
node.elevation !== prev.elevation ||
|
||||
node.holes !== prev.holes
|
||||
) {
|
||||
const levelId = resolveLevelId(node, state.nodes)
|
||||
spatialGridManager.handleNodeUpdated(node, levelId)
|
||||
|
||||
@@ -119,7 +136,15 @@ function markNodesOverlappingSlab(
|
||||
// Only floor items are affected by slabs
|
||||
if (item.asset.attachTo) continue
|
||||
if (resolveLevelId(node, nodes) !== slabLevelId) continue
|
||||
if (itemOverlapsPolygon(item.position, getScaledDimensions(item), item.rotation, slab.polygon, 0.01)) {
|
||||
if (
|
||||
itemOverlapsPolygon(
|
||||
item.position,
|
||||
getScaledDimensions(item),
|
||||
item.rotation,
|
||||
slab.polygon,
|
||||
0.01,
|
||||
)
|
||||
) {
|
||||
markDirty(node.id)
|
||||
}
|
||||
} else if (node.type === 'wall') {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user