Merge pull request #142 from pascalorg/chore/carving-out-community

Chore/carving out community
This commit is contained in:
Wassim SAMAD
2026-03-13 09:08:25 +01:00
committed by GitHub
350 changed files with 12072 additions and 34318 deletions
@@ -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 }
+3
View File
@@ -0,0 +1,3 @@
export function GET() {
return Response.json({ status: 'ok', app: 'editor', timestamp: new Date().toISOString() })
}
-89
View File
@@ -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 })
}
-81
View File
@@ -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&apos;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
View File
@@ -1,12 +1,19 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@source "../../../packages/editor/src";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme { @theme {
--font-sans: var(--font-barlow), var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; --font-sans:
--font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; var(--font-barlow), var(--font-geist-sans), ui-sans-serif, system-ui,
--font-barlow: var(--font-barlow), var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; 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 { @theme inline {
@@ -97,7 +104,9 @@
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0); --muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 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); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
@@ -166,90 +175,189 @@
.pascal-loader-1 { .pascal-loader-1 {
width: 45px; width: 45px;
aspect-ratio: 1; 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); background: var(--c), var(--c), var(--c);
animation: animation:
pascal-l1-1 1s infinite, pascal-l1-1 1s infinite,
pascal-l1-2 1s infinite; pascal-l1-2 1s infinite;
} }
@keyframes pascal-l1-1 { @keyframes pascal-l1-1 {
0%,100% {background-size:20% 100%} 0%,
33%,66% {background-size:20% 20%} 100% {
background-size: 20% 100%;
}
33%,
66% {
background-size: 20% 20%;
}
} }
@keyframes pascal-l1-2 { @keyframes pascal-l1-2 {
0%,33% {background-position: 0 0,50% 50%,100% 100%} 0%,
66%,100% {background-position: 100% 0,50% 50%,0 100%} 33% {
background-position:
0 0,
50% 50%,
100% 100%;
}
66%,
100% {
background-position:
100% 0,
50% 50%,
0 100%;
}
} }
.pascal-loader-2 { .pascal-loader-2 {
width: 45px; width: 45px;
aspect-ratio: .75; aspect-ratio: 0.75;
--c: no-repeat linear-gradient(currentColor 0 0); --c: no-repeat linear-gradient(currentColor 0 0);
background: background:
var(--c) 0% 50%, var(--c) 0% 50%,
var(--c) 50% 50%, var(--c) 50% 50%,
var(--c) 100% 50%; var(--c) 100% 50%;
background-size: 20% 50%; background-size: 20% 50%;
animation: pascal-l2 1s infinite linear; animation: pascal-l2 1s infinite linear;
} }
@keyframes pascal-l2 { @keyframes pascal-l2 {
20% {background-position: 0% 0% ,50% 50% ,100% 50% } 20% {
40% {background-position: 0% 100%,50% 0% ,100% 50% } background-position:
60% {background-position: 0% 50% ,50% 100%,100% 0% } 0% 0%,
80% {background-position: 0% 50% ,50% 50% ,100% 100%} 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 { .pascal-loader-3 {
width: 45px; width: 45px;
aspect-ratio: .75; aspect-ratio: 0.75;
--c:no-repeat linear-gradient(currentColor 0 0); --c: no-repeat linear-gradient(currentColor 0 0);
background: background:
var(--c) 0% 100%, var(--c) 0% 100%,
var(--c) 50% 100%, var(--c) 50% 100%,
var(--c) 100% 100%; var(--c) 100% 100%;
background-size: 20% 65%; background-size: 20% 65%;
animation: pascal-l3 1s infinite linear; animation: pascal-l3 1s infinite linear;
} }
@keyframes pascal-l3 { @keyframes pascal-l3 {
16.67% {background-position: 0% 0% ,50% 100%,100% 100%} 16.67% {
33.33% {background-position: 0% 0% ,50% 0% ,100% 100%} background-position:
50% {background-position: 0% 0% ,50% 0% ,100% 0% } 0% 0%,
66.67% {background-position: 0% 100%,50% 0% ,100% 0% } 50% 100%,
83.33% {background-position: 0% 100%,50% 100%,100% 0% } 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 { .pascal-loader-4 {
width: 45px; width: 45px;
aspect-ratio: 1; 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); background: var(--c), var(--c), var(--c);
animation: animation:
pascal-l4-1 1s infinite, pascal-l4-1 1s infinite,
pascal-l4-2 1s infinite; pascal-l4-2 1s infinite;
} }
@keyframes pascal-l4-1 { @keyframes pascal-l4-1 {
0%,100% {background-size:20% 100%} 0%,
33%,66% {background-size:20% 40%} 100% {
background-size: 20% 100%;
}
33%,
66% {
background-size: 20% 40%;
}
} }
@keyframes pascal-l4-2 { @keyframes pascal-l4-2 {
0%,33% {background-position: 0 0,50% 100%,100% 100%} 0%,
66%,100% {background-position: 100% 0,0 100%,50% 100%} 33% {
background-position:
0 0,
50% 100%,
100% 100%;
}
66%,
100% {
background-position:
100% 0,
0 100%,
50% 100%;
}
} }
.pascal-loader-5 { .pascal-loader-5 {
width: 45px; width: 45px;
aspect-ratio: 1; 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); background: var(--c), var(--c), var(--c);
animation: animation:
pascal-l5-1 1s infinite, pascal-l5-1 1s infinite,
pascal-l5-2 1s infinite; pascal-l5-2 1s infinite;
} }
@keyframes pascal-l5-1 { @keyframes pascal-l5-1 {
0%,100% {background-size:20% 100%} 0%,
33%,66% {background-size:20% 40%} 100% {
background-size: 20% 100%;
}
33%,
66% {
background-size: 20% 40%;
}
} }
@keyframes pascal-l5-2 { @keyframes pascal-l5-2 {
0%,33% {background-position: 0 0 ,50% 100%,100% 0} 0%,
66%,100% {background-position: 0 100%,50% 0 ,100% 100%} 33% {
background-position:
0 0,
50% 100%,
100% 0;
}
66%,
100% {
background-position:
0 100%,
50% 0,
100% 100%;
}
} }
+8 -60
View File
@@ -1,12 +1,7 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import Script from 'next/script'
import localFont from 'next/font/local'
import { Barlow } from 'next/font/google' import { Barlow } from 'next/font/google'
import { Analytics } from '@vercel/analytics/react' import localFont from 'next/font/local'
import { SpeedInsights } from '@vercel/speed-insights/next' import Script from 'next/script'
import { VercelToolbar } from '@vercel/toolbar/next'
import { UsernameGate } from '@/features/community/components/username-gate'
import { siteConfig } from './seo'
import './globals.css' import './globals.css'
const geistSans = localFont({ const geistSans = localFont({
@@ -26,48 +21,8 @@ const barlow = Barlow({
}) })
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL(siteConfig.url), title: 'Pascal Editor',
title: { description: 'Standalone building editor',
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,
},
},
} }
export default function RootLayout({ export default function RootLayout({
@@ -75,32 +30,25 @@ export default function RootLayout({
}: Readonly<{ }: Readonly<{
children: React.ReactNode children: React.ReactNode
}>) { }>) {
const shouldShowToolbar = process.env.NODE_ENV === 'development'
return ( return (
<html lang="en" className={`${geistSans.variable} ${geistMono.variable} ${barlow.variable}`}> <html className={`${geistSans.variable} ${geistMono.variable} ${barlow.variable}`} lang="en">
<head> <head>
{process.env.NODE_ENV === 'development' && ( {process.env.NODE_ENV === 'development' && (
<> <>
<Script <Script
src="//unpkg.com/react-scan/dist/auto.global.js"
crossOrigin="anonymous" crossOrigin="anonymous"
src="//unpkg.com/react-scan/dist/auto.global.js"
strategy="beforeInteractive" strategy="beforeInteractive"
/> />
<Script <Script
src="//unpkg.com/react-grab/dist/index.global.js"
crossOrigin="anonymous" crossOrigin="anonymous"
src="//unpkg.com/react-grab/dist/index.global.js"
strategy="beforeInteractive" strategy="beforeInteractive"
/> />
</> </>
)} )}
</head> </head>
<body className="font-sans"> <body className="font-sans">{children}</body>
<UsernameGate>{children}</UsernameGate>
<Analytics />
<SpeedInsights />
{shouldShowToolbar && <VercelToolbar />}
</body>
</html> </html>
) )
} }
-163
View File
@@ -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,
})
}
}
+7 -8
View File
@@ -1,12 +1,11 @@
import type { Metadata } from 'next' 'use client'
import CommunityHub from '@/features/community/components/community-hub'
export const metadata: Metadata = { import { Editor } from '@pascal-app/editor'
title: 'Community Projects',
description:
'Create and share 3D home projects with Pascal Editor, the open-source building editor.',
}
export default function Home() { export default function Home() {
return <CommunityHub /> return (
<div className="h-screen w-screen">
<Editor />
</div>
)
} }
+65 -65
View File
@@ -9,100 +9,101 @@ export const metadata: Metadata = {
export default function PrivacyPage() { export default function PrivacyPage() {
return ( return (
<div className="min-h-screen bg-background"> <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"> <div className="container mx-auto px-6 py-4">
<nav className="flex items-center gap-4 text-sm"> <nav className="flex items-center gap-4 text-sm">
<Link <Link
href="/"
className="text-muted-foreground transition-colors hover:text-foreground" className="text-muted-foreground transition-colors hover:text-foreground"
href="/"
> >
Home Home
</Link> </Link>
<span className="text-muted-foreground">/</span> <span className="text-muted-foreground">/</span>
<Link <Link
href="/terms"
className="text-muted-foreground transition-colors hover:text-foreground" className="text-muted-foreground transition-colors hover:text-foreground"
href="/terms"
> >
Terms of Service Terms of Service
</Link> </Link>
<span className="text-muted-foreground">|</span> <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> </nav>
</div> </div>
</header> </header>
<main className="container mx-auto max-w-3xl px-6 py-12"> <main className="container mx-auto max-w-3xl px-6 py-12">
<article className="prose prose-neutral dark:prose-invert max-w-none"> <article className="prose prose-neutral dark:prose-invert max-w-none">
<h1 className="text-3xl font-bold mb-2">Privacy Policy</h1> <h1 className="mb-2 font-bold text-3xl">Privacy Policy</h1>
<p className="text-muted-foreground text-sm mb-8"> <p className="mb-8 text-muted-foreground text-sm">Effective Date: February 20, 2026</p>
Effective Date: February 20, 2026
</p>
<section className="space-y-4 mb-8"> <section className="mb-8 space-y-4">
<h2 className="text-xl font-semibold">1. Introduction</h2> <h2 className="font-semibold text-xl">1. Introduction</h2>
<p className="text-foreground/90 leading-relaxed"> <p className="text-foreground/90 leading-relaxed">
Pascal Group Inc. (&quot;we,&quot; &quot;us,&quot; or &quot;our&quot;) operates the Pascal Editor and Pascal Group Inc. (&quot;we,&quot; &quot;us,&quot; or &quot;our&quot;) operates the
Platform at pascal.app. This Privacy Policy explains how we collect, use, and Pascal Editor and Platform at pascal.app. This Privacy Policy explains how we collect,
protect your information when you use our services. use, and protect your information when you use our services.
</p> </p>
</section> </section>
<section className="space-y-4 mb-8"> <section className="mb-8 space-y-4">
<h2 className="text-xl font-semibold">2. Information We Collect</h2> <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"> <p className="text-foreground/90 leading-relaxed">
When you create an account, we collect: When you create an account, we collect:
</p> </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>Email address</li>
<li>Name</li> <li>Name</li>
<li>Profile picture/avatar</li> <li>Profile picture/avatar</li>
<li>OAuth provider data (from Google when you sign in with Google)</li> <li>OAuth provider data (from Google when you sign in with Google)</li>
</ul> </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"> <p className="text-foreground/90 leading-relaxed">
When you use the Platform, we store your projects, including 3D building designs, When you use the Platform, we store your projects, including 3D building designs,
floor plans, and associated metadata. floor plans, and associated metadata.
</p> </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"> <p className="text-foreground/90 leading-relaxed">
We use Vercel Analytics and Speed Insights to collect anonymized usage data, We use Vercel Analytics and Speed Insights to collect anonymized usage data, including
including page views, performance metrics, and general usage patterns. This helps page views, performance metrics, and general usage patterns. This helps us improve the
us improve the Platform. Platform.
</p> </p>
</section> </section>
<section className="space-y-4 mb-8"> <section className="mb-8 space-y-4">
<h2 className="text-xl font-semibold">3. How We Use Your Information</h2> <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> <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>Provide and maintain your account</li>
<li>Store and sync your projects across devices</li> <li>Store and sync your projects across devices</li>
<li>Improve our services based on usage patterns</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>Respond to support requests</li>
<li>Ensure platform security and prevent abuse</li> <li>Ensure platform security and prevent abuse</li>
</ul> </ul>
</section> </section>
<section className="space-y-4 mb-8"> <section className="mb-8 space-y-4">
<h2 className="text-xl font-semibold">4. Data Storage</h2> <h2 className="font-semibold text-xl">4. Data Storage</h2>
<p className="text-foreground/90 leading-relaxed"> <p className="text-foreground/90 leading-relaxed">
Your data is stored using Supabase (PostgreSQL database) on secure cloud Your data is stored using Supabase (PostgreSQL database) on secure cloud
infrastructure. We implement appropriate technical and organizational measures infrastructure. We implement appropriate technical and organizational measures to
to protect your data. protect your data.
</p> </p>
</section> </section>
<section className="space-y-4 mb-8"> <section className="mb-8 space-y-4">
<h2 className="text-xl font-semibold">5. Third-Party Services</h2> <h2 className="font-semibold text-xl">5. Third-Party Services</h2>
<p className="text-foreground/90 leading-relaxed"> <p className="text-foreground/90 leading-relaxed">
We use the following third-party services to operate the Platform: We use the following third-party services to operate the Platform:
</p> </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> <li>
<strong>Google</strong> - OAuth authentication for sign-in <strong>Google</strong> - OAuth authentication for sign-in
</li> </li>
@@ -113,44 +114,44 @@ export default function PrivacyPage() {
<strong>Supabase</strong> - Database hosting and authentication infrastructure <strong>Supabase</strong> - Database hosting and authentication infrastructure
</li> </li>
</ul> </ul>
<p className="text-foreground/90 leading-relaxed mt-4"> <p className="mt-4 text-foreground/90 leading-relaxed">
Each of these services has their own privacy policies governing their handling Each of these services has their own privacy policies governing their handling of your
of your data. data.
</p> </p>
</section> </section>
<section className="space-y-4 mb-8"> <section className="mb-8 space-y-4">
<h2 className="text-xl font-semibold">6. Cookies</h2> <h2 className="font-semibold text-xl">6. Cookies</h2>
<p className="text-foreground/90 leading-relaxed"> <p className="text-foreground/90 leading-relaxed">
We use minimal cookies necessary for the Platform to function: We use minimal cookies necessary for the Platform to function:
</p> </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> <li>
<strong>Session cookies</strong> - Essential for authentication and keeping you <strong>Session cookies</strong> - Essential for authentication and keeping you
signed in signed in
</li> </li>
<li> <li>
<strong>Analytics cookies</strong> - Used by Vercel Analytics to collect <strong>Analytics cookies</strong> - Used by Vercel Analytics to collect anonymized
anonymized usage data usage data
</li> </li>
</ul> </ul>
</section> </section>
<section className="space-y-4 mb-8"> <section className="mb-8 space-y-4">
<h2 className="text-xl font-semibold">7. Your Rights</h2> <h2 className="font-semibold text-xl">7. Your Rights</h2>
<p className="text-foreground/90 leading-relaxed">You have the right to:</p> <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>Access the personal data we hold about you</li>
<li>Request correction of inaccurate data</li> <li>Request correction of inaccurate data</li>
<li>Request deletion of your data</li> <li>Request deletion of your data</li>
<li>Export your project data</li> <li>Export your project data</li>
<li>Opt out of marketing communications</li> <li>Opt out of marketing communications</li>
</ul> </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{' '} To exercise any of these rights, please contact us at{' '}
<a <a
href="mailto:support@pascal.app"
className="text-foreground underline hover:text-foreground/80" className="text-foreground underline hover:text-foreground/80"
href="mailto:support@pascal.app"
> >
support@pascal.app support@pascal.app
</a> </a>
@@ -158,42 +159,41 @@ export default function PrivacyPage() {
</p> </p>
</section> </section>
<section className="space-y-4 mb-8"> <section className="mb-8 space-y-4">
<h2 className="text-xl font-semibold">8. Data Retention</h2> <h2 className="font-semibold text-xl">8. Data Retention</h2>
<p className="text-foreground/90 leading-relaxed"> <p className="text-foreground/90 leading-relaxed">
We retain your data for as long as your account is active. If you delete your We retain your data for as long as your account is active. If you delete your account,
account, we will delete your personal data and project data within 30 days, we will delete your personal data and project data within 30 days, except where we are
except where we are required by law to retain certain information. required by law to retain certain information.
</p> </p>
</section> </section>
<section className="space-y-4 mb-8"> <section className="mb-8 space-y-4">
<h2 className="text-xl font-semibold">9. Children&apos;s Privacy</h2> <h2 className="font-semibold text-xl">9. Children&apos;s Privacy</h2>
<p className="text-foreground/90 leading-relaxed"> <p className="text-foreground/90 leading-relaxed">
The Platform is not intended for children under 13. We do not knowingly collect 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 personal information from children under 13. If you believe we have collected such
such information, please contact us immediately. information, please contact us immediately.
</p> </p>
</section> </section>
<section className="space-y-4 mb-8"> <section className="mb-8 space-y-4">
<h2 className="text-xl font-semibold">10. Changes to This Policy</h2> <h2 className="font-semibold text-xl">10. Changes to This Policy</h2>
<p className="text-foreground/90 leading-relaxed"> <p className="text-foreground/90 leading-relaxed">
We may update this Privacy Policy from time to time. We will notify you of We may update this Privacy Policy from time to time. We will notify you of material
material changes by posting the updated policy on the Platform. Your continued changes by posting the updated policy on the Platform. Your continued use of the
use of the Platform after changes are posted constitutes your acceptance of the Platform after changes are posted constitutes your acceptance of the revised policy.
revised policy.
</p> </p>
</section> </section>
<section className="space-y-4"> <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"> <p className="text-foreground/90 leading-relaxed">
If you have questions about this Privacy Policy or how we handle your data, If you have questions about this Privacy Policy or how we handle your data, please
please contact us at{' '} contact us at{' '}
<a <a
href="mailto:support@pascal.app"
className="text-foreground underline hover:text-foreground/80" className="text-foreground underline hover:text-foreground/80"
href="mailto:support@pascal.app"
> >
support@pascal.app support@pascal.app
</a> </a>
-16
View File
@@ -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,
}
}
-22
View File
@@ -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
-40
View File
@@ -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}
/>
)
}
-22
View File
@@ -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,
},
]
}
+71 -68
View File
@@ -9,21 +9,21 @@ export const metadata: Metadata = {
export default function TermsPage() { export default function TermsPage() {
return ( return (
<div className="min-h-screen bg-background"> <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"> <div className="container mx-auto px-6 py-4">
<nav className="flex items-center gap-4 text-sm"> <nav className="flex items-center gap-4 text-sm">
<Link <Link
href="/"
className="text-muted-foreground transition-colors hover:text-foreground" className="text-muted-foreground transition-colors hover:text-foreground"
href="/"
> >
Home Home
</Link> </Link>
<span className="text-muted-foreground">/</span> <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> <span className="text-muted-foreground">|</span>
<Link <Link
href="/privacy"
className="text-muted-foreground transition-colors hover:text-foreground" className="text-muted-foreground transition-colors hover:text-foreground"
href="/privacy"
> >
Privacy Policy Privacy Policy
</Link> </Link>
@@ -33,26 +33,25 @@ export default function TermsPage() {
<main className="container mx-auto max-w-3xl px-6 py-12"> <main className="container mx-auto max-w-3xl px-6 py-12">
<article className="prose prose-neutral dark:prose-invert max-w-none"> <article className="prose prose-neutral dark:prose-invert max-w-none">
<h1 className="text-3xl font-bold mb-2">Terms of Service</h1> <h1 className="mb-2 font-bold text-3xl">Terms of Service</h1>
<p className="text-muted-foreground text-sm mb-8"> <p className="mb-8 text-muted-foreground text-sm">Effective Date: February 20, 2026</p>
Effective Date: February 20, 2026
</p>
<section className="space-y-4 mb-8"> <section className="mb-8 space-y-4">
<h2 className="text-xl font-semibold">1. Introduction</h2> <h2 className="font-semibold text-xl">1. Introduction</h2>
<p className="text-foreground/90 leading-relaxed"> <p className="text-foreground/90 leading-relaxed">
Welcome to Pascal Editor (&quot;Editor&quot;) and the Pascal platform at pascal.app Welcome to Pascal Editor (&quot;Editor&quot;) and the Pascal platform at pascal.app
(&quot;Platform&quot;), operated by Pascal Group Inc. (&quot;we,&quot; &quot;us,&quot; or &quot;our&quot;). (&quot;Platform&quot;), operated by Pascal Group Inc. (&quot;we,&quot; &quot;us,&quot;
By accessing or using our services, you agree to these Terms of Service. or &quot;our&quot;). By accessing or using our services, you agree to these Terms of
Service.
</p> </p>
</section> </section>
<section className="space-y-4 mb-8"> <section className="mb-8 space-y-4">
<h2 className="text-xl font-semibold">2. The Editor and Platform</h2> <h2 className="font-semibold text-xl">2. The Editor and Platform</h2>
<p className="text-foreground/90 leading-relaxed"> <p className="text-foreground/90 leading-relaxed">
The Pascal Editor is open-source software released under the MIT License. The Pascal Editor is open-source software released under the MIT License. You may use,
You may use, copy, modify, merge, publish, distribute, sublicense, and/or sell copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Editor
copies of the Editor software in accordance with the MIT License terms. software in accordance with the MIT License terms.
</p> </p>
<p className="text-foreground/90 leading-relaxed"> <p className="text-foreground/90 leading-relaxed">
The Pascal platform (pascal.app) and its associated services, including user accounts, The Pascal platform (pascal.app) and its associated services, including user accounts,
@@ -61,22 +60,26 @@ export default function TermsPage() {
</p> </p>
</section> </section>
<section className="space-y-4 mb-8"> <section className="mb-8 space-y-4">
<h2 className="text-xl font-semibold">3. Accounts and Authentication</h2> <h2 className="font-semibold text-xl">3. Accounts and Authentication</h2>
<p className="text-foreground/90 leading-relaxed"> <p className="text-foreground/90 leading-relaxed">
To use certain features of the Platform, you must create an account. We use To use certain features of the Platform, you must create an account. We use Google
Google OAuth and magic link email authentication through Supabase. You are OAuth and magic link email authentication through Supabase. You are responsible for
responsible for maintaining the security of your account credentials and for maintaining the security of your account credentials and for all activities that occur
all activities that occur under your account. under your account.
</p> </p>
</section> </section>
<section className="space-y-4 mb-8"> <section className="mb-8 space-y-4">
<h2 className="text-xl font-semibold">4. Acceptable Use</h2> <h2 className="font-semibold text-xl">4. Acceptable Use</h2>
<p className="text-foreground/90 leading-relaxed">You agree not to:</p> <p className="text-foreground/90 leading-relaxed">You agree not 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>Use the Platform for any unlawful purpose or in violation of any applicable laws</li> <li>
<li>Upload, share, or distribute content that infringes intellectual property rights</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>Attempt to gain unauthorized access to the Platform or its systems</li>
<li>Interfere with or disrupt the Platform&apos;s infrastructure</li> <li>Interfere with or disrupt the Platform&apos;s infrastructure</li>
<li>Upload malicious code, viruses, or harmful content</li> <li>Upload malicious code, viruses, or harmful content</li>
@@ -85,39 +88,39 @@ export default function TermsPage() {
</ul> </ul>
</section> </section>
<section className="space-y-4 mb-8"> <section className="mb-8 space-y-4">
<h2 className="text-xl font-semibold">5. Your Content and Intellectual Property</h2> <h2 className="font-semibold text-xl">5. Your Content and Intellectual Property</h2>
<p className="text-foreground/90 leading-relaxed"> <p className="text-foreground/90 leading-relaxed">
You retain full ownership of all content, projects, and data you create or upload You retain full ownership of all content, projects, and data you create or upload to
to the Platform (&quot;Your Content&quot;). By using the Platform, you grant us a limited the Platform (&quot;Your Content&quot;). By using the Platform, you grant us a limited
license to store, display, and transmit Your Content solely to provide our services license to store, display, and transmit Your Content solely to provide our services to
to you. you.
</p> </p>
<p className="text-foreground/90 leading-relaxed"> <p className="text-foreground/90 leading-relaxed">
We do not claim any ownership rights over Your Content. You may export or delete We do not claim any ownership rights over Your Content. You may export or delete Your
Your Content at any time. Content at any time.
</p> </p>
</section> </section>
<section className="space-y-4 mb-8"> <section className="mb-8 space-y-4">
<h2 className="text-xl font-semibold">6. Platform Ownership</h2> <h2 className="font-semibold text-xl">6. Platform Ownership</h2>
<p className="text-foreground/90 leading-relaxed"> <p className="text-foreground/90 leading-relaxed">
The Platform, including its design, features, and proprietary code, is owned by The Platform, including its design, features, and proprietary code, is owned by Pascal
Pascal Group Inc. and protected by intellectual property laws. While the Editor Group Inc. and protected by intellectual property laws. While the Editor source code
source code is open-source under the MIT License, the Platform services, branding, is open-source under the MIT License, the Platform services, branding, and
and infrastructure remain our proprietary property. infrastructure remain our proprietary property.
</p> </p>
</section> </section>
<section className="space-y-4 mb-8"> <section className="mb-8 space-y-4">
<h2 className="text-xl font-semibold">7. Account Termination</h2> <h2 className="font-semibold text-xl">7. Account Termination</h2>
<p className="text-foreground/90 leading-relaxed"> <p className="text-foreground/90 leading-relaxed">
We reserve the right to suspend or terminate your account if you violate these We reserve the right to suspend or terminate your account if you violate these Terms
Terms or engage in conduct that we determine is harmful to the Platform or other or engage in conduct that we determine is harmful to the Platform or other users. You
users. You may also delete your account at any time by contacting us at{' '} may also delete your account at any time by contacting us at{' '}
<a <a
href="mailto:support@pascal.app"
className="text-foreground underline hover:text-foreground/80" className="text-foreground underline hover:text-foreground/80"
href="mailto:support@pascal.app"
> >
support@pascal.app support@pascal.app
</a> </a>
@@ -125,45 +128,45 @@ export default function TermsPage() {
</p> </p>
</section> </section>
<section className="space-y-4 mb-8"> <section className="mb-8 space-y-4">
<h2 className="text-xl font-semibold">8. Disclaimer of Warranties</h2> <h2 className="font-semibold text-xl">8. Disclaimer of Warranties</h2>
<p className="text-foreground/90 leading-relaxed"> <p className="text-foreground/90 leading-relaxed">
THE PLATFORM IS PROVIDED &quot;AS IS&quot; AND &quot;AS AVAILABLE&quot; WITHOUT WARRANTIES OF ANY THE PLATFORM IS PROVIDED &quot;AS IS&quot; AND &quot;AS AVAILABLE&quot; WITHOUT
KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO IMPLIED WARRANTIES WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND
NON-INFRINGEMENT.
</p> </p>
<p className="text-foreground/90 leading-relaxed"> <p className="text-foreground/90 leading-relaxed">
We do not warrant that the Platform will be uninterrupted, error-free, or free We do not warrant that the Platform will be uninterrupted, error-free, or free of
of harmful components. harmful components.
</p> </p>
</section> </section>
<section className="space-y-4 mb-8"> <section className="mb-8 space-y-4">
<h2 className="text-xl font-semibold">9. Limitation of Liability</h2> <h2 className="font-semibold text-xl">9. Limitation of Liability</h2>
<p className="text-foreground/90 leading-relaxed"> <p className="text-foreground/90 leading-relaxed">
TO THE MAXIMUM EXTENT PERMITTED BY LAW, PASCAL GROUP INC. SHALL NOT BE LIABLE TO THE MAXIMUM EXTENT PERMITTED BY LAW, PASCAL GROUP INC. SHALL NOT BE LIABLE FOR ANY
FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, INCLUDING LOSS OF
INCLUDING LOSS OF DATA, PROFITS, OR GOODWILL, ARISING FROM YOUR USE OF THE DATA, PROFITS, OR GOODWILL, ARISING FROM YOUR USE OF THE PLATFORM.
PLATFORM.
</p> </p>
</section> </section>
<section className="space-y-4 mb-8"> <section className="mb-8 space-y-4">
<h2 className="text-xl font-semibold">10. Changes to Terms</h2> <h2 className="font-semibold text-xl">10. Changes to Terms</h2>
<p className="text-foreground/90 leading-relaxed"> <p className="text-foreground/90 leading-relaxed">
We may update these Terms from time to time. We will notify you of material We may update these Terms from time to time. We will notify you of material changes by
changes by posting the updated Terms on the Platform. Your continued use of the posting the updated Terms on the Platform. Your continued use of the Platform after
Platform after changes are posted constitutes your acceptance of the revised Terms. changes are posted constitutes your acceptance of the revised Terms.
</p> </p>
</section> </section>
<section className="space-y-4"> <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"> <p className="text-foreground/90 leading-relaxed">
If you have questions about these Terms, please contact us at{' '} If you have questions about these Terms, please contact us at{' '}
<a <a
href="mailto:support@pascal.app"
className="text-foreground underline hover:text-foreground/80" className="text-foreground underline hover:text-foreground/80"
href="mailto:support@pascal.app"
> >
support@pascal.app support@pascal.app
</a> </a>
-45
View File
@@ -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>
)
}
-42
View File
@@ -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&apos;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>
)
}
-14
View File
@@ -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
}
-190
View File
@@ -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} />
</>
)
}
-182
View File
@@ -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;
};
-16
View File
@@ -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 };
@@ -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>
);
}
-198
View File
@@ -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&apos;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
}
-50
View File
@@ -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')
}
}
}
-49
View File
@@ -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
-15
View File
@@ -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 '/'
}
-11
View File
@@ -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)
-39
View File
@@ -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,
},
},
)
-123
View File
@@ -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)
}
+1 -2
View File
@@ -6,8 +6,7 @@ export function cn(...inputs: ClassValue[]) {
} }
export const isDevelopment = export const isDevelopment =
process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'development' || process.env.NEXT_PUBLIC_VERCEL_ENV === 'development'
process.env.NEXT_PUBLIC_VERCEL_ENV === 'development'
export const isProduction = export const isProduction =
process.env.NODE_ENV === 'production' || process.env.NEXT_PUBLIC_VERCEL_ENV === 'production' process.env.NODE_ENV === 'production' || process.env.NEXT_PUBLIC_VERCEL_ENV === 'production'
+12 -1
View File
@@ -1,7 +1,18 @@
import type { NextConfig } from 'next' import type { NextConfig } from 'next'
const nextConfig: NextConfig = { 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: { experimental: {
serverActions: { serverActions: {
bodySizeLimit: '100mb', bodySizeLimit: '100mb',
+8 -40
View File
@@ -1,66 +1,34 @@
{ {
"name": "web", "name": "editor",
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "set -a && . ../../.env 2>/dev/null; set +a; next dev", "dev": "dotenv -e ./.env.local --override -- next dev --port 3002",
"build": "next build", "build": "dotenv -e ./.env.local --override -- next build",
"start": "next start", "start": "next start",
"lint": "biome lint", "lint": "biome lint",
"check-types": "next typegen && tsc --noEmit" "check-types": "next typegen && tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@number-flow/react": "^0.5.14", "@number-flow/react": "^0.5.14",
"@pascal-app/auth": "*",
"@pascal-app/core": "*", "@pascal-app/core": "*",
"@pascal-app/db": "*", "@pascal-app/editor": "*",
"@pascal-app/viewer": "*", "@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/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0", "@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", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "tailwind-merge": "^3.5.0",
"howler": "^2.2.4", "@tailwindcss/postcss": "^4.2.1",
"lucide-react": "^0.562.0",
"motion": "^12.34.3",
"nanoid": "^5.1.6",
"next": "16.1.6", "next": "16.1.6",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"three": "^0.183.1", "three": "^0.183.1"
"zod": "^4.3.6",
"zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
"@repo/typescript-config": "*", "@pascal/typescript-config": "*",
"@types/howler": "^2.2.12", "@types/howler": "^2.2.12",
"@types/node": "^22.19.12", "@types/node": "^22.19.12",
"@types/react": "19.2.2", "@types/react": "19.2.2",
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"extends": "@repo/typescript-config/nextjs.json", "extends": "@pascal/typescript-config/nextjs.json",
"compilerOptions": { "compilerOptions": {
"plugins": [ "plugins": [
{ {
+159 -758
View File
File diff suppressed because it is too large Load Diff
+3 -10
View File
@@ -12,14 +12,6 @@
"check:fix": "biome check --write", "check:fix": "biome check --write",
"check-types": "turbo run check-types", "check-types": "turbo run check-types",
"kill": "lsof -ti:3002 | xargs kill -9 2>/dev/null || echo 'No processes found on port 3002'", "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": "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: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", "release:core": "gh workflow run release.yml -f package=core -f bump=patch",
@@ -31,7 +23,7 @@
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.6", "@biomejs/biome": "^2.4.6",
"supabase": "2.76.15", "dotenv-cli": "^11.0.0",
"turbo": "^2.8.15", "turbo": "^2.8.15",
"typescript": "5.9.3", "typescript": "5.9.3",
"ultracite": "^7.2.5" "ultracite": "^7.2.5"
@@ -42,6 +34,7 @@
"packageManager": "bun@1.3.0", "packageManager": "bun@1.3.0",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"packages/*" "packages/*",
"tooling/*"
] ]
} }
-142
View File
@@ -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
-24
View File
@@ -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"
}
}
-61
View File
@@ -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']>
-99
View File
@@ -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>
-9
View File
@@ -1,9 +0,0 @@
{
"extends": "@repo/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+1 -1
View File
@@ -39,7 +39,7 @@
"zustand": "^5" "zustand": "^5"
}, },
"devDependencies": { "devDependencies": {
"@repo/typescript-config": "*", "@pascal/typescript-config": "*",
"@types/react": "^19.2.2", "@types/react": "^19.2.2",
"typescript": "5.9.3", "typescript": "5.9.3",
"@types/three": "^0.183.0" "@types/three": "^0.183.0"
+13 -1
View File
@@ -1,6 +1,18 @@
import type { ThreeEvent } from '@react-three/fiber' import type { ThreeEvent } from '@react-three/fiber'
import mitt from 'mitt' 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' import type { AnyNode } from '../schema/types'
// Base event interfaces // Base event interfaces
@@ -1,5 +1,5 @@
import { useLayoutEffect } from "react"; import { useLayoutEffect } from 'react'
import type * as THREE from "three"; import type * as THREE from 'three'
export const sceneRegistry = { export const sceneRegistry = {
// Master lookup: ID -> Object3D // Master lookup: ID -> Object3D
@@ -22,7 +22,7 @@ export const sceneRegistry = {
window: new Set<string>(), window: new Set<string>(),
door: new Set<string>(), door: new Set<string>(),
}, },
}; }
export function useRegistry( export function useRegistry(
id: string, id: string,
@@ -30,19 +30,19 @@ export function useRegistry(
ref: React.RefObject<THREE.Object3D>, ref: React.RefObject<THREE.Object3D>,
) { ) {
useLayoutEffect(() => { useLayoutEffect(() => {
const obj = ref.current; const obj = ref.current
if (!obj) return; if (!obj) return
// 1. Add to master map // 1. Add to master map
sceneRegistry.nodes.set(id, obj); sceneRegistry.nodes.set(id, obj)
// 2. Add to type-specific set // 2. Add to type-specific set
sceneRegistry.byType[type].add(id); sceneRegistry.byType[type].add(id)
// 4. Cleanup when component unmounts // 4. Cleanup when component unmounts
return () => { return () => {
sceneRegistry.nodes.delete(id); sceneRegistry.nodes.delete(id)
sceneRegistry.byType[type].delete(id); sceneRegistry.byType[type].delete(id)
}; }
}, [id, type, ref]); }, [id, type, ref])
} }
@@ -1,5 +1,5 @@
import { getScaledDimensions } from '../../schema'
import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'
import { getScaledDimensions } from '../../schema'
import { SpatialGrid } from './spatial-grid' import { SpatialGrid } from './spatial-grid'
import { WallSpatialGrid } from './wall-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 let inside = false
const n = polygon.length const n = polygon.length
for (let i = 0, j = n - 1; i < n; j = i++) { for (let i = 0, j = n - 1; i < n; j = i++) {
const xi = polygon[i]![0], zi = polygon[i]![1] const xi = polygon[i]![0],
const xj = polygon[j]![0], zj = polygon[j]![1] 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 inside = !inside
} }
} }
@@ -53,8 +55,14 @@ function getItemFootprint(
* Test if two line segments (a1->a2) and (b1->b2) intersect. * Test if two line segments (a1->a2) and (b1->b2) intersect.
*/ */
function segmentsIntersect( function segmentsIntersect(
ax1: number, az1: number, ax2: number, az2: number, ax1: number,
bx1: number, bz1: number, bx2: number, bz2: number, az1: number,
ax2: number,
az2: number,
bx1: number,
bz1: number,
bx2: number,
bz2: number,
): boolean { ): boolean {
const cross = (ox: number, oz: number, ax: number, az: number, bx: number, bz: number) => const cross = (ox: number, oz: number, ax: number, az: number, bx: number, bz: number) =>
(ax - ox) * (bz - oz) - (az - oz) * (bx - ox) (ax - ox) * (bz - oz) - (az - oz) * (bx - ox)
@@ -64,15 +72,16 @@ function segmentsIntersect(
const d3 = cross(ax1, az1, ax2, az2, bx1, bz1) const d3 = cross(ax1, az1, ax2, az2, bx1, bz1)
const d4 = cross(ax1, az1, ax2, az2, bx2, bz2) const d4 = cross(ax1, az1, ax2, az2, bx2, bz2)
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {
((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {
return true return true
} }
// Collinear touching cases // Collinear touching cases
const onSeg = (px: number, pz: number, qx: number, qz: number, rx: number, rz: number) => 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(px, qx) <= rx &&
Math.min(pz, qz) <= rz && rz <= Math.max(pz, qz) 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 (d1 === 0 && onSeg(bx1, bz1, bx2, bz2, ax1, az1)) return true
if (d2 === 0 && onSeg(bx1, bz1, bx2, bz2, ax2, az2)) 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. * Test if a line segment intersects any edge of a polygon.
*/ */
function segmentIntersectsPolygon( function segmentIntersectsPolygon(
sx1: number, sz1: number, sx2: number, sz2: number, sx1: number,
sz1: number,
sx2: number,
sz2: number,
polygon: Array<[number, number]>, polygon: Array<[number, number]>,
): boolean { ): boolean {
const n = polygon.length const n = polygon.length
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const j = (i + 1) % n const j = (i + 1) % n
if (segmentsIntersect( if (
sx1, sz1, sx2, sz2, segmentsIntersect(
polygon[i]![0], polygon[i]![1], polygon[j]![0], polygon[j]![1], sx1,
)) { sz1,
sx2,
sz2,
polygon[i]![0],
polygon[i]![1],
polygon[j]![0],
polygon[j]![1],
)
) {
return true return true
} }
} }
@@ -129,10 +149,16 @@ export function itemOverlapsPolygon(
// Check if any item edge intersects any polygon edge // Check if any item edge intersects any polygon edge
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
const j = (i + 1) % 4 const j = (i + 1) % 4
if (segmentIntersectsPolygon( if (
corners[i]![0], corners[i]![1], corners[j]![0], corners[j]![1], segmentIntersectsPolygon(
polygon, corners[i]![0],
)) return true corners[i]![1],
corners[j]![0],
corners[j]![1],
polygon,
)
)
return true
} }
return false return false
@@ -144,8 +170,14 @@ export function itemOverlapsPolygon(
* This prevents walls that just touch one point from being detected. * This prevents walls that just touch one point from being detected.
*/ */
function segmentsCollinearAndOverlap( function segmentsCollinearAndOverlap(
ax1: number, az1: number, ax2: number, az2: number, ax1: number,
bx1: number, bz1: number, bx2: number, bz2: number, az1: number,
ax2: number,
az2: number,
bx1: number,
bz1: number,
bx2: number,
bz2: number,
): boolean { ): boolean {
const EPSILON = 1e-6 const EPSILON = 1e-6
@@ -159,8 +191,10 @@ function segmentsCollinearAndOverlap(
// Check if a point is on segment b // Check if a point is on segment b
const onSegment = (px: number, pz: number, qx: number, qz: number, rx: number, rz: number) => 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(px, qx) - EPSILON <= rx &&
Math.min(pz, qz) - EPSILON <= rz && rz <= Math.max(pz, qz) + EPSILON 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 // BOTH endpoints of wall (a) must be on edge (b) for substantial overlap
const a1OnB = onSegment(bx1, bz1, bx2, bz2, ax1, az1) const a1OnB = onSegment(bx1, bz1, bx2, bz2, ax1, az1)
@@ -314,7 +348,7 @@ export class SpatialGridManager {
// position[1] is the bottom of the item // position[1] is the bottom of the item
this.getWallGrid(levelId).insert({ this.getWallGrid(levelId).insert({
itemId: item.id, itemId: item.id,
wallId: wallId, wallId,
tStart: t - halfW, tStart: t - halfW,
tEnd: t + halfW, tEnd: t + halfW,
yStart: item.position[1], yStart: item.position[1],
@@ -328,7 +362,12 @@ export class SpatialGridManager {
// Ceiling item - use parentId as the ceiling ID // Ceiling item - use parentId as the ceiling ID
const ceilingId = item.parentId const ceilingId = item.parentId
if (ceilingId && this.ceilings.has(ceilingId)) { 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) this.itemCeilingMap.set(item.id, ceilingId)
} }
} else if (!item.asset.attachTo) { } else if (!item.asset.attachTo) {
@@ -367,7 +406,7 @@ export class SpatialGridManager {
// position[1] is the bottom of the item // position[1] is the bottom of the item
this.getWallGrid(levelId).insert({ this.getWallGrid(levelId).insert({
itemId: item.id, itemId: item.id,
wallId: wallId, wallId,
tStart: t - halfW, tStart: t - halfW,
tEnd: t + halfW, tEnd: t + halfW,
yStart: item.position[1], yStart: item.position[1],
@@ -387,7 +426,12 @@ export class SpatialGridManager {
// Insert into new ceiling grid // Insert into new ceiling grid
const ceilingId = item.parentId const ceilingId = item.parentId
if (ceilingId && this.ceilings.has(ceilingId)) { 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) this.itemCeilingMap.set(item.id, ceilingId)
} }
} else if (!item.asset.attachTo) { } else if (!item.asset.attachTo) {
@@ -530,9 +574,12 @@ export class SpatialGridManager {
const slabMap = this.slabsByLevel.get(levelId) const slabMap = this.slabsByLevel.get(levelId)
if (!slabMap) return 0 if (!slabMap) return 0
let maxElevation = -Infinity let maxElevation = Number.NEGATIVE_INFINITY
for (const slab of slabMap.values()) { 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) // 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 // We consider it entirely in a hole if the item center is in the hole
let inHole = false 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). * Uses wallOverlapsPolygon which handles edge cases (points on boundary, collinear segments).
* Returns the highest slab elevation found, or 0 if none. * Returns the highest slab elevation found, or 0 if none.
*/ */
getSlabElevationForWall( getSlabElevationForWall(levelId: string, start: [number, number], end: [number, number]): number {
levelId: string,
start: [number, number],
end: [number, number],
): number {
const slabMap = this.slabsByLevel.get(levelId) const slabMap = this.slabsByLevel.get(levelId)
if (!slabMap) return 0 if (!slabMap) return 0
let maxElevation = -Infinity let maxElevation = Number.NEGATIVE_INFINITY
for (const slab of slabMap.values()) { for (const slab of slabMap.values()) {
if (slab.polygon.length < 3) continue if (slab.polygon.length < 3) continue
if (!wallOverlapsPolygon(start, end, slab.polygon)) continue if (!wallOverlapsPolygon(start, end, slab.polygon)) continue
@@ -609,7 +652,7 @@ export class SpatialGridManager {
if (elevation > maxElevation) maxElevation = elevation 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 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 { export function resolveLevelId(node: AnyNode, nodes: Record<string, AnyNode>): string {
// If the node itself is a level // If the node itself is a level
@@ -13,10 +24,10 @@ export function resolveLevelId(node: AnyNode, nodes: Record<string, AnyNode>): s
while (current) { while (current) {
if (current.type === 'level') return current.id if (current.type === 'level') return current.id
// Find parent (you might need to add parentId to your schema or derive it) // Find parent (you might need to add parentId to your schema or derive it)
if (!current.parentId) { if (current.parentId) {
current = undefined
} else {
current = nodes[current.parentId] current = nodes[current.parentId]
} else {
current = undefined
} }
} }
@@ -71,9 +82,11 @@ export function initSpatialGridSync() {
if (node.type === 'item' && prev.type === 'item') { if (node.type === 'item' && prev.type === 'item') {
if ( if (
!arraysEqual(node.position, prev.position) || !(
!arraysEqual(node.rotation, prev.rotation) || arraysEqual(node.position, prev.position) &&
!arraysEqual(node.scale, prev.scale) || arraysEqual(node.rotation, prev.rotation) &&
arraysEqual(node.scale, prev.scale)
) ||
node.parentId !== prev.parentId || node.parentId !== prev.parentId ||
node.side !== prev.side node.side !== prev.side
) { ) {
@@ -85,7 +98,11 @@ export function initSpatialGridSync() {
} }
} }
} else if (node.type === 'slab' && prev.type === 'slab') { } 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) const levelId = resolveLevelId(node, state.nodes)
spatialGridManager.handleNodeUpdated(node, levelId) spatialGridManager.handleNodeUpdated(node, levelId)
@@ -119,7 +136,15 @@ function markNodesOverlappingSlab(
// Only floor items are affected by slabs // Only floor items are affected by slabs
if (item.asset.attachTo) continue if (item.asset.attachTo) continue
if (resolveLevelId(node, nodes) !== slabLevelId) 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) markDirty(node.id)
} }
} else if (node.type === 'wall') { } else if (node.type === 'wall') {

Some files were not shown because too many files have changed in this diff Show More