Merge pull request #142 from pascalorg/chore/carving-out-community
Chore/carving out community
This commit is contained in:
@@ -1,11 +0,0 @@
|
|||||||
/**
|
|
||||||
* Better Auth API route handler
|
|
||||||
* Handles all /api/auth/* routes for authentication
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { toNextJsHandler } from 'better-auth/next-js'
|
|
||||||
import { auth } from '@/lib/auth'
|
|
||||||
|
|
||||||
const { GET, POST } = toNextJsHandler(auth)
|
|
||||||
|
|
||||||
export { GET, POST }
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function GET() {
|
||||||
|
return Response.json({ status: 'ok', app: 'editor', timestamp: new Date().toISOString() })
|
||||||
|
}
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { headers } from 'next/headers'
|
|
||||||
import { auth } from '@/lib/auth'
|
|
||||||
import { supabaseAdmin } from '@/lib/supabase/server'
|
|
||||||
|
|
||||||
// PUT /api/presets/[id]
|
|
||||||
// Accepts any subset of: name, data, is_community
|
|
||||||
export async function PUT(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
|
||||||
) {
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() })
|
|
||||||
if (!session?.user) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = await params
|
|
||||||
const body = await req.json()
|
|
||||||
const { name, data, is_community } = body
|
|
||||||
|
|
||||||
if (name === undefined && data === undefined && is_community === undefined) {
|
|
||||||
return NextResponse.json({ error: 'Nothing to update' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingResult = await supabaseAdmin
|
|
||||||
.from('presets')
|
|
||||||
.select('user_id')
|
|
||||||
.eq('id', id)
|
|
||||||
.single()
|
|
||||||
const existing = existingResult.data as { user_id: string | null } | null
|
|
||||||
|
|
||||||
if (!existing || existing.user_id !== session.user.id) {
|
|
||||||
return NextResponse.json({ error: 'Not found or forbidden' }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const updates: Record<string, unknown> = {}
|
|
||||||
if (name !== undefined) updates.name = name
|
|
||||||
if (data !== undefined) updates.data = data
|
|
||||||
if (is_community !== undefined) updates.is_community = is_community
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const { data: preset, error } = await (supabaseAdmin as any)
|
|
||||||
.from('presets')
|
|
||||||
.update(updates)
|
|
||||||
.eq('id', id)
|
|
||||||
.select()
|
|
||||||
.single()
|
|
||||||
|
|
||||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
|
||||||
return NextResponse.json({ preset })
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE /api/presets/[id]
|
|
||||||
export async function DELETE(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
|
||||||
) {
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() })
|
|
||||||
if (!session?.user) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = await params
|
|
||||||
|
|
||||||
const existingResult = await supabaseAdmin
|
|
||||||
.from('presets')
|
|
||||||
.select('user_id, thumbnail_url')
|
|
||||||
.eq('id', id)
|
|
||||||
.single()
|
|
||||||
const existing = existingResult.data as { user_id: string | null; thumbnail_url: string | null } | null
|
|
||||||
|
|
||||||
if (!existing || existing.user_id !== session.user.id) {
|
|
||||||
return NextResponse.json({ error: 'Not found or forbidden' }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete thumbnail from storage if present
|
|
||||||
if (existing.thumbnail_url) {
|
|
||||||
const url = existing.thumbnail_url as string
|
|
||||||
const match = url.match(/preset-thumbnails\/(.+)$/)
|
|
||||||
if (match?.[1]) {
|
|
||||||
await supabaseAdmin.storage.from('preset-thumbnails').remove([match[1]])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error } = await supabaseAdmin.from('presets').delete().eq('id', id)
|
|
||||||
|
|
||||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
|
||||||
return NextResponse.json({ success: true })
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { headers } from 'next/headers'
|
|
||||||
import { auth } from '@/lib/auth'
|
|
||||||
import { supabaseAdmin } from '@/lib/supabase/server'
|
|
||||||
|
|
||||||
// POST /api/presets/[id]/thumbnail
|
|
||||||
// Accepts a raw PNG blob, uploads to preset-thumbnails bucket, updates thumbnail_url
|
|
||||||
export async function POST(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
|
||||||
) {
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() })
|
|
||||||
if (!session?.user) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = await params
|
|
||||||
|
|
||||||
const existingResult = await supabaseAdmin
|
|
||||||
.from('presets')
|
|
||||||
.select('user_id')
|
|
||||||
.eq('id', id)
|
|
||||||
.single()
|
|
||||||
const existing = existingResult.data as { user_id: string | null } | null
|
|
||||||
|
|
||||||
if (!existing || existing.user_id !== session.user.id) {
|
|
||||||
return NextResponse.json({ error: 'Not found or forbidden' }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await req.blob()
|
|
||||||
|
|
||||||
const filename = `${id}/thumbnail.png`
|
|
||||||
const { data: uploadData, error: uploadError } = await supabaseAdmin.storage
|
|
||||||
.from('preset-thumbnails')
|
|
||||||
.upload(filename, blob, {
|
|
||||||
contentType: 'image/png',
|
|
||||||
upsert: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (uploadError) {
|
|
||||||
return NextResponse.json({ error: uploadError.message }, { status: 500 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: urlData } = supabaseAdmin.storage
|
|
||||||
.from('preset-thumbnails')
|
|
||||||
.getPublicUrl(uploadData.path)
|
|
||||||
|
|
||||||
const thumbnailUrl = `${urlData.publicUrl}?t=${Date.now()}`
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const { error: updateError } = await (supabaseAdmin as any)
|
|
||||||
.from('presets')
|
|
||||||
.update({ thumbnail_url: thumbnailUrl })
|
|
||||||
.eq('id', id)
|
|
||||||
|
|
||||||
if (updateError) {
|
|
||||||
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ thumbnail_url: thumbnailUrl })
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { headers } from 'next/headers'
|
|
||||||
import { auth } from '@/lib/auth'
|
|
||||||
import { supabaseAdmin } from '@/lib/supabase/server'
|
|
||||||
import { createId } from '@pascal-app/db'
|
|
||||||
|
|
||||||
// GET /api/presets?type=door|window&tab=community|mine
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
const { searchParams } = req.nextUrl
|
|
||||||
const type = searchParams.get('type')
|
|
||||||
const tab = searchParams.get('tab') ?? 'community'
|
|
||||||
|
|
||||||
if (!type || (type !== 'door' && type !== 'window')) {
|
|
||||||
return NextResponse.json({ error: 'Invalid type' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tab === 'mine') {
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() })
|
|
||||||
if (!session?.user) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data, error } = await supabaseAdmin
|
|
||||||
.from('presets')
|
|
||||||
.select('*')
|
|
||||||
.eq('type', type)
|
|
||||||
.eq('user_id', session.user.id)
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
|
|
||||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
|
||||||
return NextResponse.json({ presets: data })
|
|
||||||
}
|
|
||||||
|
|
||||||
// community tab
|
|
||||||
const { data, error } = await supabaseAdmin
|
|
||||||
.from('presets')
|
|
||||||
.select('*')
|
|
||||||
.eq('type', type)
|
|
||||||
.eq('is_community', true)
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
|
|
||||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
|
||||||
return NextResponse.json({ presets: data })
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /api/presets
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() })
|
|
||||||
if (!session?.user) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await req.json()
|
|
||||||
const { type, name, data, thumbnailUrl } = body
|
|
||||||
|
|
||||||
if (!type || !name || !data) {
|
|
||||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type !== 'door' && type !== 'window') {
|
|
||||||
return NextResponse.json({ error: 'Invalid type' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const { data: preset, error } = await (supabaseAdmin as any)
|
|
||||||
.from('presets')
|
|
||||||
.insert({
|
|
||||||
id: createId('preset'),
|
|
||||||
type,
|
|
||||||
name,
|
|
||||||
data,
|
|
||||||
thumbnail_url: thumbnailUrl ?? null,
|
|
||||||
user_id: session.user.id,
|
|
||||||
is_community: false,
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single()
|
|
||||||
|
|
||||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
|
||||||
return NextResponse.json({ preset }, { status: 201 })
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
|
|
||||||
export default function EditorRouteError({
|
|
||||||
error,
|
|
||||||
reset,
|
|
||||||
}: Readonly<{
|
|
||||||
error: Error & { digest?: string }
|
|
||||||
reset: () => void
|
|
||||||
}>) {
|
|
||||||
useEffect(() => {
|
|
||||||
console.error('[editor-route] Unhandled editor error:', error)
|
|
||||||
}, [error])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen w-full items-center justify-center bg-background p-4 text-foreground">
|
|
||||||
<div className="w-full max-w-md rounded-2xl border border-border/60 bg-background p-6 shadow-xl">
|
|
||||||
<h1 className="text-lg font-semibold">Editor error</h1>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
We couldn't load this editor route. You can retry or return home.
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
className="rounded-md border border-border bg-accent px-3 py-2 text-sm font-medium hover:bg-accent/80"
|
|
||||||
onClick={reset}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
<Link
|
|
||||||
className="rounded-md border border-border bg-background px-3 py-2 text-sm font-medium hover:bg-accent/40"
|
|
||||||
href="/"
|
|
||||||
>
|
|
||||||
Back to home
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import type { Metadata } from 'next'
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'Editor Workspace',
|
|
||||||
description: 'Edit your Pascal projects in a full 3D workspace.',
|
|
||||||
robots: {
|
|
||||||
index: false,
|
|
||||||
follow: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EditorProjectLayout({
|
|
||||||
children,
|
|
||||||
}: Readonly<{
|
|
||||||
children: React.ReactNode
|
|
||||||
}>) {
|
|
||||||
return (
|
|
||||||
<div style={{ cursor: "url('/cursor.svg') 4 2, default" }}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import Editor from '@/components/editor'
|
|
||||||
import { SceneLoader } from '@/components/ui/scene-loader'
|
|
||||||
import { useAuth } from '@/features/community/lib/auth/hooks'
|
|
||||||
import { useProjectStore } from '@/features/community/lib/projects/store'
|
|
||||||
|
|
||||||
export default function EditorPage() {
|
|
||||||
const params = useParams()
|
|
||||||
const projectId = params.projectId as string
|
|
||||||
const { isAuthenticated, isLoading } = useAuth()
|
|
||||||
const setActiveProject = useProjectStore((state) => state.setActiveProject)
|
|
||||||
const router = useRouter()
|
|
||||||
const [mounted, setMounted] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Use layoutEffect to set active project BEFORE the editor renders and hooks run
|
|
||||||
useEffect(() => {
|
|
||||||
if (isLoading) return
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
router.replace('/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (projectId) {
|
|
||||||
setActiveProject(projectId)
|
|
||||||
}
|
|
||||||
}, [projectId, isAuthenticated, isLoading, setActiveProject, router])
|
|
||||||
|
|
||||||
if (!mounted || isLoading) {
|
|
||||||
return <SceneLoader fullScreen />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-screen w-full max-w-screen">
|
|
||||||
<div className="relative h-full w-full">
|
|
||||||
<Editor projectId={projectId} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+139
-31
@@ -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,24 +175,42 @@
|
|||||||
.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%,
|
||||||
@@ -193,16 +220,36 @@
|
|||||||
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%,
|
||||||
@@ -211,45 +258,106 @@
|
|||||||
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%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
import { readFile } from 'node:fs/promises'
|
|
||||||
import { join } from 'node:path'
|
|
||||||
import { ImageResponse } from 'next/og'
|
|
||||||
import * as z from 'zod'
|
|
||||||
import { siteConfig } from '@/app/seo'
|
|
||||||
|
|
||||||
const ogImageSchema = z.object({
|
|
||||||
title: z.string().default(''),
|
|
||||||
description: z.string().default(''),
|
|
||||||
theme: z.enum(['light', 'dark']).default('dark'),
|
|
||||||
})
|
|
||||||
|
|
||||||
let sansFont: ArrayBuffer | null = null
|
|
||||||
let jakartaFont: ArrayBuffer | null = null
|
|
||||||
|
|
||||||
async function loadFonts() {
|
|
||||||
if (!(sansFont && jakartaFont)) {
|
|
||||||
const fontDir = join(process.cwd(), 'public', 'fonts')
|
|
||||||
const [sans, jakarta] = await Promise.all([
|
|
||||||
readFile(join(fontDir, 'geist-regular.ttf')),
|
|
||||||
readFile(join(fontDir, 'PlusJakartaSans-SemiBold.ttf')),
|
|
||||||
])
|
|
||||||
sansFont = sans.buffer.slice(sans.byteOffset, sans.byteOffset + sans.byteLength)
|
|
||||||
jakartaFont = jakarta.buffer.slice(jakarta.byteOffset, jakarta.byteOffset + jakarta.byteLength)
|
|
||||||
}
|
|
||||||
return { sans: sansFont, jakarta: jakartaFont }
|
|
||||||
}
|
|
||||||
|
|
||||||
function PascalLogo({ color = '#fff', size = 100 }: { color?: string; size?: number }) {
|
|
||||||
return (
|
|
||||||
<svg fill="none" height={size} style={{ display: 'flex' }} viewBox="0 0 100 100" width={size}>
|
|
||||||
<rect fill={color} height="40" width="20" y="60" />
|
|
||||||
<rect fill={color} height="40" width="20" x="40" y="30" />
|
|
||||||
<rect fill={color} height="40" width="20" x="80" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
|
||||||
try {
|
|
||||||
const url = new URL(req.url)
|
|
||||||
const values = ogImageSchema.parse(Object.fromEntries(url.searchParams))
|
|
||||||
const heading =
|
|
||||||
values.title.length > 140 ? `${values.title.substring(0, 140)}...` : values.title
|
|
||||||
|
|
||||||
const { theme } = values
|
|
||||||
const paint = theme === 'dark' ? '#fff' : '#000'
|
|
||||||
const logoColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.9)'
|
|
||||||
|
|
||||||
const fontSize = heading.length > 100 ? '70px' : '100px'
|
|
||||||
|
|
||||||
const showLargeLogo = !(values.title || values.description)
|
|
||||||
|
|
||||||
return new ImageResponse(
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
color: paint,
|
|
||||||
background:
|
|
||||||
theme === 'dark'
|
|
||||||
? 'linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 50%, #0f0f0f 100%)'
|
|
||||||
: 'white',
|
|
||||||
}}
|
|
||||||
tw="flex relative flex-col w-full h-full items-start bg-cover"
|
|
||||||
>
|
|
||||||
{showLargeLogo ? (
|
|
||||||
<div tw="flex flex-col flex-1 py-10 px-12 h-full justify-center">
|
|
||||||
<div style={{ display: 'flex', marginBottom: 24 }}>
|
|
||||||
<PascalLogo color={logoColor} size={80} />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: 'jakarta-semibold',
|
|
||||||
fontWeight: 'bolder',
|
|
||||||
fontSize: '72px',
|
|
||||||
letterSpacing: '-0.02em',
|
|
||||||
color: paint,
|
|
||||||
display: 'flex',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Pascal Editor
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: 'sans-regular',
|
|
||||||
fontWeight: 'normal',
|
|
||||||
fontSize: '28px',
|
|
||||||
letterSpacing: '0.05em',
|
|
||||||
color: 'rgba(255, 255, 255, 0.6)',
|
|
||||||
marginTop: 16,
|
|
||||||
display: 'flex',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{siteConfig.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div tw="flex flex-col flex-1 py-10 px-12 h-full">
|
|
||||||
<div style={{ display: 'flex', marginBottom: 24 }}>
|
|
||||||
<PascalLogo color={logoColor} size={40} />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: 'jakarta-semibold',
|
|
||||||
fontWeight: 'bolder',
|
|
||||||
marginLeft: '-3px',
|
|
||||||
fontSize,
|
|
||||||
letterSpacing: '.05rem',
|
|
||||||
}}
|
|
||||||
tw="flex leading-[1.1] text-[80px] tracking-tighter font-sans mb-4"
|
|
||||||
>
|
|
||||||
{heading}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: 'sans-regular',
|
|
||||||
fontWeight: 'normal',
|
|
||||||
letterSpacing: '.1rem',
|
|
||||||
}}
|
|
||||||
tw="flex flex-1 text-[50px] tracking-tight font-sans"
|
|
||||||
>
|
|
||||||
{values.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!showLargeLogo && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: 'sans-regular',
|
|
||||||
fontWeight: 'normal',
|
|
||||||
letterSpacing: '.1rem',
|
|
||||||
}}
|
|
||||||
tw="w-full pt-24 p-10 text-[30px] tracking-tight font-sans text-right w-full"
|
|
||||||
>
|
|
||||||
editor.pascal.app
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>,
|
|
||||||
{
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
fonts: await loadFonts().then(({ sans, jakarta }) => [
|
|
||||||
{
|
|
||||||
name: 'sans-regular',
|
|
||||||
data: sans,
|
|
||||||
style: 'normal' as const,
|
|
||||||
weight: 400 as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'jakarta-semibold',
|
|
||||||
data: jakarta,
|
|
||||||
style: 'normal' as const,
|
|
||||||
weight: 600 as const,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
console.log('error', error)
|
|
||||||
return new Response('Failed to generate image', {
|
|
||||||
status: 500,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import type { Metadata } from 'next'
|
'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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. ("we," "us," or "our") operates the Pascal Editor and
|
Pascal Group Inc. ("we," "us," or "our") 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's Privacy</h2>
|
<h2 className="font-semibold text-xl">9. Children'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>
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import type { MetadataRoute } from 'next'
|
|
||||||
import { siteConfig } from './seo'
|
|
||||||
|
|
||||||
export default function robots(): MetadataRoute.Robots {
|
|
||||||
return {
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
userAgent: '*',
|
|
||||||
allow: ['/', '/viewer/', '/u/'],
|
|
||||||
disallow: ['/api/', '/editor/', '/settings', '/_next/'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
sitemap: `${siteConfig.url}/sitemap.xml`,
|
|
||||||
host: siteConfig.url,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { BASE_URL } from '@/lib/utils'
|
|
||||||
|
|
||||||
export const siteConfig = {
|
|
||||||
name: 'Pascal Editor',
|
|
||||||
description:
|
|
||||||
'Pascal Editor is an open-source 3D building editor for designing, editing, and sharing home projects.',
|
|
||||||
url: BASE_URL,
|
|
||||||
website: 'editor.pascal.app',
|
|
||||||
ogImage: '/og',
|
|
||||||
keywords: [
|
|
||||||
'Pascal Editor',
|
|
||||||
'open-source 3D editor',
|
|
||||||
'3D building editor',
|
|
||||||
'home design software',
|
|
||||||
'architecture editor',
|
|
||||||
'collaborative design',
|
|
||||||
],
|
|
||||||
twitterHandle: '@pascal_app',
|
|
||||||
links: {
|
|
||||||
github: 'https://github.com/pascalorg/editor',
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
import type { Metadata } from 'next'
|
|
||||||
import { redirect } from 'next/navigation'
|
|
||||||
import { getSession } from '@/features/community/lib/auth/server'
|
|
||||||
import { getUserProfile, getConnectedAccounts } from '@/features/community/lib/auth/actions'
|
|
||||||
import { SettingsPage } from '@/features/community/components/settings-page'
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'Account Settings',
|
|
||||||
description: 'Manage your Pascal Editor account profile and connected providers.',
|
|
||||||
robots: {
|
|
||||||
index: false,
|
|
||||||
follow: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function Settings() {
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session?.user) {
|
|
||||||
redirect('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
const [profile, connectedAccounts] = await Promise.all([
|
|
||||||
getUserProfile(),
|
|
||||||
getConnectedAccounts(),
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsPage
|
|
||||||
user={session.user}
|
|
||||||
currentUsername={profile?.username ?? null}
|
|
||||||
currentGithubUrl={profile?.githubUrl ?? null}
|
|
||||||
currentXUrl={profile?.xUrl ?? null}
|
|
||||||
currentYoutubeUrl={profile?.youtubeUrl ?? null}
|
|
||||||
currentEmailNotifications={profile?.emailNotifications ?? true}
|
|
||||||
connectedAccounts={connectedAccounts}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import type { MetadataRoute } from 'next'
|
|
||||||
import { siteConfig } from './seo'
|
|
||||||
|
|
||||||
export default function sitemap(): MetadataRoute.Sitemap {
|
|
||||||
const currentDate = new Date()
|
|
||||||
const baseUrl = siteConfig.url
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
url: baseUrl,
|
|
||||||
lastModified: currentDate,
|
|
||||||
changeFrequency: 'daily',
|
|
||||||
priority: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: `${baseUrl}/viewer/demo_1`,
|
|
||||||
lastModified: currentDate,
|
|
||||||
changeFrequency: 'weekly',
|
|
||||||
priority: 0.7,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -9,21 +9,21 @@ export const metadata: Metadata = {
|
|||||||
export default function TermsPage() {
|
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 ("Editor") and the Pascal platform at pascal.app
|
Welcome to Pascal Editor ("Editor") and the Pascal platform at pascal.app
|
||||||
("Platform"), operated by Pascal Group Inc. ("we," "us," or "our").
|
("Platform"), operated by Pascal Group Inc. ("we," "us,"
|
||||||
By accessing or using our services, you agree to these Terms of Service.
|
or "our"). 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's infrastructure</li>
|
<li>Interfere with or disrupt the Platform'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 ("Your Content"). By using the Platform, you grant us a limited
|
the Platform ("Your Content"). By using the Platform, you grant us a limited
|
||||||
license to store, display, and transmit Your Content solely to provide our services
|
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 "AS IS" AND "AS AVAILABLE" WITHOUT WARRANTIES OF ANY
|
THE PLATFORM IS PROVIDED "AS IS" AND "AS AVAILABLE" 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>
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
import type { Metadata } from 'next'
|
|
||||||
import { notFound } from 'next/navigation'
|
|
||||||
import { getPublicProfile } from '@/features/community/lib/auth/actions'
|
|
||||||
import { getPublicProjectsByUserId } from '@/features/community/lib/projects/actions'
|
|
||||||
import { PublicProfilePage } from '@/features/community/components/public-profile-page'
|
|
||||||
|
|
||||||
export async function generateMetadata({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ username: string }>
|
|
||||||
}): Promise<Metadata> {
|
|
||||||
const { username } = await params
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: `${username}'s Projects`,
|
|
||||||
description: `Public projects shared by @${username} on Pascal Editor.`,
|
|
||||||
alternates: {
|
|
||||||
canonical: `/u/${encodeURIComponent(username)}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ProfilePage({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ username: string }>
|
|
||||||
}) {
|
|
||||||
const { username } = await params
|
|
||||||
const profileResult = await getPublicProfile(username)
|
|
||||||
|
|
||||||
if (!profileResult.success || !profileResult.data) {
|
|
||||||
notFound()
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectsResult = await getPublicProjectsByUserId(profileResult.data.id)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PublicProfilePage
|
|
||||||
profile={profileResult.data}
|
|
||||||
projects={projectsResult.data ?? []}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import {
|
|
||||||
type AnyNodeId,
|
|
||||||
type CollectionId,
|
|
||||||
type Control,
|
|
||||||
type ControlValue,
|
|
||||||
type ItemNode,
|
|
||||||
useInteractive,
|
|
||||||
useScene,
|
|
||||||
} from '@pascal-app/core'
|
|
||||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
|
||||||
import { useMemo, useState } from 'react'
|
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
// ─── Shared control derivation ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
type ItemControlRef = { itemId: AnyNodeId; controlIndex: number }
|
|
||||||
|
|
||||||
type SharedControlDef = {
|
|
||||||
kind: 'toggle' | 'slider' | 'temperature'
|
|
||||||
label?: string
|
|
||||||
min?: number
|
|
||||||
max?: number
|
|
||||||
step?: number
|
|
||||||
unit?: string
|
|
||||||
refs: ItemControlRef[]
|
|
||||||
}
|
|
||||||
|
|
||||||
function deriveSharedControls(items: ItemNode[]): SharedControlDef[] {
|
|
||||||
if (items.length === 0) return []
|
|
||||||
const result: SharedControlDef[] = []
|
|
||||||
|
|
||||||
for (const kind of ['toggle', 'slider', 'temperature'] as const) {
|
|
||||||
const refs: ItemControlRef[] = []
|
|
||||||
let ref: Control | null = null
|
|
||||||
let allHave = true
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const idx = item.asset.interactive!.controls.findIndex((c) => c.kind === kind)
|
|
||||||
if (idx === -1) { allHave = false; break }
|
|
||||||
refs.push({ itemId: item.id, controlIndex: idx })
|
|
||||||
if (!ref) ref = item.asset.interactive!.controls[idx]!
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!allHave || !ref) continue
|
|
||||||
|
|
||||||
const def: SharedControlDef = { kind, label: ref.label, refs }
|
|
||||||
if ('min' in ref) { def.min = ref.min; def.max = ref.max }
|
|
||||||
if ('step' in ref) def.step = (ref as { step?: number }).step
|
|
||||||
if ('unit' in ref) def.unit = (ref as { unit?: string }).unit
|
|
||||||
result.push(def)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Shared control widget ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function SharedWidget({ def, value, onChange }: { def: SharedControlDef; value: ControlValue; onChange: (v: ControlValue) => void }) {
|
|
||||||
if (def.kind === 'toggle') {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onChange(!value)}
|
|
||||||
className={cn(
|
|
||||||
'flex h-7 w-full items-center justify-center rounded-md px-3 text-xs font-medium transition-colors',
|
|
||||||
value ? 'bg-green-500/20 text-green-400' : 'bg-white/10 text-muted-foreground hover:text-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{def.label ?? (value ? 'On' : 'Off')}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
|
|
||||||
<span>{def.label ?? def.kind}</span>
|
|
||||||
<span>{value}{def.kind === 'temperature' ? '°' : ''}{def.unit ? ` ${def.unit}` : ''}</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={def.min}
|
|
||||||
max={def.max}
|
|
||||||
step={def.step ?? 1}
|
|
||||||
value={value as number}
|
|
||||||
onChange={(e) => onChange(Number(e.target.value))}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
className="w-full accent-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Individual item control widget ──────────────────────────────────────────
|
|
||||||
|
|
||||||
function ItemWidget({ control, value, onChange }: { control: Control; value: ControlValue; onChange: (v: ControlValue) => void }) {
|
|
||||||
if (control.kind === 'toggle') {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onChange(!value)}
|
|
||||||
className={cn(
|
|
||||||
'flex h-6 w-full items-center justify-center rounded px-2 text-[10px] font-medium transition-colors',
|
|
||||||
value ? 'bg-green-500/20 text-green-400' : 'bg-white/5 text-muted-foreground hover:text-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{control.label ?? (value ? 'On' : 'Off')}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
|
|
||||||
<span>{control.label ?? control.kind}</span>
|
|
||||||
<span>{value}{control.kind === 'temperature' ? '°' : ''}{'unit' in control && control.unit ? ` ${control.unit}` : ''}</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={'min' in control ? control.min : 0}
|
|
||||||
max={'max' in control ? control.max : 100}
|
|
||||||
step={'step' in control ? (control as { step?: number }).step ?? 1 : 1}
|
|
||||||
value={value as number}
|
|
||||||
onChange={(e) => onChange(Number(e.target.value))}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
className="w-full accent-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Collection row ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function CollectionRow({ collectionId }: { collectionId: CollectionId }) {
|
|
||||||
const collection = useScene((s) => s.collections[collectionId])
|
|
||||||
|
|
||||||
const interactiveItems = useScene(
|
|
||||||
useShallow((s) =>
|
|
||||||
(collection?.nodeIds ?? [])
|
|
||||||
.map((id) => s.nodes[id])
|
|
||||||
.filter((n): n is ItemNode => n?.type === 'item' && !!n.asset.interactive)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const allItems = useInteractive((s) => s.items)
|
|
||||||
const controlValuesByItem = useMemo(
|
|
||||||
() => Object.fromEntries(interactiveItems.map((n) => [n.id, allItems[n.id]?.controlValues ?? []])),
|
|
||||||
[allItems, interactiveItems],
|
|
||||||
)
|
|
||||||
|
|
||||||
const setControlValue = useInteractive((s) => s.setControlValue)
|
|
||||||
|
|
||||||
const [expanded, setExpanded] = useState(false)
|
|
||||||
const [expandedItemIds, setExpandedItemIds] = useState<Set<AnyNodeId>>(new Set())
|
|
||||||
|
|
||||||
if (!collection) return null
|
|
||||||
|
|
||||||
const sharedControls = deriveSharedControls(interactiveItems)
|
|
||||||
|
|
||||||
const getSharedValue = (def: SharedControlDef): ControlValue => {
|
|
||||||
if (def.kind === 'toggle') {
|
|
||||||
return def.refs.every(({ itemId, controlIndex }) => Boolean(controlValuesByItem[itemId]?.[controlIndex]))
|
|
||||||
}
|
|
||||||
const first = def.refs[0]!
|
|
||||||
return controlValuesByItem[first.itemId]?.[first.controlIndex] ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const setSharedValue = (def: SharedControlDef, value: ControlValue) => {
|
|
||||||
for (const { itemId, controlIndex } of def.refs) {
|
|
||||||
setControlValue(itemId, controlIndex, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleItemExpand = (id: AnyNodeId) => {
|
|
||||||
setExpandedItemIds((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
if (next.has(id)) next.delete(id)
|
|
||||||
else next.add(id)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Header */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setExpanded((v) => !v)}
|
|
||||||
className="flex w-full items-center gap-2 px-3 py-2.5 hover:bg-white/5 transition-colors"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="h-2.5 w-2.5 shrink-0 rounded-sm border border-white/20"
|
|
||||||
style={{ backgroundColor: collection.color ?? '#6366f1' }}
|
|
||||||
/>
|
|
||||||
<span className="flex-1 min-w-0 text-xs font-medium text-foreground truncate text-left">
|
|
||||||
{collection.name}
|
|
||||||
</span>
|
|
||||||
{interactiveItems.length > 0 && (
|
|
||||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
|
||||||
{interactiveItems.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{expanded
|
|
||||||
? <ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
|
|
||||||
: <ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Expanded */}
|
|
||||||
{expanded && (
|
|
||||||
<div className="pb-1">
|
|
||||||
{interactiveItems.length === 0 ? (
|
|
||||||
<p className="px-3 pb-2 text-[11px] text-muted-foreground">No interactive items.</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Shared controls */}
|
|
||||||
{sharedControls.length > 0 && (
|
|
||||||
<div className="px-3 pt-0.5 pb-2.5 border-b border-border/30">
|
|
||||||
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mb-2">All</p>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{sharedControls.map((def, i) => (
|
|
||||||
<SharedWidget
|
|
||||||
key={i}
|
|
||||||
def={def}
|
|
||||||
value={getSharedValue(def)}
|
|
||||||
onChange={(v) => setSharedValue(def, v)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Individual items */}
|
|
||||||
{interactiveItems.map((item) => {
|
|
||||||
const isItemExpanded = expandedItemIds.has(item.id)
|
|
||||||
const controls = item.asset.interactive!.controls
|
|
||||||
const values = controlValuesByItem[item.id] ?? []
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={item.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleItemExpand(item.id)}
|
|
||||||
className="flex w-full items-center gap-1.5 px-3 py-1.5 hover:bg-white/5 transition-colors"
|
|
||||||
>
|
|
||||||
{isItemExpanded
|
|
||||||
? <ChevronDown className="h-2.5 w-2.5 shrink-0 text-muted-foreground/50" />
|
|
||||||
: <ChevronRight className="h-2.5 w-2.5 shrink-0 text-muted-foreground/50" />}
|
|
||||||
<span className="flex-1 min-w-0 text-[11px] text-muted-foreground truncate text-left">
|
|
||||||
{item.name || item.asset.name}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isItemExpanded && (
|
|
||||||
<div className="px-3 pb-2 flex flex-col gap-1.5">
|
|
||||||
{controls.map((control, i) => (
|
|
||||||
<ItemWidget
|
|
||||||
key={i}
|
|
||||||
control={control}
|
|
||||||
value={values[i] ?? false}
|
|
||||||
onChange={(v) => setControlValue(item.id, i, v)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main panel ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function CollectionsPanel() {
|
|
||||||
const collectionIds = useScene(
|
|
||||||
useShallow((s) => Object.keys(s.collections) as CollectionId[]),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (collectionIds.length === 0) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pointer-events-auto flex flex-col rounded-2xl border border-border/40 bg-background/95 shadow-lg backdrop-blur-xl overflow-hidden w-56">
|
|
||||||
<div className="px-3 py-2 border-b border-border/40 shrink-0">
|
|
||||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Collections</span>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-y-auto max-h-[70vh] no-scrollbar divide-y divide-border/30">
|
|
||||||
{collectionIds.map((id) => (
|
|
||||||
<CollectionRow key={id} collectionId={id} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
|
|
||||||
export default function ViewerRouteError({
|
|
||||||
error,
|
|
||||||
reset,
|
|
||||||
}: Readonly<{
|
|
||||||
error: Error & { digest?: string }
|
|
||||||
reset: () => void
|
|
||||||
}>) {
|
|
||||||
useEffect(() => {
|
|
||||||
console.error('[viewer-route] Unhandled viewer error:', error)
|
|
||||||
}, [error])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen w-full items-center justify-center bg-background p-4 text-foreground">
|
|
||||||
<div className="w-full max-w-md rounded-2xl border border-border/60 bg-background p-6 shadow-xl">
|
|
||||||
<h1 className="text-lg font-semibold">Viewer error</h1>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
We couldn't load this project view. You can retry without leaving the app.
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
className="rounded-md border border-border bg-accent px-3 py-2 text-sm font-medium hover:bg-accent/80"
|
|
||||||
onClick={reset}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
<Link
|
|
||||||
className="rounded-md border border-border bg-background px-3 py-2 text-sm font-medium hover:bg-accent/40"
|
|
||||||
href="/"
|
|
||||||
>
|
|
||||||
Back to home
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import type { Metadata } from 'next'
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'Project Viewer',
|
|
||||||
description: 'View and share 3D projects built with Pascal Editor.',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ViewerLayout({
|
|
||||||
children,
|
|
||||||
}: Readonly<{
|
|
||||||
children: React.ReactNode
|
|
||||||
}>) {
|
|
||||||
return children
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { initSpatialGridSync, useScene } from '@pascal-app/core'
|
|
||||||
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { useParams } from 'next/navigation'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { ErrorBoundary } from '@/components/ui/primitives/error-boundary'
|
|
||||||
import { SceneLoader } from '@/components/ui/scene-loader'
|
|
||||||
import {
|
|
||||||
getProjectModelPublic,
|
|
||||||
incrementProjectViews,
|
|
||||||
} from '@/features/community/lib/projects/actions'
|
|
||||||
import type { ProjectOwner } from '@/features/community/lib/projects/types'
|
|
||||||
import { ViewerCameraControls } from './viewer-camera-controls'
|
|
||||||
import { ViewerGuestCTA } from './viewer-guest-cta'
|
|
||||||
import { ViewerOverlay } from './viewer-overlay'
|
|
||||||
import { ViewerZoneSystem } from './viewer-zone-system'
|
|
||||||
|
|
||||||
function ViewerSceneCrashFallback({ projectName }: { projectName?: string | null }) {
|
|
||||||
return (
|
|
||||||
<div className="absolute inset-0 z-30 flex items-center justify-center bg-background/95 p-4 text-foreground">
|
|
||||||
<div className="w-full max-w-md rounded-2xl border border-border/60 bg-background p-6 shadow-xl">
|
|
||||||
<h2 className="text-lg font-semibold">The 3D scene failed to render</h2>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
{projectName ? `"${projectName}" ` : ''}
|
|
||||||
hit a rendering error. The rest of the app is still available.
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
className="rounded-md border border-border bg-accent px-3 py-2 text-sm font-medium hover:bg-accent/80"
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Reload scene
|
|
||||||
</button>
|
|
||||||
<Link
|
|
||||||
className="rounded-md border border-border bg-background px-3 py-2 text-sm font-medium hover:bg-accent/40"
|
|
||||||
href="/"
|
|
||||||
>
|
|
||||||
Back to home
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ViewerPage() {
|
|
||||||
const params = useParams()
|
|
||||||
const id = params.id as string
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [projectId, setProjectId] = useState<string | null>(null)
|
|
||||||
const [projectName, setProjectName] = useState<string | null>(null)
|
|
||||||
const [owner, setOwner] = useState<ProjectOwner | null>(null)
|
|
||||||
const [canShowScans, setCanShowScans] = useState(true)
|
|
||||||
const [canShowGuides, setCanShowGuides] = useState(true)
|
|
||||||
const setScene = useScene((state) => state.setScene)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
useViewer.getState().setProjectId(projectId)
|
|
||||||
}, [projectId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
setProjectId(null)
|
|
||||||
setProjectName(null)
|
|
||||||
setOwner(null)
|
|
||||||
setCanShowScans(true)
|
|
||||||
setCanShowGuides(true)
|
|
||||||
useViewer.getState().setShowScans(true)
|
|
||||||
useViewer.getState().setShowGuides(true)
|
|
||||||
|
|
||||||
const loadContent = async () => {
|
|
||||||
try {
|
|
||||||
// Check if it's a demo file (starts with 'demo_')
|
|
||||||
if (id.startsWith('demo_')) {
|
|
||||||
const response = await fetch(`/demos/${id}.json`)
|
|
||||||
if (cancelled) return
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Demo "${id}" not found`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
if (cancelled) return
|
|
||||||
|
|
||||||
if (data.nodes && data.rootNodeIds) {
|
|
||||||
setScene(data.nodes, data.rootNodeIds)
|
|
||||||
initSpatialGridSync()
|
|
||||||
}
|
|
||||||
|
|
||||||
setProjectName('Demo')
|
|
||||||
} else {
|
|
||||||
// Load from database (public project)
|
|
||||||
const result = await getProjectModelPublic(id)
|
|
||||||
if (cancelled) return
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
const { project, model, isOwner } = result.data
|
|
||||||
const projectData = project as any
|
|
||||||
|
|
||||||
setProjectId(project.id)
|
|
||||||
setProjectName(project.name)
|
|
||||||
setOwner(projectData.owner ?? null)
|
|
||||||
|
|
||||||
// Apply public visibility settings for scans/guides (only for non-owners)
|
|
||||||
if (!isOwner) {
|
|
||||||
const scansAllowed = projectData.show_scans_public !== false
|
|
||||||
const guidesAllowed = projectData.show_guides_public !== false
|
|
||||||
setCanShowScans(scansAllowed)
|
|
||||||
setCanShowGuides(guidesAllowed)
|
|
||||||
|
|
||||||
if (!scansAllowed) {
|
|
||||||
useViewer.getState().setShowScans(false)
|
|
||||||
}
|
|
||||||
if (!guidesAllowed) {
|
|
||||||
useViewer.getState().setShowGuides(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (model?.scene_graph) {
|
|
||||||
const { nodes, rootNodeIds } = model.scene_graph
|
|
||||||
setScene(nodes, rootNodeIds)
|
|
||||||
initSpatialGridSync()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Increment view count
|
|
||||||
await incrementProjectViews(id)
|
|
||||||
if (cancelled) return
|
|
||||||
} else {
|
|
||||||
throw new Error(result.error || 'Project not found')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cancelled) {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (!cancelled) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load content')
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadContent()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [id, setScene])
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-screen w-full items-center justify-center bg-neutral-100">
|
|
||||||
<p className="text-destructive">{error}</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative h-screen w-full">
|
|
||||||
{loading && <SceneLoader fullScreen />}
|
|
||||||
|
|
||||||
{!loading && (
|
|
||||||
<>
|
|
||||||
<ViewerOverlay
|
|
||||||
projectName={projectName}
|
|
||||||
owner={owner}
|
|
||||||
canShowScans={canShowScans}
|
|
||||||
canShowGuides={canShowGuides}
|
|
||||||
/>
|
|
||||||
<ViewerGuestCTA />
|
|
||||||
|
|
||||||
<ErrorBoundary key={id} fallback={<ViewerSceneCrashFallback projectName={projectName} />}>
|
|
||||||
<Viewer>
|
|
||||||
<ViewerCameraControls />
|
|
||||||
<ViewerZoneSystem />
|
|
||||||
<InteractiveSystem />
|
|
||||||
</Viewer>
|
|
||||||
</ErrorBoundary>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { emitter, sceneRegistry, useScene } from '@pascal-app/core'
|
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
|
||||||
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
|
|
||||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
|
||||||
import { Box3, Vector3 } from 'three'
|
|
||||||
|
|
||||||
const tempBox = new Box3()
|
|
||||||
const tempCenter = new Vector3()
|
|
||||||
const tempSize = new Vector3()
|
|
||||||
|
|
||||||
export const ViewerCameraControls = () => {
|
|
||||||
const controls = useRef<CameraControlsImpl>(null!)
|
|
||||||
const selection = useViewer((s) => s.selection)
|
|
||||||
const nodes = useScene((s) => s.nodes)
|
|
||||||
const cameraMode = useViewer((s) => s.cameraMode)
|
|
||||||
const firstLoad = useRef(true)
|
|
||||||
|
|
||||||
// Get the deepest selected node ID (excluding selectedIds)
|
|
||||||
const targetNodeId = selection.zoneId ?? selection.levelId ?? selection.buildingId
|
|
||||||
|
|
||||||
// Configure mouse buttons - same as editor
|
|
||||||
const mouseButtons = useMemo(() => {
|
|
||||||
const wheelAction =
|
|
||||||
cameraMode === 'orthographic'
|
|
||||||
? CameraControlsImpl.ACTION.ZOOM
|
|
||||||
: CameraControlsImpl.ACTION.DOLLY
|
|
||||||
|
|
||||||
return {
|
|
||||||
left: CameraControlsImpl.ACTION.SCREEN_PAN,
|
|
||||||
middle: CameraControlsImpl.ACTION.SCREEN_PAN,
|
|
||||||
right: CameraControlsImpl.ACTION.ROTATE,
|
|
||||||
wheel: wheelAction,
|
|
||||||
}
|
|
||||||
}, [cameraMode])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!controls.current) return
|
|
||||||
|
|
||||||
// On first load, set a default camera position
|
|
||||||
if (firstLoad.current) {
|
|
||||||
firstLoad.current = false
|
|
||||||
controls.current.setLookAt(30, 30, 30, 0, 0, 0, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
let node = targetNodeId ? nodes[targetNodeId] : null;
|
|
||||||
if (!targetNodeId) {
|
|
||||||
const site = Object.values(nodes).find((n) => n.type === 'site')
|
|
||||||
node = site || null
|
|
||||||
}
|
|
||||||
if (!node) return
|
|
||||||
|
|
||||||
// Check if node has a saved camera
|
|
||||||
if (node.camera) {
|
|
||||||
|
|
||||||
const { position, target } = node.camera
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
controls.current.setLookAt(
|
|
||||||
position[0],
|
|
||||||
position[1],
|
|
||||||
position[2],
|
|
||||||
target[0],
|
|
||||||
target[1],
|
|
||||||
target[2],
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!targetNodeId) {
|
|
||||||
// No selection and no site - do nothing
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate camera position based on the node's 3D object
|
|
||||||
const object3D = sceneRegistry.nodes.get(targetNodeId)
|
|
||||||
if (!object3D) return
|
|
||||||
|
|
||||||
// Compute bounding box
|
|
||||||
tempBox.setFromObject(object3D)
|
|
||||||
tempBox.getCenter(tempCenter)
|
|
||||||
tempBox.getSize(tempSize)
|
|
||||||
|
|
||||||
// Calculate a good viewing distance based on the object size
|
|
||||||
const maxDim = Math.max(tempSize.x, tempSize.y, tempSize.z)
|
|
||||||
const distance = Math.max(maxDim * 2, 15)
|
|
||||||
|
|
||||||
// Position camera at an angle looking at the center
|
|
||||||
const cameraPos = new Vector3(
|
|
||||||
tempCenter.x + distance * 0.7,
|
|
||||||
tempCenter.y + distance * 0.5,
|
|
||||||
tempCenter.z + distance * 0.7,
|
|
||||||
)
|
|
||||||
|
|
||||||
controls.current.setLookAt(
|
|
||||||
cameraPos.x,
|
|
||||||
cameraPos.y,
|
|
||||||
cameraPos.z,
|
|
||||||
tempCenter.x,
|
|
||||||
tempCenter.y,
|
|
||||||
tempCenter.z,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
}, [targetNodeId, nodes])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleTopView = () => {
|
|
||||||
if (!controls.current) return
|
|
||||||
|
|
||||||
const currentPolarAngle = controls.current.polarAngle
|
|
||||||
|
|
||||||
// Toggle: if already near top view (< 0.1 radians ≈ 5.7°), go back to 45°
|
|
||||||
// Otherwise, go to top view (0°)
|
|
||||||
const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0
|
|
||||||
|
|
||||||
controls.current.rotatePolarTo(targetAngle, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleOrbitCW = () => {
|
|
||||||
if (!controls.current) return
|
|
||||||
|
|
||||||
const currentAzimuth = controls.current.azimuthAngle
|
|
||||||
const currentPolar = controls.current.polarAngle
|
|
||||||
// Round to nearest 90° increment, then rotate 90° clockwise
|
|
||||||
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
|
|
||||||
const target = rounded - Math.PI / 2
|
|
||||||
|
|
||||||
controls.current.rotateTo(target, currentPolar, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleOrbitCCW = () => {
|
|
||||||
if (!controls.current) return
|
|
||||||
|
|
||||||
const currentAzimuth = controls.current.azimuthAngle
|
|
||||||
const currentPolar = controls.current.polarAngle
|
|
||||||
// Round to nearest 90° increment, then rotate 90° counter-clockwise
|
|
||||||
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
|
|
||||||
const target = rounded + Math.PI / 2
|
|
||||||
|
|
||||||
controls.current.rotateTo(target, currentPolar, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
emitter.on('camera-controls:top-view', handleTopView)
|
|
||||||
emitter.on('camera-controls:orbit-cw', handleOrbitCW)
|
|
||||||
emitter.on('camera-controls:orbit-ccw', handleOrbitCCW)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
emitter.off('camera-controls:top-view', handleTopView)
|
|
||||||
emitter.off('camera-controls:orbit-cw', handleOrbitCW)
|
|
||||||
emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const onTransitionStart = useCallback(() => {
|
|
||||||
useViewer.getState().setCameraDragging(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const onRest = useCallback(() => {
|
|
||||||
useViewer.getState().setCameraDragging(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CameraControls
|
|
||||||
ref={controls}
|
|
||||||
maxDistance={100}
|
|
||||||
minDistance={5}
|
|
||||||
maxPolarAngle={Math.PI / 2 - 0.1}
|
|
||||||
minPolarAngle={0}
|
|
||||||
mouseButtons={mouseButtons}
|
|
||||||
onTransitionStart={onTransitionStart}
|
|
||||||
onRest={onRest}
|
|
||||||
restThreshold={0.01}
|
|
||||||
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useAuth } from '@/features/community/lib/auth/hooks'
|
|
||||||
import { SignInDialog } from '@/features/community/components/sign-in-dialog'
|
|
||||||
|
|
||||||
export function ViewerGuestCTA() {
|
|
||||||
const { isAuthenticated, isLoading } = useAuth()
|
|
||||||
const [showSignIn, setShowSignIn] = useState(false)
|
|
||||||
|
|
||||||
if (isLoading || isAuthenticated) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="absolute top-4 right-4 z-20 dark text-foreground">
|
|
||||||
<div className="pointer-events-auto bg-background/95 backdrop-blur-xl border border-border/40 rounded-2xl px-6 py-3 shadow-lg transition-colors duration-200 ease-out flex flex-col sm:flex-row items-center gap-4">
|
|
||||||
<p className="text-sm font-medium text-foreground text-center">Want to create your own 3D project?</p>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowSignIn(true)}
|
|
||||||
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors whitespace-nowrap w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
Get Started
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<SignInDialog open={showSignIn} onOpenChange={setShowSignIn} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core'
|
|
||||||
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
import { useProjectScene } from '@/features/community/lib/models/hooks'
|
|
||||||
import { useProjectStore } from '@/features/community/lib/projects/store'
|
|
||||||
import { useKeyboard } from '@/hooks/use-keyboard'
|
|
||||||
import { initSFXBus } from '@/lib/sfx-bus'
|
|
||||||
import useEditor from '@/store/use-editor'
|
|
||||||
import { ViewerOverlay } from '@/app/viewer/[id]/viewer-overlay'
|
|
||||||
import { ViewerZoneSystem } from '@/app/viewer/[id]/viewer-zone-system'
|
|
||||||
import { FeedbackDialog } from '../feedback-dialog'
|
|
||||||
import { PascalRadio } from '../pascal-radio'
|
|
||||||
import { PreviewButton } from '../preview-button'
|
|
||||||
import { CeilingSystem } from '../systems/ceiling/ceiling-system'
|
|
||||||
import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
|
|
||||||
import { ZoneSystem } from '../systems/zone/zone-system'
|
|
||||||
import { ToolManager } from '../tools/tool-manager'
|
|
||||||
import { ActionMenu } from '../ui/action-menu'
|
|
||||||
import { HelperManager } from '../ui/helpers/helper-manager'
|
|
||||||
import { PanelManager } from '../ui/panels/panel-manager'
|
|
||||||
import { ErrorBoundary } from '../ui/primitives/error-boundary'
|
|
||||||
import { SidebarProvider } from '../ui/primitives/sidebar'
|
|
||||||
import { SceneLoader } from '../ui/scene-loader'
|
|
||||||
import { AppSidebar } from '../ui/sidebar/app-sidebar'
|
|
||||||
import { CustomCameraControls } from './custom-camera-controls'
|
|
||||||
import { ExportManager } from './export-manager'
|
|
||||||
import { FloatingActionMenu } from './floating-action-menu'
|
|
||||||
import { Grid } from './grid'
|
|
||||||
import { PresetThumbnailGenerator } from './preset-thumbnail-generator'
|
|
||||||
import { SelectionManager } from './selection-manager'
|
|
||||||
import { SiteEdgeLabels } from './site-edge-labels'
|
|
||||||
import { ThumbnailGenerator } from './thumbnail-generator'
|
|
||||||
|
|
||||||
// Load default scene initially (will be replaced when project loads)
|
|
||||||
useScene.getState().loadScene()
|
|
||||||
initSpatialGridSync()
|
|
||||||
initSpaceDetectionSync(useScene, useEditor)
|
|
||||||
|
|
||||||
// Auto-select the first building and level for the default scene
|
|
||||||
const sceneNodes = useScene.getState().nodes as Record<string, any>
|
|
||||||
const sceneRootIds = useScene.getState().rootNodeIds
|
|
||||||
const siteNode = sceneRootIds[0] ? sceneNodes[sceneRootIds[0]] : null
|
|
||||||
const resolve = (child: any) => (typeof child === 'string' ? sceneNodes[child] : child)
|
|
||||||
const firstBuilding = siteNode?.children?.map(resolve).find((n: any) => n?.type === 'building')
|
|
||||||
const firstLevel = firstBuilding?.children?.map(resolve).find((n: any) => n?.type === 'level')
|
|
||||||
|
|
||||||
if (firstBuilding && firstLevel) {
|
|
||||||
useViewer.getState().setSelection({
|
|
||||||
buildingId: firstBuilding.id,
|
|
||||||
levelId: firstLevel.id,
|
|
||||||
selectedIds: [],
|
|
||||||
zoneId: null,
|
|
||||||
})
|
|
||||||
useEditor.getState().setPhase('structure')
|
|
||||||
useEditor.getState().setStructureLayer('elements')
|
|
||||||
|
|
||||||
// Auto-select the wall tool if the level is empty
|
|
||||||
if (!firstLevel.children || firstLevel.children.length === 0) {
|
|
||||||
useEditor.getState().setMode('build')
|
|
||||||
useEditor.getState().setTool('wall')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize SFX bus to connect events to sound effects
|
|
||||||
initSFXBus()
|
|
||||||
|
|
||||||
interface EditorProps {
|
|
||||||
projectId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function EditorSceneCrashFallback() {
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-background/95 p-4 text-foreground">
|
|
||||||
<div className="w-full max-w-md rounded-2xl border border-border/60 bg-background p-6 shadow-xl">
|
|
||||||
<h2 className="text-lg font-semibold">The editor scene failed to render</h2>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
You can retry the scene or return home without reloading the whole app shell.
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
className="rounded-md border border-border bg-accent px-3 py-2 text-sm font-medium hover:bg-accent/80"
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Reload editor
|
|
||||||
</button>
|
|
||||||
<a
|
|
||||||
className="rounded-md border border-border bg-background px-3 py-2 text-sm font-medium hover:bg-accent/40"
|
|
||||||
href="/"
|
|
||||||
>
|
|
||||||
Back to home
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Editor({ projectId }: EditorProps) {
|
|
||||||
useKeyboard()
|
|
||||||
useProjectScene()
|
|
||||||
|
|
||||||
const isProjectLoading = useProjectStore((state) => state.isLoading)
|
|
||||||
const isSceneLoading = useProjectStore((state) => state.isSceneLoading)
|
|
||||||
const isLoading = isProjectLoading || isSceneLoading
|
|
||||||
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
|
||||||
const activeProject = useProjectStore((s) => s.activeProject)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (projectId) {
|
|
||||||
useViewer.getState().setProjectId(projectId)
|
|
||||||
} else {
|
|
||||||
useViewer.getState().setProjectId(null)
|
|
||||||
}
|
|
||||||
}, [projectId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.body.classList.add('dark')
|
|
||||||
return () => {
|
|
||||||
document.body.classList.remove('dark')
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full dark text-foreground">
|
|
||||||
{isLoading && <SceneLoader />}
|
|
||||||
|
|
||||||
{isPreviewMode ? (
|
|
||||||
<ViewerOverlay
|
|
||||||
projectName={activeProject?.name}
|
|
||||||
onBack={() => useEditor.getState().setPreviewMode(false)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ActionMenu />
|
|
||||||
<PanelManager />
|
|
||||||
<HelperManager />
|
|
||||||
|
|
||||||
{/* Top-right controls */}
|
|
||||||
<div className="pointer-events-none fixed top-4 right-4 z-50 flex items-start gap-2">
|
|
||||||
<div className="pointer-events-auto">
|
|
||||||
<PreviewButton />
|
|
||||||
</div>
|
|
||||||
<div className="pointer-events-auto">
|
|
||||||
<PascalRadio />
|
|
||||||
</div>
|
|
||||||
<div className="pointer-events-auto">
|
|
||||||
<FeedbackDialog projectId={projectId} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SidebarProvider className="fixed z-20">
|
|
||||||
<AppSidebar />
|
|
||||||
</SidebarProvider>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ErrorBoundary key={projectId} fallback={<EditorSceneCrashFallback />}>
|
|
||||||
<Viewer selectionManager={isPreviewMode ? 'default' : 'custom'}>
|
|
||||||
{!isPreviewMode && <SelectionManager />}
|
|
||||||
{!isPreviewMode && <FloatingActionMenu />}
|
|
||||||
<ExportManager />
|
|
||||||
{/* Swap zone systems: viewer drill-down vs editor layer toggle */}
|
|
||||||
{isPreviewMode ? <ViewerZoneSystem /> : <ZoneSystem />}
|
|
||||||
<CeilingSystem />
|
|
||||||
{!isPreviewMode && (
|
|
||||||
<Grid cellColor="#aaa" sectionColor="#ccc" fadeDistance={500} />
|
|
||||||
)}
|
|
||||||
{!isPreviewMode && <ToolManager />}
|
|
||||||
<CustomCameraControls />
|
|
||||||
<ThumbnailGenerator projectId={projectId} />
|
|
||||||
<PresetThumbnailGenerator />
|
|
||||||
{!isPreviewMode && <SiteEdgeLabels />}
|
|
||||||
{isPreviewMode && <InteractiveSystem />}
|
|
||||||
</Viewer>
|
|
||||||
{!isPreviewMode && <ZoneLabelEditorSystem />}
|
|
||||||
</ErrorBoundary>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,449 +0,0 @@
|
|||||||
import {
|
|
||||||
type AnyNode,
|
|
||||||
type BuildingNode,
|
|
||||||
emitter,
|
|
||||||
type ItemNode,
|
|
||||||
type NodeEvent,
|
|
||||||
resolveLevelId,
|
|
||||||
sceneRegistry,
|
|
||||||
useScene,
|
|
||||||
} from "@pascal-app/core";
|
|
||||||
|
|
||||||
import { useViewer } from "@pascal-app/viewer";
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import useEditor from "@/store/use-editor";
|
|
||||||
|
|
||||||
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
|
|
||||||
const currentLevelId = useViewer.getState().selection.levelId;
|
|
||||||
if (!currentLevelId) return true; // No level selected, allow all
|
|
||||||
const nodeLevelId = resolveLevelId(node, useScene.getState().nodes);
|
|
||||||
return nodeLevelId === currentLevelId;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SelectableNodeType = "wall" | "item" | "building" | "zone" | 'slab' | 'ceiling' | 'roof' | 'window' | 'door';
|
|
||||||
|
|
||||||
type ModifierKeys = {
|
|
||||||
meta: boolean;
|
|
||||||
ctrl: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface SelectionStrategy {
|
|
||||||
types: SelectableNodeType[];
|
|
||||||
handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void;
|
|
||||||
handleDeselect: () => void;
|
|
||||||
isValid: (node: AnyNode) => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const resolveBuildingId = (levelId: string, nodes: Record<string, AnyNode>): string | null => {
|
|
||||||
const level = nodes[levelId];
|
|
||||||
if (!level) return null;
|
|
||||||
if (level.parentId && nodes[level.parentId]?.type === "building") {
|
|
||||||
return level.parentId;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const computeNextIds = (
|
|
||||||
node: AnyNode,
|
|
||||||
selectedIds: string[],
|
|
||||||
event?: any,
|
|
||||||
modifierKeys?: ModifierKeys
|
|
||||||
): string[] => {
|
|
||||||
const isMeta = event?.metaKey || event?.nativeEvent?.metaKey || modifierKeys?.meta || false;
|
|
||||||
const isCtrl = event?.ctrlKey || event?.nativeEvent?.ctrlKey || modifierKeys?.ctrl || false;
|
|
||||||
|
|
||||||
console.log("computeNextIds:", {
|
|
||||||
nodeId: node.id,
|
|
||||||
selectedIds,
|
|
||||||
isMeta,
|
|
||||||
isCtrl,
|
|
||||||
eventMeta: event?.metaKey,
|
|
||||||
nativeMeta: event?.nativeEvent?.metaKey,
|
|
||||||
modMeta: modifierKeys?.meta
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isMeta || isCtrl) {
|
|
||||||
if (selectedIds.includes(node.id)) {
|
|
||||||
return selectedIds.filter((id) => id !== node.id);
|
|
||||||
} else {
|
|
||||||
return [...selectedIds, node.id];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not holding modifiers: select only this node
|
|
||||||
return [node.id];
|
|
||||||
};
|
|
||||||
|
|
||||||
const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|
||||||
site: {
|
|
||||||
types: ["building"],
|
|
||||||
handleSelect: (node) => {
|
|
||||||
useViewer
|
|
||||||
.getState()
|
|
||||||
.setSelection({ buildingId: (node as BuildingNode).id });
|
|
||||||
},
|
|
||||||
handleDeselect: () => {
|
|
||||||
useViewer.getState().setSelection({ buildingId: null });
|
|
||||||
},
|
|
||||||
isValid: (node) => node.type === "building",
|
|
||||||
},
|
|
||||||
|
|
||||||
structure: {
|
|
||||||
types: ["wall", "item", "zone", "slab", "ceiling", "roof", "window", "door"],
|
|
||||||
handleSelect: (node, nativeEvent, modifierKeys) => {
|
|
||||||
const { selection, setSelection } = useViewer.getState();
|
|
||||||
const nodes = useScene.getState().nodes;
|
|
||||||
const nodeLevelId = resolveLevelId(node, nodes);
|
|
||||||
const buildingId = resolveBuildingId(nodeLevelId, nodes);
|
|
||||||
|
|
||||||
const updates: any = {};
|
|
||||||
if (nodeLevelId !== "default" && nodeLevelId !== selection.levelId) {
|
|
||||||
updates.levelId = nodeLevelId;
|
|
||||||
}
|
|
||||||
if (buildingId && buildingId !== selection.buildingId) {
|
|
||||||
updates.buildingId = buildingId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === 'zone') {
|
|
||||||
updates.zoneId = node.id;
|
|
||||||
// Don't reset selectedIds in structure phase for zone, but if we changed level, it might reset them via hierarchy guard.
|
|
||||||
// Wait, the hierarchy guard resets zoneId if levelId changes. That's fine since we provide zoneId.
|
|
||||||
setSelection(updates);
|
|
||||||
} else {
|
|
||||||
updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys);
|
|
||||||
setSelection(updates);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
handleDeselect: () => {
|
|
||||||
const structureLayer = useEditor.getState().structureLayer;
|
|
||||||
if (structureLayer === "zones") {
|
|
||||||
useViewer.getState().setSelection({ zoneId: null });
|
|
||||||
} else {
|
|
||||||
useViewer.getState().setSelection({ selectedIds: [] });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isValid: (node) => {
|
|
||||||
if (!isNodeInCurrentLevel(node)) return false;
|
|
||||||
const structureLayer = useEditor.getState().structureLayer;
|
|
||||||
if (structureLayer === "zones") {
|
|
||||||
if (node.type === "zone") return true;
|
|
||||||
return false;
|
|
||||||
} else {
|
|
||||||
if (node.type === "wall" || node.type === "slab" || node.type === "ceiling" || node.type === "roof") return true;
|
|
||||||
if (node.type === "item") {
|
|
||||||
return (
|
|
||||||
(node as ItemNode).asset.category === "door" ||
|
|
||||||
(node as ItemNode).asset.category === "window"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (node.type === "window" || node.type === "door") return true;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
furnish: {
|
|
||||||
types: ["item"],
|
|
||||||
handleSelect: (node, nativeEvent, modifierKeys) => {
|
|
||||||
const { selection, setSelection } = useViewer.getState();
|
|
||||||
const nodes = useScene.getState().nodes;
|
|
||||||
const nodeLevelId = resolveLevelId(node, nodes);
|
|
||||||
const buildingId = resolveBuildingId(nodeLevelId, nodes);
|
|
||||||
|
|
||||||
const updates: any = {};
|
|
||||||
if (nodeLevelId !== "default" && nodeLevelId !== selection.levelId) {
|
|
||||||
updates.levelId = nodeLevelId;
|
|
||||||
}
|
|
||||||
if (buildingId && buildingId !== selection.buildingId) {
|
|
||||||
updates.buildingId = buildingId;
|
|
||||||
}
|
|
||||||
|
|
||||||
updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys);
|
|
||||||
setSelection(updates);
|
|
||||||
},
|
|
||||||
handleDeselect: () => {
|
|
||||||
useViewer.getState().setSelection({ selectedIds: [] });
|
|
||||||
},
|
|
||||||
isValid: (node) => {
|
|
||||||
if (!isNodeInCurrentLevel(node)) return false;
|
|
||||||
if (node.type !== "item") return false;
|
|
||||||
const item = node as ItemNode;
|
|
||||||
return item.asset.category !== "door" && item.asset.category !== "window";
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SelectionManager = () => {
|
|
||||||
const phase = useEditor((s) => s.phase);
|
|
||||||
const mode = useEditor((s) => s.mode);
|
|
||||||
const modifierKeysRef = useRef<ModifierKeys>({
|
|
||||||
meta: false,
|
|
||||||
ctrl: false,
|
|
||||||
});
|
|
||||||
const clickHandledRef = useRef(false);
|
|
||||||
|
|
||||||
const movingNode = useEditor((s) => s.movingNode);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Meta") modifierKeysRef.current.meta = true;
|
|
||||||
if (event.key === "Control") modifierKeysRef.current.ctrl = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onKeyUp = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Meta") modifierKeysRef.current.meta = false;
|
|
||||||
if (event.key === "Control") modifierKeysRef.current.ctrl = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearModifiers = () => {
|
|
||||||
modifierKeysRef.current.meta = false;
|
|
||||||
modifierKeysRef.current.ctrl = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("keydown", onKeyDown);
|
|
||||||
window.addEventListener("keyup", onKeyUp);
|
|
||||||
window.addEventListener("blur", clearModifiers);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("keydown", onKeyDown);
|
|
||||||
window.removeEventListener("keyup", onKeyUp);
|
|
||||||
window.removeEventListener("blur", clearModifiers);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (mode !== "select") return;
|
|
||||||
if (movingNode) return;
|
|
||||||
|
|
||||||
const onClick = (event: NodeEvent) => {
|
|
||||||
const node = event.node;
|
|
||||||
let currentPhase = useEditor.getState().phase;
|
|
||||||
let targetPhase = currentPhase;
|
|
||||||
|
|
||||||
// Auto-switch between structure and furnish phases when clicking elements on the same level
|
|
||||||
if (currentPhase === "structure" || currentPhase === "furnish") {
|
|
||||||
if (isNodeInCurrentLevel(node)) {
|
|
||||||
if (
|
|
||||||
node.type === "wall" ||
|
|
||||||
node.type === "slab" ||
|
|
||||||
node.type === "ceiling" ||
|
|
||||||
node.type === "roof" ||
|
|
||||||
node.type === "window" ||
|
|
||||||
node.type === "door"
|
|
||||||
) {
|
|
||||||
targetPhase = "structure";
|
|
||||||
} else if (node.type === "item") {
|
|
||||||
const item = node as ItemNode;
|
|
||||||
if (item.asset.category === "door" || item.asset.category === "window") {
|
|
||||||
targetPhase = "structure";
|
|
||||||
} else {
|
|
||||||
targetPhase = "furnish";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetPhase !== currentPhase) {
|
|
||||||
useEditor.getState().setPhase(targetPhase);
|
|
||||||
if (targetPhase === "structure" && useEditor.getState().structureLayer === "zones") {
|
|
||||||
useEditor.getState().setStructureLayer("elements");
|
|
||||||
}
|
|
||||||
currentPhase = targetPhase;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeStrategy = SELECTION_STRATEGIES[currentPhase];
|
|
||||||
if (activeStrategy?.isValid(node)) {
|
|
||||||
event.stopPropagation();
|
|
||||||
clickHandledRef.current = true;
|
|
||||||
|
|
||||||
console.log("[SelectionManager] Valid click on:", node.type, node.id, "Shift:", event.nativeEvent.shiftKey);
|
|
||||||
activeStrategy.handleSelect(node, event.nativeEvent, modifierKeysRef.current);
|
|
||||||
|
|
||||||
// Reset the handled flag after a short delay to allow grid:click to be ignored
|
|
||||||
setTimeout(() => {
|
|
||||||
clickHandledRef.current = false;
|
|
||||||
}, 50);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const allTypes = ["wall", "item", "building", "zone", "slab", "ceiling", "roof", "window", "door"];
|
|
||||||
allTypes.forEach((type) => {
|
|
||||||
emitter.on(`${type}:click` as any, onClick as any);
|
|
||||||
});
|
|
||||||
|
|
||||||
const onGridClick = () => {
|
|
||||||
if (clickHandledRef.current) return;
|
|
||||||
console.log("onGridClick triggered! Deselecting.");
|
|
||||||
const activeStrategy = SELECTION_STRATEGIES[useEditor.getState().phase];
|
|
||||||
if (activeStrategy) activeStrategy.handleDeselect();
|
|
||||||
};
|
|
||||||
emitter.on("grid:click", onGridClick);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
allTypes.forEach((type) => {
|
|
||||||
emitter.off(`${type}:click` as any, onClick as any);
|
|
||||||
});
|
|
||||||
emitter.off("grid:click", onGridClick);
|
|
||||||
};
|
|
||||||
}, [mode, movingNode]);
|
|
||||||
|
|
||||||
// Global double-click handler for auto-switching phases and cross-phase hover
|
|
||||||
useEffect(() => {
|
|
||||||
if (mode !== "select") return;
|
|
||||||
if (movingNode) return;
|
|
||||||
|
|
||||||
const onEnter = (event: NodeEvent) => {
|
|
||||||
const node = event.node;
|
|
||||||
const currentPhase = useEditor.getState().phase;
|
|
||||||
|
|
||||||
// Ignore site/building if we are already inside a building
|
|
||||||
if (node.type === "building" || node.type === "site") {
|
|
||||||
if (currentPhase === "structure" || currentPhase === "furnish") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ignore zones unless specifically in zones layer
|
|
||||||
if (node.type === "zone") {
|
|
||||||
if (currentPhase !== "structure" || useEditor.getState().structureLayer !== "zones") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check level constraint for interior nodes
|
|
||||||
if (currentPhase === "structure" || currentPhase === "furnish") {
|
|
||||||
if (!isNodeInCurrentLevel(node)) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.stopPropagation();
|
|
||||||
useViewer.setState({ hoveredId: node.id });
|
|
||||||
};
|
|
||||||
|
|
||||||
const onLeave = (event: NodeEvent) => {
|
|
||||||
if (useViewer.getState().hoveredId === event.node.id) {
|
|
||||||
useViewer.setState({ hoveredId: null });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDoubleClick = (event: NodeEvent) => {
|
|
||||||
const node = event.node;
|
|
||||||
const currentPhase = useEditor.getState().phase;
|
|
||||||
|
|
||||||
let targetPhase: "site" | "structure" | "furnish" | null = null;
|
|
||||||
|
|
||||||
if (node.type === "building" || node.type === "site") {
|
|
||||||
if (currentPhase === "structure" || currentPhase === "furnish") {
|
|
||||||
return; // Ignore building/site double clicks if we are already inside a building
|
|
||||||
}
|
|
||||||
if (node.type === "building") {
|
|
||||||
targetPhase = "structure";
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
node.type === "wall" ||
|
|
||||||
node.type === "slab" ||
|
|
||||||
node.type === "ceiling" ||
|
|
||||||
node.type === "roof" ||
|
|
||||||
node.type === "window" ||
|
|
||||||
node.type === "door"
|
|
||||||
) {
|
|
||||||
targetPhase = "structure";
|
|
||||||
} else if (node.type === "item") {
|
|
||||||
const item = node as ItemNode;
|
|
||||||
if (item.asset.category === "door" || item.asset.category === "window") {
|
|
||||||
targetPhase = "structure";
|
|
||||||
} else {
|
|
||||||
targetPhase = "furnish";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === "zone") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetPhase && targetPhase !== useEditor.getState().phase) {
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
useEditor.getState().setPhase(targetPhase);
|
|
||||||
|
|
||||||
if (targetPhase === "structure" && useEditor.getState().structureLayer === "zones") {
|
|
||||||
useEditor.getState().setStructureLayer("elements");
|
|
||||||
}
|
|
||||||
|
|
||||||
const strategy = SELECTION_STRATEGIES[targetPhase];
|
|
||||||
if (strategy) {
|
|
||||||
strategy.handleSelect(node, event.nativeEvent, modifierKeysRef.current);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const allTypes = ["wall", "item", "building", "slab", "ceiling", "roof", "window", "door", "zone", "site"];
|
|
||||||
allTypes.forEach((type) => {
|
|
||||||
emitter.on(`${type}:enter` as any, onEnter as any);
|
|
||||||
emitter.on(`${type}:leave` as any, onLeave as any);
|
|
||||||
emitter.on(`${type}:double-click` as any, onDoubleClick as any);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
allTypes.forEach((type) => {
|
|
||||||
emitter.off(`${type}:enter` as any, onEnter as any);
|
|
||||||
emitter.off(`${type}:leave` as any, onLeave as any);
|
|
||||||
emitter.off(`${type}:double-click` as any, onDoubleClick as any);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}, [mode, movingNode]);
|
|
||||||
|
|
||||||
return <EditorOutlinerSync />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const EditorOutlinerSync = () => {
|
|
||||||
const phase = useEditor((s) => s.phase);
|
|
||||||
const selection = useViewer((s) => s.selection);
|
|
||||||
const hoveredId = useViewer((s) => s.hoveredId);
|
|
||||||
const outliner = useViewer((s) => s.outliner);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let idsToHighlight: string[] = [];
|
|
||||||
|
|
||||||
// 1. Determine what should be highlighted based on Phase
|
|
||||||
switch (phase) {
|
|
||||||
case "site":
|
|
||||||
// Only highlight the building if one is selected
|
|
||||||
if (selection.buildingId) idsToHighlight = [selection.buildingId];
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "structure":
|
|
||||||
// Highlight selected items (walls/slabs)
|
|
||||||
// We IGNORE buildingId even if it's set in the store
|
|
||||||
idsToHighlight = selection.selectedIds;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "furnish":
|
|
||||||
// Highlight selected furniture/items
|
|
||||||
idsToHighlight = selection.selectedIds;
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
// Pure Viewer mode: Highlight based on the "deepest" selection
|
|
||||||
if (selection.selectedIds.length > 0)
|
|
||||||
idsToHighlight = selection.selectedIds;
|
|
||||||
else if (selection.levelId) idsToHighlight = [selection.levelId];
|
|
||||||
else if (selection.buildingId) idsToHighlight = [selection.buildingId];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Sync with the imperative outliner arrays (mutate in place to keep references)
|
|
||||||
outliner.selectedObjects.length = 0;
|
|
||||||
for (const id of idsToHighlight) {
|
|
||||||
const obj = sceneRegistry.nodes.get(id);
|
|
||||||
if (obj) outliner.selectedObjects.push(obj);
|
|
||||||
}
|
|
||||||
|
|
||||||
outliner.hoveredObjects.length = 0;
|
|
||||||
if (hoveredId) {
|
|
||||||
const obj = sceneRegistry.nodes.get(hoveredId);
|
|
||||||
if (obj) outliner.hoveredObjects.push(obj);
|
|
||||||
}
|
|
||||||
}, [phase, selection, hoveredId, outliner]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Eye } from 'lucide-react'
|
|
||||||
import useEditor from '@/store/use-editor'
|
|
||||||
|
|
||||||
export function PreviewButton() {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={() => useEditor.getState().setPreviewMode(true)}
|
|
||||||
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 shadow-lg backdrop-blur-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-accent/90 transition-colors"
|
|
||||||
>
|
|
||||||
<Eye className="h-4 w-4 shrink-0" />
|
|
||||||
<span className="hidden sm:inline whitespace-nowrap">Preview</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,360 +0,0 @@
|
|||||||
import { emitter, type GridEvent, useScene, ZoneNode, type LevelNode } from "@pascal-app/core";
|
|
||||||
import { useViewer } from "@pascal-app/viewer";
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { BufferGeometry, DoubleSide, type Line, type Group, Shape, Vector3 } from "three";
|
|
||||||
import { EDITOR_LAYER } from "@/lib/constants";
|
|
||||||
import useEditor from "@/store/use-editor";
|
|
||||||
import { CursorSphere } from "../shared/cursor-sphere";
|
|
||||||
import { PALETTE_COLORS } from "@/components/ui/primitives/color-dot";
|
|
||||||
|
|
||||||
const Y_OFFSET = 0.02;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
|
|
||||||
*/
|
|
||||||
const calculateSnapPoint = (
|
|
||||||
lastPoint: [number, number],
|
|
||||||
currentPoint: [number, number]
|
|
||||||
): [number, number] => {
|
|
||||||
const [x1, y1] = lastPoint;
|
|
||||||
const [x, y] = currentPoint;
|
|
||||||
|
|
||||||
const dx = x - x1;
|
|
||||||
const dy = y - y1;
|
|
||||||
const absDx = Math.abs(dx);
|
|
||||||
const absDy = Math.abs(dy);
|
|
||||||
|
|
||||||
// Calculate distances to horizontal, vertical, and diagonal lines
|
|
||||||
const horizontalDist = absDy;
|
|
||||||
const verticalDist = absDx;
|
|
||||||
const diagonalDist = Math.abs(absDx - absDy);
|
|
||||||
|
|
||||||
// Find the minimum distance to determine which axis to snap to
|
|
||||||
const minDist = Math.min(horizontalDist, verticalDist, diagonalDist);
|
|
||||||
|
|
||||||
if (minDist === diagonalDist) {
|
|
||||||
// Snap to 45° diagonal
|
|
||||||
const diagonalLength = Math.min(absDx, absDy);
|
|
||||||
return [
|
|
||||||
x1 + Math.sign(dx) * diagonalLength,
|
|
||||||
y1 + Math.sign(dy) * diagonalLength,
|
|
||||||
];
|
|
||||||
} else if (minDist === horizontalDist) {
|
|
||||||
// Snap to horizontal
|
|
||||||
return [x, y1];
|
|
||||||
} else {
|
|
||||||
// Snap to vertical
|
|
||||||
return [x1, y];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a zone with the given polygon points
|
|
||||||
*/
|
|
||||||
const commitZoneDrawing = (
|
|
||||||
levelId: LevelNode["id"],
|
|
||||||
points: Array<[number, number]>
|
|
||||||
) => {
|
|
||||||
const { createNode, nodes } = useScene.getState();
|
|
||||||
|
|
||||||
// Count existing zones for naming and color cycling
|
|
||||||
const zoneCount = Object.values(nodes).filter((n) => n.type === "zone").length;
|
|
||||||
const name = `Zone ${zoneCount + 1}`;
|
|
||||||
|
|
||||||
// Cycle through colors
|
|
||||||
const color = PALETTE_COLORS[zoneCount % PALETTE_COLORS.length];
|
|
||||||
|
|
||||||
const zone = ZoneNode.parse({
|
|
||||||
name,
|
|
||||||
polygon: points,
|
|
||||||
color,
|
|
||||||
});
|
|
||||||
|
|
||||||
createNode(zone, levelId);
|
|
||||||
|
|
||||||
// Select the newly created zone
|
|
||||||
useViewer.getState().setSelection({ zoneId: zone.id });
|
|
||||||
};
|
|
||||||
|
|
||||||
type PreviewState = {
|
|
||||||
points: Array<[number, number]>;
|
|
||||||
cursorPoint: [number, number] | null;
|
|
||||||
levelY: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to validate point values (no NaN or Infinity)
|
|
||||||
const isValidPoint = (
|
|
||||||
pt: [number, number] | null | undefined
|
|
||||||
): pt is [number, number] => {
|
|
||||||
if (!pt) return false;
|
|
||||||
return Number.isFinite(pt[0]) && Number.isFinite(pt[1]);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ZoneTool: React.FC = () => {
|
|
||||||
const cursorRef = useRef<Group>(null);
|
|
||||||
const mainLineRef = useRef<Line>(null!);
|
|
||||||
const closingLineRef = useRef<Line>(null!);
|
|
||||||
const pointsRef = useRef<Array<[number, number]>>([]);
|
|
||||||
const levelYRef = useRef(0); // Track current level Y position
|
|
||||||
const currentLevelId = useViewer((state) => state.selection.levelId);
|
|
||||||
const setTool = useEditor((state) => state.setTool);
|
|
||||||
|
|
||||||
// Preview state for reactive rendering (for shape and point markers)
|
|
||||||
const [preview, setPreview] = useState<PreviewState>({
|
|
||||||
points: [],
|
|
||||||
cursorPoint: null,
|
|
||||||
levelY: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentLevelId) return;
|
|
||||||
|
|
||||||
let cursorPosition: [number, number] = [0, 0];
|
|
||||||
|
|
||||||
// Initialize line geometries
|
|
||||||
mainLineRef.current.geometry = new BufferGeometry();
|
|
||||||
closingLineRef.current.geometry = new BufferGeometry();
|
|
||||||
|
|
||||||
const updateLines = () => {
|
|
||||||
const points = pointsRef.current;
|
|
||||||
const y = levelYRef.current + Y_OFFSET;
|
|
||||||
|
|
||||||
if (points.length === 0) {
|
|
||||||
mainLineRef.current.visible = false;
|
|
||||||
closingLineRef.current.visible = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build main line points
|
|
||||||
const linePoints: Vector3[] = points.map(
|
|
||||||
([x, z]) => new Vector3(x, y, z)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add cursor point
|
|
||||||
const lastPoint = points[points.length - 1];
|
|
||||||
if (lastPoint) {
|
|
||||||
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
|
|
||||||
if (isValidPoint(snapped)) {
|
|
||||||
linePoints.push(new Vector3(snapped[0], y, snapped[1]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update main line geometry
|
|
||||||
if (linePoints.length >= 2) {
|
|
||||||
mainLineRef.current.geometry.dispose();
|
|
||||||
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints);
|
|
||||||
mainLineRef.current.visible = true;
|
|
||||||
} else {
|
|
||||||
mainLineRef.current.visible = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update closing line (from cursor back to first point)
|
|
||||||
const firstPoint = points[0];
|
|
||||||
if (points.length >= 2 && lastPoint && isValidPoint(firstPoint)) {
|
|
||||||
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
|
|
||||||
if (isValidPoint(snapped)) {
|
|
||||||
const closingPoints = [
|
|
||||||
new Vector3(snapped[0], y, snapped[1]),
|
|
||||||
new Vector3(firstPoint[0], y, firstPoint[1]),
|
|
||||||
];
|
|
||||||
closingLineRef.current.geometry.dispose();
|
|
||||||
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints);
|
|
||||||
closingLineRef.current.visible = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
closingLineRef.current.visible = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatePreview = () => {
|
|
||||||
const points = pointsRef.current;
|
|
||||||
const lastPoint = points[points.length - 1];
|
|
||||||
|
|
||||||
let cursorPt: [number, number] | null = null;
|
|
||||||
if (lastPoint) {
|
|
||||||
cursorPt = calculateSnapPoint(lastPoint, cursorPosition);
|
|
||||||
} else if (points.length === 0) {
|
|
||||||
cursorPt = cursorPosition;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPreview({ points: [...points], cursorPoint: cursorPt, levelY: levelYRef.current });
|
|
||||||
updateLines();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onGridMove = (event: GridEvent) => {
|
|
||||||
if (!cursorRef.current) return;
|
|
||||||
|
|
||||||
// Snap to 0.5 grid
|
|
||||||
const gridX = Math.round(event.position[0] * 2) / 2;
|
|
||||||
const gridZ = Math.round(event.position[2] * 2) / 2;
|
|
||||||
cursorPosition = [gridX, gridZ];
|
|
||||||
levelYRef.current = event.position[1];
|
|
||||||
|
|
||||||
// If we have points, snap to axis from last point
|
|
||||||
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
|
|
||||||
if (lastPoint) {
|
|
||||||
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
|
|
||||||
cursorRef.current.position.set(snapped[0], event.position[1], snapped[1]);
|
|
||||||
} else {
|
|
||||||
cursorRef.current.position.set(gridX, event.position[1], gridZ);
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePreview();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onGridClick = (event: GridEvent) => {
|
|
||||||
if (!currentLevelId) return;
|
|
||||||
|
|
||||||
const gridX = Math.round(event.position[0] * 2) / 2;
|
|
||||||
const gridZ = Math.round(event.position[2] * 2) / 2;
|
|
||||||
let clickPoint: [number, number] = [gridX, gridZ];
|
|
||||||
|
|
||||||
// Snap to axis from last point
|
|
||||||
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
|
|
||||||
if (lastPoint) {
|
|
||||||
clickPoint = calculateSnapPoint(lastPoint, clickPoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if clicking on the first point to close the shape
|
|
||||||
const firstPoint = pointsRef.current[0];
|
|
||||||
if (
|
|
||||||
pointsRef.current.length >= 3 &&
|
|
||||||
firstPoint &&
|
|
||||||
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
|
|
||||||
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
|
|
||||||
) {
|
|
||||||
// Create the zone
|
|
||||||
commitZoneDrawing(currentLevelId, pointsRef.current);
|
|
||||||
|
|
||||||
// Reset state
|
|
||||||
pointsRef.current = [];
|
|
||||||
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
|
|
||||||
mainLineRef.current.visible = false;
|
|
||||||
closingLineRef.current.visible = false;
|
|
||||||
} else {
|
|
||||||
// Add point to polygon
|
|
||||||
pointsRef.current = [...pointsRef.current, clickPoint];
|
|
||||||
updatePreview();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onGridDoubleClick = (_event: GridEvent) => {
|
|
||||||
if (!currentLevelId) return;
|
|
||||||
|
|
||||||
// Need at least 3 points to form a polygon
|
|
||||||
if (pointsRef.current.length >= 3) {
|
|
||||||
commitZoneDrawing(currentLevelId, pointsRef.current);
|
|
||||||
|
|
||||||
// Reset state
|
|
||||||
pointsRef.current = [];
|
|
||||||
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
|
|
||||||
mainLineRef.current.visible = false;
|
|
||||||
closingLineRef.current.visible = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Subscribe to events
|
|
||||||
emitter.on("grid:move", onGridMove);
|
|
||||||
emitter.on("grid:click", onGridClick);
|
|
||||||
emitter.on("grid:double-click", onGridDoubleClick);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
emitter.off("grid:move", onGridMove);
|
|
||||||
emitter.off("grid:click", onGridClick);
|
|
||||||
emitter.off("grid:double-click", onGridDoubleClick);
|
|
||||||
|
|
||||||
// Reset state on unmount
|
|
||||||
pointsRef.current = [];
|
|
||||||
};
|
|
||||||
}, [currentLevelId, setTool]);
|
|
||||||
|
|
||||||
const { points, cursorPoint, levelY } = preview;
|
|
||||||
|
|
||||||
// Create preview shape when we have 3+ points
|
|
||||||
const previewShape = useMemo(() => {
|
|
||||||
if (points.length < 3) return null;
|
|
||||||
|
|
||||||
const allPoints = [...points];
|
|
||||||
if (isValidPoint(cursorPoint)) {
|
|
||||||
allPoints.push(cursorPoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
// THREE.Shape is in X-Y plane. After rotation of -PI/2 around X:
|
|
||||||
// - Shape X -> World X
|
|
||||||
// - Shape Y -> World -Z (so we negate Z to get correct orientation)
|
|
||||||
const firstPt = allPoints[0];
|
|
||||||
if (!isValidPoint(firstPt)) return null;
|
|
||||||
|
|
||||||
const shape = new Shape();
|
|
||||||
shape.moveTo(firstPt[0], -firstPt[1]);
|
|
||||||
|
|
||||||
for (let i = 1; i < allPoints.length; i++) {
|
|
||||||
const pt = allPoints[i];
|
|
||||||
if (isValidPoint(pt)) {
|
|
||||||
shape.lineTo(pt[0], -pt[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
shape.closePath();
|
|
||||||
|
|
||||||
return shape;
|
|
||||||
}, [points, cursorPoint]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group>
|
|
||||||
{/* Cursor */}
|
|
||||||
<CursorSphere ref={cursorRef} />
|
|
||||||
|
|
||||||
{/* Preview fill */}
|
|
||||||
{previewShape && (
|
|
||||||
<mesh
|
|
||||||
frustumCulled={false}
|
|
||||||
layers={EDITOR_LAYER}
|
|
||||||
position={[0, levelY + Y_OFFSET, 0]}
|
|
||||||
rotation={[-Math.PI / 2, 0, 0]}
|
|
||||||
>
|
|
||||||
<shapeGeometry args={[previewShape]} />
|
|
||||||
<meshBasicMaterial
|
|
||||||
color="#818cf8"
|
|
||||||
depthTest={false}
|
|
||||||
opacity={0.15}
|
|
||||||
side={DoubleSide}
|
|
||||||
transparent
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Main line - uses native line element with TSL-compatible material */}
|
|
||||||
{/* @ts-ignore */}
|
|
||||||
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
|
||||||
<bufferGeometry />
|
|
||||||
<lineBasicNodeMaterial
|
|
||||||
color="#818cf8"
|
|
||||||
linewidth={3}
|
|
||||||
depthTest={false}
|
|
||||||
depthWrite={false}
|
|
||||||
/>
|
|
||||||
</line>
|
|
||||||
|
|
||||||
{/* Closing line - uses native line element with TSL-compatible material */}
|
|
||||||
{/* @ts-ignore */}
|
|
||||||
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
|
||||||
<bufferGeometry />
|
|
||||||
<lineBasicNodeMaterial
|
|
||||||
color="#818cf8"
|
|
||||||
linewidth={2}
|
|
||||||
depthTest={false}
|
|
||||||
depthWrite={false}
|
|
||||||
opacity={0.5}
|
|
||||||
transparent
|
|
||||||
/>
|
|
||||||
</line>
|
|
||||||
|
|
||||||
{/* Point markers */}
|
|
||||||
{points.map(([x, z], index) =>
|
|
||||||
isValidPoint([x, z]) ? (
|
|
||||||
<CursorSphere key={index} position={[x, levelY + Y_OFFSET + 0.01, z]} color="#818cf8" showTooltip={false} height={0} />
|
|
||||||
) : null
|
|
||||||
)}
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Image from "next/image";
|
|
||||||
import { ActionButton } from "./action-button";
|
|
||||||
import { Pencil, Trash2, type LucideIcon } from "lucide-react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import useEditor, { Mode, Phase } from "@/store/use-editor";
|
|
||||||
|
|
||||||
type ModeConfig = {
|
|
||||||
id: Mode;
|
|
||||||
icon?: LucideIcon;
|
|
||||||
imageSrc?: string;
|
|
||||||
label: string;
|
|
||||||
shortcut: string;
|
|
||||||
color: string;
|
|
||||||
activeColor: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// All available control modes
|
|
||||||
const allModes: ModeConfig[] = [
|
|
||||||
{
|
|
||||||
id: "select",
|
|
||||||
imageSrc: "/icons/select.png",
|
|
||||||
label: "Select",
|
|
||||||
shortcut: "V",
|
|
||||||
color: "hover:bg-blue-500/20 hover:text-blue-400",
|
|
||||||
activeColor: "bg-blue-500/20 text-blue-400",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "edit",
|
|
||||||
icon: Pencil,
|
|
||||||
label: "Edit",
|
|
||||||
shortcut: "E",
|
|
||||||
color: "hover:bg-orange-500/20 hover:text-orange-400",
|
|
||||||
activeColor: "bg-orange-500/20 text-orange-400",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "build",
|
|
||||||
imageSrc: "/icons/build.png",
|
|
||||||
label: "Build",
|
|
||||||
shortcut: "B",
|
|
||||||
color: "hover:bg-green-500/20 hover:text-green-400",
|
|
||||||
activeColor: "bg-green-500/20 text-green-400",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "delete",
|
|
||||||
icon: Trash2,
|
|
||||||
label: "Delete",
|
|
||||||
shortcut: "D",
|
|
||||||
color: "hover:bg-red-500/20 hover:text-red-400",
|
|
||||||
activeColor: "bg-red-500/20 text-red-400",
|
|
||||||
},
|
|
||||||
// {
|
|
||||||
// id: 'painting',
|
|
||||||
// icon: Paintbrush,
|
|
||||||
// label: 'Painting',
|
|
||||||
// shortcut: 'P',
|
|
||||||
// color: 'hover:bg-cyan-500/20 hover:text-cyan-400',
|
|
||||||
// activeColor: 'bg-cyan-500/20 text-cyan-400',
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// id: 'guide',
|
|
||||||
// icon: Image,
|
|
||||||
// label: 'Guide',
|
|
||||||
// shortcut: 'G',
|
|
||||||
// color: 'hover:bg-purple-500/20 hover:text-purple-400',
|
|
||||||
// activeColor: 'bg-purple-500/20 text-purple-400',
|
|
||||||
// },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Define which modes are available in each editor mode
|
|
||||||
const modesByPhase: Record<Phase, Mode[]> = {
|
|
||||||
site: ["select", "edit"],
|
|
||||||
structure: ["select", "delete", "build"],
|
|
||||||
furnish: ["select", "delete", "build"],
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ControlModes() {
|
|
||||||
const mode = useEditor((state) => state.mode);
|
|
||||||
const phase = useEditor((state) => state.phase);
|
|
||||||
const setMode = useEditor((state) => state.setMode);
|
|
||||||
|
|
||||||
const availableModeIds = modesByPhase[phase];
|
|
||||||
const availableModes = allModes.filter((m) =>
|
|
||||||
availableModeIds.includes(m.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleModeClick = (mode: Mode) => {
|
|
||||||
setMode(mode);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{availableModes.map((m) => {
|
|
||||||
const Icon = m.icon;
|
|
||||||
const isActive = mode === m.id;
|
|
||||||
const isImageMode = Boolean(m.imageSrc);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ActionButton
|
|
||||||
key={m.id}
|
|
||||||
label={m.label}
|
|
||||||
shortcut={m.shortcut}
|
|
||||||
className={cn(
|
|
||||||
"text-muted-foreground",
|
|
||||||
!isImageMode && !isActive && m.color,
|
|
||||||
!isImageMode && isActive && m.activeColor,
|
|
||||||
isImageMode && isActive && "bg-white/10 hover:bg-white/10",
|
|
||||||
isImageMode && !isActive && "hover:bg-white/5"
|
|
||||||
)}
|
|
||||||
onClick={() => handleModeClick(m.id)}
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
{m.imageSrc ? (
|
|
||||||
<Image
|
|
||||||
alt={m.label}
|
|
||||||
className={cn(
|
|
||||||
"h-[28px] w-[28px] object-contain transition-[opacity,filter] duration-200",
|
|
||||||
!isActive && "opacity-60 grayscale",
|
|
||||||
isActive && "opacity-100 grayscale-0"
|
|
||||||
)}
|
|
||||||
height={28}
|
|
||||||
src={m.imageSrc}
|
|
||||||
width={28}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
Icon && <Icon className="h-5 w-5" />
|
|
||||||
)}
|
|
||||||
</ActionButton>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import NextImage from "next/image";
|
|
||||||
import { ActionButton } from "./action-button";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import useEditor, { CatalogCategory } from "@/store/use-editor";
|
|
||||||
|
|
||||||
export type FurnishToolConfig = {
|
|
||||||
id: "item";
|
|
||||||
iconSrc: string;
|
|
||||||
label: string;
|
|
||||||
catalogCategory: CatalogCategory;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Furnish mode tools: furniture, appliances, decoration (painting is now a control mode)
|
|
||||||
export const furnishTools: FurnishToolConfig[] = [
|
|
||||||
{
|
|
||||||
id: "item",
|
|
||||||
iconSrc: "/icons/couch.png",
|
|
||||||
label: "Furniture",
|
|
||||||
catalogCategory: "furniture",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "item",
|
|
||||||
iconSrc: "/icons/appliance.png",
|
|
||||||
label: "Appliance",
|
|
||||||
catalogCategory: "appliance",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "item",
|
|
||||||
iconSrc: "/icons/kitchen.png",
|
|
||||||
label: "Kitchen",
|
|
||||||
catalogCategory: "kitchen",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "item",
|
|
||||||
iconSrc: "/icons/bathroom.png",
|
|
||||||
label: "Bathroom",
|
|
||||||
catalogCategory: "bathroom",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "item",
|
|
||||||
iconSrc: "/icons/tree.png",
|
|
||||||
label: "Outdoor",
|
|
||||||
catalogCategory: "outdoor",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function FurnishTools() {
|
|
||||||
const mode = useEditor((state) => state.mode);
|
|
||||||
const activeTool = useEditor((state) => state.tool);
|
|
||||||
const setActiveTool = useEditor((state) => state.setTool);
|
|
||||||
const setMode = useEditor((state) => state.setMode);
|
|
||||||
const catalogCategory = useEditor((state) => state.catalogCategory);
|
|
||||||
const setCatalogCategory = useEditor((state) => state.setCatalogCategory);
|
|
||||||
|
|
||||||
const hasActiveTool = furnishTools.some((tool) =>
|
|
||||||
mode === "build" &&
|
|
||||||
activeTool === "item" &&
|
|
||||||
catalogCategory === tool.catalogCategory
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1.5 px-1">
|
|
||||||
{furnishTools.map((tool, index) => {
|
|
||||||
// For item tools with catalog category, check both tool and category match
|
|
||||||
const isActive =
|
|
||||||
mode === "build" &&
|
|
||||||
activeTool === "item" &&
|
|
||||||
catalogCategory === tool.catalogCategory;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ActionButton
|
|
||||||
key={`${tool.id}-${tool.catalogCategory ?? index}`}
|
|
||||||
label={tool.label}
|
|
||||||
className={cn(
|
|
||||||
"rounded-lg duration-300",
|
|
||||||
isActive ? "bg-black/40 hover:bg-black/40 scale-110 z-10" : "bg-transparent opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-black/20 scale-95",
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
if (!isActive) {
|
|
||||||
setCatalogCategory(tool.catalogCategory);
|
|
||||||
setActiveTool("item");
|
|
||||||
if (mode !== "build") {
|
|
||||||
setMode("build");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<NextImage
|
|
||||||
alt={tool.label}
|
|
||||||
className="size-full object-contain"
|
|
||||||
height={28}
|
|
||||||
src={tool.iconSrc}
|
|
||||||
width={28}
|
|
||||||
/>
|
|
||||||
</ActionButton>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
interface ActionButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
||||||
icon?: React.ReactNode
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ActionButton({ icon, label, className, ...props }: ActionButtonProps) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
{...props}
|
|
||||||
className={cn(
|
|
||||||
"flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-xs font-medium text-foreground transition-colors hover:bg-[#3e3e3e] active:bg-[#3e3e3e]",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
<span>{label}</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ActionGroup({ children, className }: { children: React.ReactNode; className?: string }) {
|
|
||||||
return (
|
|
||||||
<div className={cn("flex gap-1.5", className)}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { ChevronDown } from 'lucide-react'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
|
||||||
|
|
||||||
interface PanelSectionProps {
|
|
||||||
title: string
|
|
||||||
children: React.ReactNode
|
|
||||||
defaultExpanded?: boolean
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PanelSection({
|
|
||||||
title,
|
|
||||||
children,
|
|
||||||
defaultExpanded = true,
|
|
||||||
className,
|
|
||||||
}: PanelSectionProps) {
|
|
||||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
layout
|
|
||||||
transition={{ type: "spring", bounce: 0, duration: 0.4 }}
|
|
||||||
className={cn("flex flex-col shrink-0 overflow-hidden border-b border-border/50", className)}
|
|
||||||
>
|
|
||||||
<motion.button
|
|
||||||
layout="position"
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
|
||||||
className={cn(
|
|
||||||
"group/section flex items-center justify-between h-10 px-3 transition-all duration-200 shrink-0",
|
|
||||||
isExpanded
|
|
||||||
? "bg-accent/50 text-foreground"
|
|
||||||
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="font-medium text-sm truncate">{title}</span>
|
|
||||||
<ChevronDown
|
|
||||||
className={cn(
|
|
||||||
"h-4 w-4 transition-transform duration-200",
|
|
||||||
isExpanded ? "rotate-180" : "rotate-0",
|
|
||||||
isExpanded ? "text-foreground" : "opacity-0 group-hover/section:opacity-100"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</motion.button>
|
|
||||||
|
|
||||||
<AnimatePresence initial={false}>
|
|
||||||
{isExpanded && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ height: 0, opacity: 0 }}
|
|
||||||
animate={{ height: "auto", opacity: 1 }}
|
|
||||||
exit={{ height: 0, opacity: 0 }}
|
|
||||||
transition={{ type: "spring", bounce: 0, duration: 0.4 }}
|
|
||||||
className="overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-1.5 p-3 pt-2">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</motion.div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { Check } from 'lucide-react'
|
|
||||||
|
|
||||||
interface ToggleControlProps {
|
|
||||||
label: string
|
|
||||||
checked: boolean
|
|
||||||
onChange: (checked: boolean) => void
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ToggleControl({
|
|
||||||
label,
|
|
||||||
checked,
|
|
||||||
onChange,
|
|
||||||
className,
|
|
||||||
}: ToggleControlProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn("group flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm transition-colors hover:bg-[#3e3e3e]", className)}
|
|
||||||
onClick={() => onChange(!checked)}
|
|
||||||
>
|
|
||||||
<div className="text-muted-foreground transition-colors group-hover:text-foreground select-none">
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex h-5 w-5 items-center justify-center rounded-[4px] border transition-all duration-200",
|
|
||||||
checked
|
|
||||||
? "border-primary bg-primary text-primary-foreground"
|
|
||||||
: "border-border bg-black/20 text-transparent group-hover:border-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Check className="h-3.5 w-3.5" strokeWidth={3} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,712 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
|
||||||
import { IconRail, type PanelId } from "./icon-rail";
|
|
||||||
import { CommandPalette } from "@/components/ui/command-palette";
|
|
||||||
import {
|
|
||||||
ArrowUpCircle,
|
|
||||||
ChevronDown,
|
|
||||||
Clock3,
|
|
||||||
RotateCcw,
|
|
||||||
Save,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useScene } from "@pascal-app/core";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Sidebar,
|
|
||||||
SidebarContent,
|
|
||||||
SidebarHeader,
|
|
||||||
useSidebarStore,
|
|
||||||
} from "@/components/ui/primitives/sidebar";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/primitives/popover";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/primitives/tooltip";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { SettingsPanel } from "./panels/settings-panel";
|
|
||||||
import { SitePanel } from "./panels/site-panel";
|
|
||||||
import {
|
|
||||||
getProjectModel,
|
|
||||||
getProjectVersionById,
|
|
||||||
getProjectVersionList,
|
|
||||||
getProjectVersionStatus,
|
|
||||||
publishProjectModel,
|
|
||||||
saveProjectModel,
|
|
||||||
saveProjectVersion,
|
|
||||||
type SceneGraph,
|
|
||||||
type ProjectVersionListItem,
|
|
||||||
type ProjectVersionStatus,
|
|
||||||
} from "@/features/community/lib/models/actions";
|
|
||||||
import { useProjectStore } from "@/features/community/lib/projects/store";
|
|
||||||
import { updateProjectName } from "@/features/community/lib/projects/actions";
|
|
||||||
import { applySceneGraphToEditor } from "@/features/community/lib/models/hooks";
|
|
||||||
|
|
||||||
function formatRelativeTime(value: string): string {
|
|
||||||
const target = new Date(value).getTime();
|
|
||||||
const now = Date.now();
|
|
||||||
const diffSeconds = Math.max(1, Math.floor((now - target) / 1000));
|
|
||||||
|
|
||||||
if (diffSeconds < 60) return `${diffSeconds}s ago`;
|
|
||||||
|
|
||||||
const diffMinutes = Math.floor(diffSeconds / 60);
|
|
||||||
if (diffMinutes < 60) return `${diffMinutes}min ago`;
|
|
||||||
|
|
||||||
const diffHours = Math.floor(diffMinutes / 60);
|
|
||||||
if (diffHours < 24) return `${diffHours}h ago`;
|
|
||||||
|
|
||||||
const diffDays = Math.floor(diffHours / 24);
|
|
||||||
if (diffDays < 30) return `${diffDays}d ago`;
|
|
||||||
|
|
||||||
const diffMonths = Math.floor(diffDays / 30);
|
|
||||||
if (diffMonths < 12) return `${diffMonths}mo ago`;
|
|
||||||
|
|
||||||
const diffYears = Math.floor(diffMonths / 12);
|
|
||||||
return `${diffYears}y ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppSidebar() {
|
|
||||||
type VersionAction = "save" | "savePublish" | "publish";
|
|
||||||
type VersionItemAction = "restore" | "publish";
|
|
||||||
|
|
||||||
const [activePanel, setActivePanel] = useState<PanelId>("site");
|
|
||||||
const activeProject = useProjectStore((s) => s.activeProject);
|
|
||||||
const isVersionPreviewMode = useProjectStore((s) => s.isVersionPreviewMode);
|
|
||||||
const setIsVersionPreviewMode = useProjectStore((s) => s.setIsVersionPreviewMode);
|
|
||||||
const setIsSceneLoading = useProjectStore((s) => s.setIsSceneLoading);
|
|
||||||
const setAutosaveStatus = useProjectStore((s) => s.setAutosaveStatus);
|
|
||||||
|
|
||||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
|
||||||
const [titleValue, setTitleValue] = useState("");
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [versionStatus, setVersionStatus] = useState<ProjectVersionStatus | null>(null);
|
|
||||||
const [versionList, setVersionList] = useState<ProjectVersionListItem[]>([]);
|
|
||||||
const [isVersionsOpen, setIsVersionsOpen] = useState(false);
|
|
||||||
const [isVersionListLoading, setIsVersionListLoading] = useState(false);
|
|
||||||
const [previewVersion, setPreviewVersion] = useState<{
|
|
||||||
id: string;
|
|
||||||
version: number;
|
|
||||||
} | null>(null);
|
|
||||||
const [activeVersionAction, setActiveVersionAction] = useState<VersionAction | null>(null);
|
|
||||||
const [activeVersionItemAction, setActiveVersionItemAction] = useState<{
|
|
||||||
version: number;
|
|
||||||
action: VersionItemAction;
|
|
||||||
} | null>(null);
|
|
||||||
const latestSceneSnapshotRef = useRef<SceneGraph | null>(null);
|
|
||||||
const activeProjectId = activeProject?.id ?? null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Widen default sidebar (288px → 432px) for better project title visibility
|
|
||||||
const store = useSidebarStore.getState();
|
|
||||||
if (store.width <= 288) {
|
|
||||||
store.setWidth(432);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isEditingTitle) {
|
|
||||||
setTitleValue(activeProject?.name || "Untitled Project");
|
|
||||||
setTimeout(() => {
|
|
||||||
if (inputRef.current) {
|
|
||||||
inputRef.current.focus();
|
|
||||||
inputRef.current.select();
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
}, [isEditingTitle, activeProject?.name]);
|
|
||||||
|
|
||||||
const handleSaveTitle = useCallback(async () => {
|
|
||||||
const trimmed = titleValue.trim();
|
|
||||||
if (trimmed && activeProject && trimmed !== activeProject.name) {
|
|
||||||
// Optimistic update
|
|
||||||
useProjectStore.setState((state) => ({
|
|
||||||
activeProject: state.activeProject ? { ...state.activeProject, name: trimmed } : null,
|
|
||||||
projects: state.projects.map((p) => p.id === activeProject.id ? { ...p, name: trimmed } : p)
|
|
||||||
}));
|
|
||||||
// Server update
|
|
||||||
try {
|
|
||||||
await updateProjectName(activeProject.id, trimmed);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to update project name:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setIsEditingTitle(false);
|
|
||||||
}, [titleValue, activeProject]);
|
|
||||||
|
|
||||||
const applyVersionStatus = useCallback(
|
|
||||||
(status: ProjectVersionStatus) => {
|
|
||||||
if (!activeProjectId) return;
|
|
||||||
|
|
||||||
const publishedVersion = status.publishedVersion ?? null;
|
|
||||||
setVersionStatus(status);
|
|
||||||
useProjectStore.setState((state) => ({
|
|
||||||
activeProject: state.activeProject
|
|
||||||
? {
|
|
||||||
...state.activeProject,
|
|
||||||
published_model_version: publishedVersion,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
projects: state.projects.map((project) =>
|
|
||||||
project.id === activeProjectId
|
|
||||||
? {
|
|
||||||
...project,
|
|
||||||
published_model_version: publishedVersion,
|
|
||||||
}
|
|
||||||
: project,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
[activeProjectId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const refreshVersionStatus = useCallback(async () => {
|
|
||||||
if (!activeProjectId) {
|
|
||||||
setVersionStatus(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusResult = await getProjectVersionStatus(activeProjectId);
|
|
||||||
if (!statusResult.success || !statusResult.data) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (useProjectStore.getState().activeProject?.id !== activeProjectId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
applyVersionStatus(statusResult.data);
|
|
||||||
}, [activeProjectId, applyVersionStatus]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activeProjectId) {
|
|
||||||
setVersionStatus(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshVersionStatus();
|
|
||||||
|
|
||||||
const intervalId = window.setInterval(() => {
|
|
||||||
refreshVersionStatus();
|
|
||||||
}, 12_000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.clearInterval(intervalId);
|
|
||||||
};
|
|
||||||
}, [activeProjectId, refreshVersionStatus]);
|
|
||||||
|
|
||||||
const loadVersionList = useCallback(async () => {
|
|
||||||
if (!activeProjectId) {
|
|
||||||
setVersionList([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsVersionListLoading(true);
|
|
||||||
try {
|
|
||||||
const result = await getProjectVersionList(activeProjectId);
|
|
||||||
if (!result.success || !result.data) {
|
|
||||||
setVersionList([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setVersionList(result.data);
|
|
||||||
} finally {
|
|
||||||
setIsVersionListLoading(false);
|
|
||||||
}
|
|
||||||
}, [activeProjectId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activeProjectId) {
|
|
||||||
setVersionList([]);
|
|
||||||
setPreviewVersion(null);
|
|
||||||
setIsVersionPreviewMode(false);
|
|
||||||
latestSceneSnapshotRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadVersionList();
|
|
||||||
setPreviewVersion(null);
|
|
||||||
setIsVersionPreviewMode(false);
|
|
||||||
latestSceneSnapshotRef.current = null;
|
|
||||||
}, [activeProjectId, loadVersionList, setIsVersionPreviewMode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isVersionsOpen) {
|
|
||||||
loadVersionList();
|
|
||||||
}
|
|
||||||
}, [isVersionsOpen, loadVersionList]);
|
|
||||||
|
|
||||||
const applySceneWithoutAutosave = useCallback(
|
|
||||||
(sceneGraph: Parameters<typeof applySceneGraphToEditor>[0], keepPreviewMode: boolean) => {
|
|
||||||
setIsVersionPreviewMode(true);
|
|
||||||
applySceneGraphToEditor(sceneGraph);
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setIsVersionPreviewMode(keepPreviewMode);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[setIsVersionPreviewMode],
|
|
||||||
);
|
|
||||||
|
|
||||||
const snapshotCurrentSceneGraph = useCallback((): SceneGraph => {
|
|
||||||
const { nodes, rootNodeIds } = useScene.getState();
|
|
||||||
// Keep a local latest snapshot so preview toggles never drop unsaved work.
|
|
||||||
return JSON.parse(JSON.stringify({ nodes, rootNodeIds })) as SceneGraph;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handlePreviewVersion = useCallback(
|
|
||||||
async (modelId: string, version: number) => {
|
|
||||||
if (!activeProjectId) return;
|
|
||||||
|
|
||||||
if (!isVersionPreviewMode) {
|
|
||||||
latestSceneSnapshotRef.current = snapshotCurrentSceneGraph();
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSceneLoading(true);
|
|
||||||
try {
|
|
||||||
const result = await getProjectVersionById(activeProjectId, modelId);
|
|
||||||
if (!result.success || !result.data?.scene_graph) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
applySceneWithoutAutosave(result.data.scene_graph, true);
|
|
||||||
setPreviewVersion({ id: modelId, version });
|
|
||||||
} finally {
|
|
||||||
setIsSceneLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
activeProjectId,
|
|
||||||
applySceneWithoutAutosave,
|
|
||||||
isVersionPreviewMode,
|
|
||||||
setIsSceneLoading,
|
|
||||||
snapshotCurrentSceneGraph,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleBackToLatest = useCallback(async () => {
|
|
||||||
if (!activeProjectId) return;
|
|
||||||
|
|
||||||
setIsSceneLoading(true);
|
|
||||||
try {
|
|
||||||
const latestSceneSnapshot = latestSceneSnapshotRef.current;
|
|
||||||
if (latestSceneSnapshot) {
|
|
||||||
applySceneWithoutAutosave(latestSceneSnapshot, false);
|
|
||||||
setPreviewVersion(null);
|
|
||||||
latestSceneSnapshotRef.current = null;
|
|
||||||
|
|
||||||
setAutosaveStatus("saving");
|
|
||||||
const saveResult = await saveProjectModel(activeProjectId, latestSceneSnapshot);
|
|
||||||
if (saveResult.success) {
|
|
||||||
if (saveResult.data) {
|
|
||||||
applyVersionStatus(saveResult.data);
|
|
||||||
}
|
|
||||||
setAutosaveStatus("saved");
|
|
||||||
await loadVersionList();
|
|
||||||
} else {
|
|
||||||
setAutosaveStatus("pending");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await getProjectModel(activeProjectId);
|
|
||||||
const sceneGraph = result.success ? result.data?.model?.scene_graph ?? null : null;
|
|
||||||
applySceneWithoutAutosave(sceneGraph, false);
|
|
||||||
setPreviewVersion(null);
|
|
||||||
setAutosaveStatus("saved");
|
|
||||||
} finally {
|
|
||||||
setIsSceneLoading(false);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
activeProjectId,
|
|
||||||
applySceneWithoutAutosave,
|
|
||||||
applyVersionStatus,
|
|
||||||
loadVersionList,
|
|
||||||
setAutosaveStatus,
|
|
||||||
setIsSceneLoading,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleRestoreVersion = useCallback(
|
|
||||||
async (modelId: string, version: number) => {
|
|
||||||
if (!activeProjectId || activeVersionItemAction) return;
|
|
||||||
|
|
||||||
setActiveVersionItemAction({ version, action: "restore" });
|
|
||||||
setIsSceneLoading(true);
|
|
||||||
try {
|
|
||||||
const versionResult = await getProjectVersionById(activeProjectId, modelId);
|
|
||||||
if (!versionResult.success || !versionResult.data?.scene_graph) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveResult = await saveProjectModel(activeProjectId, versionResult.data.scene_graph, {
|
|
||||||
restoredFromVersion: version,
|
|
||||||
});
|
|
||||||
if (!saveResult.success) {
|
|
||||||
console.error("Failed to restore version:", saveResult.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (saveResult.data) {
|
|
||||||
applyVersionStatus(saveResult.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
applySceneWithoutAutosave(versionResult.data.scene_graph, false);
|
|
||||||
setPreviewVersion(null);
|
|
||||||
latestSceneSnapshotRef.current = null;
|
|
||||||
setAutosaveStatus("saved");
|
|
||||||
await loadVersionList();
|
|
||||||
} finally {
|
|
||||||
setIsSceneLoading(false);
|
|
||||||
setActiveVersionItemAction(null);
|
|
||||||
refreshVersionStatus();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
activeProjectId,
|
|
||||||
activeVersionItemAction,
|
|
||||||
applySceneWithoutAutosave,
|
|
||||||
applyVersionStatus,
|
|
||||||
loadVersionList,
|
|
||||||
refreshVersionStatus,
|
|
||||||
setAutosaveStatus,
|
|
||||||
setIsSceneLoading,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePublishVersion = useCallback(
|
|
||||||
async (version: number) => {
|
|
||||||
if (!activeProjectId || activeVersionItemAction) return;
|
|
||||||
|
|
||||||
setActiveVersionItemAction({ version, action: "publish" });
|
|
||||||
try {
|
|
||||||
const result = await publishProjectModel(activeProjectId, { version });
|
|
||||||
if (!result.success || !result.data) {
|
|
||||||
console.error("Failed to publish version:", result.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
applyVersionStatus(result.data);
|
|
||||||
await loadVersionList();
|
|
||||||
} finally {
|
|
||||||
setActiveVersionItemAction(null);
|
|
||||||
refreshVersionStatus();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
activeProjectId,
|
|
||||||
activeVersionItemAction,
|
|
||||||
applyVersionStatus,
|
|
||||||
loadVersionList,
|
|
||||||
refreshVersionStatus,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const runVersionAction = useCallback(
|
|
||||||
async (action: VersionAction) => {
|
|
||||||
if (!activeProjectId || activeVersionAction || isVersionPreviewMode) return;
|
|
||||||
|
|
||||||
setActiveVersionAction(action);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { nodes, rootNodeIds } = useScene.getState();
|
|
||||||
const sceneGraph = { nodes, rootNodeIds };
|
|
||||||
|
|
||||||
// Flush latest in-memory scene into the current draft before version actions.
|
|
||||||
const saveDraftResult = await saveProjectModel(activeProjectId, sceneGraph);
|
|
||||||
if (!saveDraftResult.success) {
|
|
||||||
console.error("Failed to save draft:", saveDraftResult.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (saveDraftResult.data) {
|
|
||||||
applyVersionStatus(saveDraftResult.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
const versionResult = await saveProjectVersion(activeProjectId, {
|
|
||||||
publish: action !== "save",
|
|
||||||
});
|
|
||||||
if (!versionResult.success || !versionResult.data) {
|
|
||||||
console.error("Failed to save/publish version:", versionResult.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (useProjectStore.getState().activeProject?.id !== activeProjectId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
applyVersionStatus(versionResult.data);
|
|
||||||
await loadVersionList();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to run version action:", error);
|
|
||||||
} finally {
|
|
||||||
setActiveVersionAction(null);
|
|
||||||
refreshVersionStatus();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
activeProjectId,
|
|
||||||
activeVersionAction,
|
|
||||||
applyVersionStatus,
|
|
||||||
isVersionPreviewMode,
|
|
||||||
loadVersionList,
|
|
||||||
refreshVersionStatus,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const isVersionActionRunning = activeVersionAction !== null;
|
|
||||||
const isVersionActionsDisabled = isVersionActionRunning || isVersionPreviewMode;
|
|
||||||
const isQuickSaveDisabled = isVersionActionsDisabled;
|
|
||||||
const quickSaveLabel = activeVersionAction === "save" ? "Saving..." : "Save";
|
|
||||||
const quickSaveDescription = isVersionPreviewMode
|
|
||||||
? "Back to latest to save"
|
|
||||||
: "Save a new version";
|
|
||||||
|
|
||||||
const triggerVersionLabel = useMemo(() => {
|
|
||||||
if (isVersionPreviewMode && previewVersion !== null) {
|
|
||||||
return `v${previewVersion.version}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (versionStatus?.draftVersion !== null && versionStatus?.draftVersion !== undefined) {
|
|
||||||
return "Latest";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (versionStatus?.latestSavedVersion !== null && versionStatus?.latestSavedVersion !== undefined) {
|
|
||||||
return "Latest";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "Versions";
|
|
||||||
}, [
|
|
||||||
isVersionPreviewMode,
|
|
||||||
previewVersion,
|
|
||||||
versionStatus?.draftVersion,
|
|
||||||
versionStatus?.latestSavedVersion,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSaveTitle();
|
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsEditingTitle(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderPanelContent = () => {
|
|
||||||
switch (activePanel) {
|
|
||||||
case "site":
|
|
||||||
return <SitePanel />;
|
|
||||||
case "settings":
|
|
||||||
return <SettingsPanel />;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Sidebar className={cn("dark text-white ")} variant="floating">
|
|
||||||
<div className="flex h-full">
|
|
||||||
{/* Icon Rail */}
|
|
||||||
<IconRail activePanel={activePanel} onPanelChange={setActivePanel} />
|
|
||||||
|
|
||||||
{/* Panel Content */}
|
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
|
||||||
<SidebarHeader className="flex-col items-start justify-center px-3 py-3 gap-1 border-b border-border/50 relative">
|
|
||||||
<div className="flex w-full items-center justify-between gap-2">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
{isEditingTitle ? (
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={titleValue}
|
|
||||||
onChange={(e) => setTitleValue(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onBlur={handleSaveTitle}
|
|
||||||
placeholder="Untitled Project"
|
|
||||||
className="w-full bg-transparent text-foreground outline-none border-b border-primary/50 focus:border-primary rounded-none px-0 py-0 m-0 h-7 font-semibold text-lg"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<h1
|
|
||||||
className="font-semibold text-lg truncate cursor-text w-full h-7 border-b border-transparent hover:border-border/50 transition-colors leading-7"
|
|
||||||
onClick={() => setIsEditingTitle(true)}
|
|
||||||
>
|
|
||||||
{activeProject?.name || "Untitled Project"}
|
|
||||||
</h1>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={cn("shrink-0 flex items-center gap-1 transition-all duration-200", isEditingTitle && "hidden")}>
|
|
||||||
{activeProjectId && (
|
|
||||||
<Popover open={isVersionsOpen} onOpenChange={setIsVersionsOpen}>
|
|
||||||
<div className="inline-flex h-8 overflow-hidden rounded-full border border-border/50 bg-black/20">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => runVersionAction("save")}
|
|
||||||
disabled={isQuickSaveDisabled}
|
|
||||||
className={cn(
|
|
||||||
"group/save-trigger relative inline-flex h-full w-16 items-center border-r border-border/50 px-1.5 text-[10px] transition-colors",
|
|
||||||
isQuickSaveDisabled
|
|
||||||
? "cursor-not-allowed opacity-50"
|
|
||||||
: "hover:bg-black/30",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="pointer-events-none inline-flex min-w-0 items-center gap-1 transition-opacity group-hover/save-trigger:opacity-0">
|
|
||||||
<Clock3 className="h-3 w-3 shrink-0 text-muted-foreground" />
|
|
||||||
<span className="min-w-0 truncate text-left text-muted-foreground">
|
|
||||||
{triggerVersionLabel}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="pointer-events-none absolute inset-0 flex items-center justify-center gap-1 opacity-0 transition-opacity group-hover/save-trigger:opacity-100">
|
|
||||||
<Save className="h-3 w-3 shrink-0 text-foreground" />
|
|
||||||
<span className="font-medium text-foreground">{quickSaveLabel}</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">{quickSaveDescription}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex h-full w-6 items-center justify-center text-muted-foreground transition-colors hover:bg-black/30 hover:text-foreground data-[state=open]:bg-black/35"
|
|
||||||
>
|
|
||||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
</div>
|
|
||||||
<PopoverContent
|
|
||||||
align="end"
|
|
||||||
className="w-[min(320px,calc(var(--sidebar-width)-3rem),calc(100vw-2rem))] min-w-[230px] p-2"
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<div className="max-h-[280px] overflow-y-auto">
|
|
||||||
{isVersionListLoading ? (
|
|
||||||
<div className="px-2 py-3 text-xs text-muted-foreground">
|
|
||||||
Loading versions...
|
|
||||||
</div>
|
|
||||||
) : versionList.length === 0 ? (
|
|
||||||
<div className="px-2 py-3 text-xs text-muted-foreground">
|
|
||||||
No versions found
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
versionList.map((item) => {
|
|
||||||
const isPublished = item.isPublished;
|
|
||||||
const isCurrentlyViewed = isVersionPreviewMode
|
|
||||||
? previewVersion?.id === item.id
|
|
||||||
: item.isDraft;
|
|
||||||
const isActionPending = activeVersionItemAction?.version === item.version;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
className={cn(
|
|
||||||
"group/version-item relative mb-0.5 flex items-center gap-1 rounded-md px-2 py-1.5 transition-colors",
|
|
||||||
isCurrentlyViewed ? "bg-accent/25" : "hover:bg-accent/20"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isCurrentlyViewed && (
|
|
||||||
<span className="pointer-events-none absolute right-0 top-1 bottom-1 w-0.5 rounded-full bg-primary/70" />
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
item.isDraft
|
|
||||||
? handleBackToLatest()
|
|
||||||
: handlePreviewVersion(item.id, item.version)
|
|
||||||
}
|
|
||||||
className="min-w-0 flex-1 text-left"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="truncate text-sm font-medium leading-none">
|
|
||||||
{item.isDraft
|
|
||||||
? "Latest"
|
|
||||||
: `Version ${item.version}`}
|
|
||||||
</span>
|
|
||||||
{item.isDraft && item.restoredFromVersion !== null && (
|
|
||||||
<span className="text-[10px] text-muted-foreground">
|
|
||||||
restored from v{item.restoredFromVersion}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 text-[11px] text-muted-foreground">
|
|
||||||
{formatRelativeTime(item.updatedAt)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{!item.isDraft && (
|
|
||||||
<div className="absolute right-1 top-1 flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
handleRestoreVersion(item.id, item.version);
|
|
||||||
}}
|
|
||||||
disabled={!!activeVersionItemAction}
|
|
||||||
className={cn(
|
|
||||||
"group/restore pointer-events-none inline-flex h-6 items-center rounded-md border border-border/50 bg-background/80 px-1.5 text-muted-foreground opacity-0 transition-all duration-150 group-hover/version-item:pointer-events-auto group-hover/version-item:opacity-100 hover:border-border hover:bg-accent/20 hover:text-foreground",
|
|
||||||
isActionPending &&
|
|
||||||
activeVersionItemAction?.action === "restore" &&
|
|
||||||
"border-primary/40 text-primary"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<RotateCcw className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span className="max-w-0 overflow-hidden whitespace-nowrap text-[10px] opacity-0 transition-all duration-150 group-hover/restore:ml-1 group-hover/restore:max-w-14 group-hover/restore:opacity-100">
|
|
||||||
Restore
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isPublished ? (
|
|
||||||
<span className="inline-flex h-6 items-center rounded-md bg-emerald-500/15 px-2 text-[10px] font-medium text-emerald-400">
|
|
||||||
Published
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
handlePublishVersion(item.version);
|
|
||||||
}}
|
|
||||||
disabled={!!activeVersionItemAction}
|
|
||||||
className={cn(
|
|
||||||
"group/publish pointer-events-none inline-flex h-6 items-center rounded-md border border-sky-500/35 bg-sky-500/10 px-1.5 text-sky-300 opacity-0 transition-all duration-150 group-hover/version-item:pointer-events-auto group-hover/version-item:opacity-100 hover:border-sky-400/50 hover:bg-sky-500/20 hover:text-sky-200",
|
|
||||||
isActionPending &&
|
|
||||||
activeVersionItemAction?.action === "publish" &&
|
|
||||||
"border-sky-300/60 text-sky-200"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ArrowUpCircle className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span className="max-w-0 overflow-hidden whitespace-nowrap text-[10px] opacity-0 transition-all duration-150 group-hover/publish:ml-1 group-hover/publish:max-w-14 group-hover/publish:opacity-100">
|
|
||||||
Publish
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SidebarHeader>
|
|
||||||
|
|
||||||
<SidebarContent
|
|
||||||
className={cn("no-scrollbar flex flex-1 flex-col overflow-hidden")}
|
|
||||||
>
|
|
||||||
{renderPanelContent()}
|
|
||||||
</SidebarContent>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Sidebar>
|
|
||||||
<CommandPalette />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ArrowLeft, Command, FolderOpen, Moon, Search, Sun } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/primitives/tooltip";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/primitives/popover";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/primitives/dialog";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useViewer } from "@pascal-app/viewer";
|
|
||||||
import { useProjectStore } from "@/features/community/lib/projects/store";
|
|
||||||
import { useCommandPalette } from "@/components/ui/command-palette";
|
|
||||||
|
|
||||||
function OpenProjectModal({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
}) {
|
|
||||||
const router = useRouter();
|
|
||||||
const projects = useProjectStore((s) => s.projects);
|
|
||||||
const activeProject = useProjectStore((s) => s.activeProject);
|
|
||||||
const fetchProjects = useProjectStore((s) => s.fetchProjects);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open && projects.length === 0) {
|
|
||||||
fetchProjects();
|
|
||||||
}
|
|
||||||
}, [open, projects.length, fetchProjects]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="sm:max-w-sm p-0 gap-0 overflow-hidden">
|
|
||||||
<DialogHeader className="px-4 pt-4 pb-3 border-b border-border/50">
|
|
||||||
<DialogTitle className="text-sm font-medium">Open project</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="max-h-80 overflow-y-auto p-1.5">
|
|
||||||
{projects.length === 0 ? (
|
|
||||||
<p className="px-3 py-6 text-sm text-muted-foreground text-center">
|
|
||||||
No projects found
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
projects.map((project) => {
|
|
||||||
const isActive = project.id === activeProject?.id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={project.id}
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors hover:bg-accent",
|
|
||||||
isActive && "bg-accent/50"
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
onOpenChange(false);
|
|
||||||
router.push(`/editor/${project.id}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded bg-muted overflow-hidden">
|
|
||||||
{project.thumbnail_url ? (
|
|
||||||
<img
|
|
||||||
src={project.thumbnail_url}
|
|
||||||
alt=""
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="flex-1 min-w-0 truncate text-sm font-medium">
|
|
||||||
{project.name}
|
|
||||||
</p>
|
|
||||||
{isActive && (
|
|
||||||
<div className="h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PanelId = "site" | "settings";
|
|
||||||
|
|
||||||
interface IconRailProps {
|
|
||||||
activePanel: PanelId;
|
|
||||||
onPanelChange: (panel: PanelId) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const panels: { id: PanelId; iconSrc: string; label: string }[] = [
|
|
||||||
{ id: "site", iconSrc: "/icons/level.png", label: "Site" },
|
|
||||||
{ id: "settings", iconSrc: "/icons/settings.png", label: "Settings" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function IconRail({
|
|
||||||
activePanel,
|
|
||||||
onPanelChange,
|
|
||||||
className,
|
|
||||||
}: IconRailProps) {
|
|
||||||
const theme = useViewer((state) => state.theme);
|
|
||||||
const setTheme = useViewer((state) => state.setTheme);
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
||||||
const [isOpenProjectOpen, setIsSwitchProjectOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleOpenProject = () => {
|
|
||||||
setIsMenuOpen(false);
|
|
||||||
setIsSwitchProjectOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex h-full w-11 flex-col items-center gap-1 border-border/50 border-r py-2",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Pascal logo — app menu */}
|
|
||||||
<Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex h-9 w-9 items-center justify-center rounded-lg transition-all hover:bg-accent"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src="/pascal-logo-shape.svg"
|
|
||||||
alt="Pascal"
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
className="h-6 w-6 dark:invert"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent side="right" align="start" className="w-52 p-1" sideOffset={8}>
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
|
||||||
onClick={() => setIsMenuOpen(false)}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
|
||||||
Back to community
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
|
||||||
onClick={handleOpenProject}
|
|
||||||
>
|
|
||||||
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
|
||||||
Open project
|
|
||||||
</button>
|
|
||||||
<div className="my-1 h-px bg-border/50" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
||||||
onClick={() => { setIsMenuOpen(false); useCommandPalette.getState().setOpen(true); }}
|
|
||||||
>
|
|
||||||
<Search className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span className="flex-1 text-left">Actions...</span>
|
|
||||||
<span className="flex items-center gap-0.5 rounded border border-border/60 bg-muted/60 px-1 py-0.5 text-[10px] leading-none text-muted-foreground">
|
|
||||||
<Command className="h-2.5 w-2.5" />
|
|
||||||
K
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="w-8 h-px bg-border/50 mb-1" />
|
|
||||||
|
|
||||||
{panels.map((panel) => {
|
|
||||||
const isActive = activePanel === panel.id;
|
|
||||||
return (
|
|
||||||
<Tooltip key={panel.id}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"flex h-9 w-9 items-center justify-center rounded-lg transition-all",
|
|
||||||
isActive ? "bg-accent" : "hover:bg-accent",
|
|
||||||
)}
|
|
||||||
onClick={() => onPanelChange(panel.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={panel.iconSrc}
|
|
||||||
alt={panel.label}
|
|
||||||
className={cn(
|
|
||||||
"h-6 w-6 transition-all object-contain",
|
|
||||||
!isActive && "opacity-50 saturate-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">{panel.label}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Spacer */}
|
|
||||||
<div className="flex-1" />
|
|
||||||
|
|
||||||
{/* Theme Toggle */}
|
|
||||||
{mounted && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/50 bg-accent/40 transition-all text-foreground hover:bg-accent mb-2"
|
|
||||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
key={theme}
|
|
||||||
initial={{ rotate: -90, opacity: 0 }}
|
|
||||||
animate={{ rotate: 0, opacity: 1 }}
|
|
||||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
|
||||||
>
|
|
||||||
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
|
||||||
</motion.div>
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">Toggle theme</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<OpenProjectModal open={isOpenProjectOpen} onOpenChange={setIsSwitchProjectOpen} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { panels };
|
|
||||||
-189
@@ -1,189 +0,0 @@
|
|||||||
import { Keyboard } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/primitives/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/primitives/dialog";
|
|
||||||
|
|
||||||
type Shortcut = {
|
|
||||||
keys: string[];
|
|
||||||
action: string;
|
|
||||||
note?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ShortcutCategory = {
|
|
||||||
title: string;
|
|
||||||
shortcuts: Shortcut[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const KEY_DISPLAY_MAP: Record<string, string> = {
|
|
||||||
"Arrow Up": "↑",
|
|
||||||
"Arrow Down": "↓",
|
|
||||||
Esc: "⎋",
|
|
||||||
Shift: "⇧",
|
|
||||||
Space: "␣",
|
|
||||||
};
|
|
||||||
|
|
||||||
const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
|
|
||||||
{
|
|
||||||
title: "Editor Navigation",
|
|
||||||
shortcuts: [
|
|
||||||
{ keys: ["1"], action: "Switch to Site phase" },
|
|
||||||
{ keys: ["2"], action: "Switch to Structure phase" },
|
|
||||||
{ keys: ["3"], action: "Switch to Furnish phase" },
|
|
||||||
{ keys: ["S"], action: "Switch to Structure layer" },
|
|
||||||
{ keys: ["F"], action: "Switch to Furnish layer" },
|
|
||||||
{ keys: ["Z"], action: "Switch to Zones layer" },
|
|
||||||
{
|
|
||||||
keys: ["Cmd/Ctrl", "Arrow Up"],
|
|
||||||
action: "Select next level in the active building",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keys: ["Cmd/Ctrl", "Arrow Down"],
|
|
||||||
action: "Select previous level in the active building",
|
|
||||||
},
|
|
||||||
{ keys: ["Cmd/Ctrl", "B"], action: "Toggle sidebar" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Modes & History",
|
|
||||||
shortcuts: [
|
|
||||||
{ keys: ["V"], action: "Switch to Select mode" },
|
|
||||||
{ keys: ["B"], action: "Switch to Build mode" },
|
|
||||||
{
|
|
||||||
keys: ["Esc"],
|
|
||||||
action: "Cancel active tool, clear selection, and exit build mode",
|
|
||||||
},
|
|
||||||
{ keys: ["Delete / Backspace"], action: "Delete selected objects" },
|
|
||||||
{ keys: ["Cmd/Ctrl", "Z"], action: "Undo" },
|
|
||||||
{ keys: ["Cmd/Ctrl", "Shift", "Z"], action: "Redo" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Selection",
|
|
||||||
shortcuts: [
|
|
||||||
{
|
|
||||||
keys: ["Cmd/Ctrl", "Click"],
|
|
||||||
action: "Add or remove an object from multi-selection",
|
|
||||||
note: "Works while in Select mode.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Drawing Tools",
|
|
||||||
shortcuts: [
|
|
||||||
{
|
|
||||||
keys: ["Shift"],
|
|
||||||
action: "Temporarily disable angle snapping while drawing walls, slabs, and ceilings",
|
|
||||||
note: "Hold while drawing.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Item Placement",
|
|
||||||
shortcuts: [
|
|
||||||
{ keys: ["R"], action: "Rotate item clockwise by 90 degrees" },
|
|
||||||
{ keys: ["T"], action: "Rotate item counter-clockwise by 90 degrees" },
|
|
||||||
{
|
|
||||||
keys: ["Shift"],
|
|
||||||
action: "Temporarily bypass placement validation constraints",
|
|
||||||
note: "Hold while placing.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Camera",
|
|
||||||
shortcuts: [
|
|
||||||
{
|
|
||||||
keys: ["Space", "Drag"],
|
|
||||||
action: "Pan camera",
|
|
||||||
note: "Hold Space while dragging with the mouse.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function getDisplayKey(key: string, isMac: boolean): string {
|
|
||||||
if (key === "Cmd/Ctrl") return isMac ? "⌘" : "Ctrl";
|
|
||||||
if (key === "Delete / Backspace") return isMac ? "⌫" : "Backspace";
|
|
||||||
return KEY_DISPLAY_MAP[key] ?? key;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ShortcutKeys({ keys }: { keys: string[] }) {
|
|
||||||
const [isMac, setIsMac] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsMac(navigator.platform.toUpperCase().indexOf("MAC") >= 0);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
|
||||||
{keys.map((key, index) => (
|
|
||||||
<div key={`${key}-${index}`} className="flex items-center gap-1">
|
|
||||||
{index > 0 ? (
|
|
||||||
<span className="text-[10px] text-muted-foreground">+</span>
|
|
||||||
) : null}
|
|
||||||
<kbd
|
|
||||||
className="inline-flex h-6 items-center rounded border border-border bg-muted px-2 font-mono text-[11px] font-medium text-muted-foreground"
|
|
||||||
title={key}
|
|
||||||
>
|
|
||||||
{getDisplayKey(key, isMac)}
|
|
||||||
</kbd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KeyboardShortcutsDialog() {
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button className="w-full justify-start gap-2" variant="outline">
|
|
||||||
<Keyboard className="size-4" />
|
|
||||||
Keyboard Shortcuts
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="max-h-[85vh] flex flex-col overflow-hidden p-0 sm:max-w-3xl">
|
|
||||||
<DialogHeader className="shrink-0 border-b px-6 py-4">
|
|
||||||
<DialogTitle>Keyboard Shortcuts</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Shortcuts are context-aware and depend on the current phase or tool.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-5">
|
|
||||||
{SHORTCUT_CATEGORIES.map((category) => (
|
|
||||||
<section key={category.title} className="space-y-2">
|
|
||||||
<h3 className="font-medium text-sm">{category.title}</h3>
|
|
||||||
<div className="overflow-hidden rounded-md border border-border/80">
|
|
||||||
{category.shortcuts.map((shortcut, index) => (
|
|
||||||
<div
|
|
||||||
key={`${category.title}-${shortcut.action}`}
|
|
||||||
className="grid grid-cols-[minmax(130px,220px)_1fr] gap-3 px-3 py-2"
|
|
||||||
>
|
|
||||||
<ShortcutKeys keys={shortcut.keys} />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm">{shortcut.action}</p>
|
|
||||||
{shortcut.note ? (
|
|
||||||
<p className="text-muted-foreground text-xs">{shortcut.note}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{index < category.shortcuts.length - 1 ? (
|
|
||||||
<div className="col-span-2 border-border/60 border-b" />
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import { type AnyNodeId, CeilingNode, useScene } from "@pascal-app/core";
|
|
||||||
import { useViewer } from "@pascal-app/viewer";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import useEditor from "@/store/use-editor";
|
|
||||||
import { InlineRenameInput } from "./inline-rename-input";
|
|
||||||
import { TreeNode, TreeNodeWrapper, handleTreeSelection } from "./tree-node";
|
|
||||||
import { TreeNodeActions } from "./tree-node-actions";
|
|
||||||
|
|
||||||
interface CeilingTreeNodeProps {
|
|
||||||
node: CeilingNode;
|
|
||||||
depth: number;
|
|
||||||
isLast?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CeilingTreeNode({ node, depth, isLast }: CeilingTreeNodeProps) {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const selectedIds = useViewer((state) => state.selection.selectedIds);
|
|
||||||
const isSelected = selectedIds.includes(node.id);
|
|
||||||
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
|
||||||
const setSelection = useViewer((state) => state.setSelection);
|
|
||||||
const setHoveredId = useViewer((state) => state.setHoveredId);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedIds.length === 0) return;
|
|
||||||
const nodes = useScene.getState().nodes;
|
|
||||||
let isDescendant = false;
|
|
||||||
for (const id of selectedIds) {
|
|
||||||
let current = nodes[id as AnyNodeId];
|
|
||||||
while (current && current.parentId) {
|
|
||||||
if (current.parentId === node.id) {
|
|
||||||
isDescendant = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
current = nodes[current.parentId as AnyNodeId];
|
|
||||||
}
|
|
||||||
if (isDescendant) break;
|
|
||||||
}
|
|
||||||
if (isDescendant) {
|
|
||||||
setExpanded(true);
|
|
||||||
}
|
|
||||||
}, [selectedIds, node.id]);
|
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection);
|
|
||||||
if (!handled && useEditor.getState().phase === "furnish") {
|
|
||||||
useEditor.getState().setPhase("structure");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDoubleClick = () => {
|
|
||||||
setIsEditing(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
|
||||||
setHoveredId(node.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
|
||||||
setHoveredId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate approximate area from polygon
|
|
||||||
const area = calculatePolygonArea(node.polygon).toFixed(1);
|
|
||||||
const defaultName = `Ceiling (${area}m²)`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TreeNodeWrapper
|
|
||||||
nodeId={node.id}
|
|
||||||
icon={<Image src="/icons/ceiling.png" alt="" width={14} height={14} className="object-contain" />}
|
|
||||||
label={
|
|
||||||
<InlineRenameInput
|
|
||||||
node={node}
|
|
||||||
isEditing={isEditing}
|
|
||||||
onStopEditing={() => setIsEditing(false)}
|
|
||||||
onStartEditing={() => setIsEditing(true)}
|
|
||||||
defaultName={defaultName}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
depth={depth}
|
|
||||||
hasChildren={node.children.length > 0}
|
|
||||||
expanded={expanded}
|
|
||||||
onToggle={() => setExpanded(!expanded)}
|
|
||||||
onClick={handleClick}
|
|
||||||
onDoubleClick={handleDoubleClick}
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
isSelected={isSelected}
|
|
||||||
isHovered={isHovered}
|
|
||||||
isVisible={node.visible !== false}
|
|
||||||
isLast={isLast}
|
|
||||||
actions={<TreeNodeActions node={node} />}
|
|
||||||
>
|
|
||||||
{node.children.map((childId, index) => (
|
|
||||||
<TreeNode key={childId} nodeId={childId} depth={depth + 1} isLast={index === node.children.length - 1} />
|
|
||||||
))}
|
|
||||||
</TreeNodeWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate the area of a polygon using the shoelace formula
|
|
||||||
*/
|
|
||||||
function calculatePolygonArea(polygon: Array<[number, number]>): number {
|
|
||||||
if (polygon.length < 3) return 0;
|
|
||||||
|
|
||||||
let area = 0;
|
|
||||||
const n = polygon.length;
|
|
||||||
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const j = (i + 1) % n;
|
|
||||||
area += polygon[i]![0] * polygon[j]![1];
|
|
||||||
area -= polygon[j]![0] * polygon[i]![1];
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.abs(area) / 2;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,98 +0,0 @@
|
|||||||
import { useScene, type AnyNode } from "@pascal-app/core";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { Pencil } from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface InlineRenameInputProps {
|
|
||||||
node: AnyNode;
|
|
||||||
isEditing: boolean;
|
|
||||||
onStopEditing: () => void;
|
|
||||||
defaultName: string;
|
|
||||||
className?: string;
|
|
||||||
onStartEditing?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InlineRenameInput({
|
|
||||||
node,
|
|
||||||
isEditing,
|
|
||||||
onStopEditing,
|
|
||||||
defaultName,
|
|
||||||
className,
|
|
||||||
onStartEditing,
|
|
||||||
}: InlineRenameInputProps) {
|
|
||||||
const updateNode = useScene((s) => s.updateNode);
|
|
||||||
const [value, setValue] = useState(node.name || "");
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isEditing) {
|
|
||||||
setValue(node.name || "");
|
|
||||||
// Focus and select all text after a short delay
|
|
||||||
setTimeout(() => {
|
|
||||||
if (inputRef.current) {
|
|
||||||
inputRef.current.focus();
|
|
||||||
inputRef.current.select();
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
}, [isEditing, node.name]);
|
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (trimmed !== node.name) {
|
|
||||||
updateNode(node.id, { name: trimmed || undefined });
|
|
||||||
}
|
|
||||||
onStopEditing();
|
|
||||||
}, [value, node.id, node.name, updateNode, onStopEditing]);
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSave();
|
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
onStopEditing();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isEditing) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1 group/rename min-w-0 h-5">
|
|
||||||
<span
|
|
||||||
className={cn("truncate border-b border-transparent", className)}
|
|
||||||
>
|
|
||||||
{node.name || defaultName}
|
|
||||||
</span>
|
|
||||||
{onStartEditing && (
|
|
||||||
<button
|
|
||||||
className="opacity-0 group-hover/rename:opacity-100 transition-opacity text-muted-foreground hover:text-foreground shrink-0"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onStartEditing();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pencil className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => setValue(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onBlur={handleSave}
|
|
||||||
placeholder={defaultName}
|
|
||||||
className={cn(
|
|
||||||
"flex-1 w-full bg-transparent text-foreground outline-none border-b border-primary/50 focus:border-primary rounded-none px-0 py-0 m-0 h-5 text-sm",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onDoubleClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import { type AnyNodeId, ItemNode, useScene } from "@pascal-app/core";
|
|
||||||
import { useViewer } from "@pascal-app/viewer";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import useEditor from "@/store/use-editor";
|
|
||||||
import { InlineRenameInput } from "./inline-rename-input";
|
|
||||||
import { TreeNode, TreeNodeWrapper, handleTreeSelection } from "./tree-node";
|
|
||||||
import { TreeNodeActions } from "./tree-node-actions";
|
|
||||||
|
|
||||||
const CATEGORY_ICONS: Record<string, string> = {
|
|
||||||
door: "/icons/door.png",
|
|
||||||
window: "/icons/window.png",
|
|
||||||
furniture: "/icons/couch.png",
|
|
||||||
appliance: "/icons/appliance.png",
|
|
||||||
kitchen: "/icons/kitchen.png",
|
|
||||||
bathroom: "/icons/bathroom.png",
|
|
||||||
outdoor: "/icons/tree.png",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ItemTreeNodeProps {
|
|
||||||
node: ItemNode;
|
|
||||||
depth: number;
|
|
||||||
isLast?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ItemTreeNode({ node, depth, isLast }: ItemTreeNodeProps) {
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const [expanded, setExpanded] = useState(true);
|
|
||||||
const iconSrc = CATEGORY_ICONS[node.asset.category] || "/icons/couch.png";
|
|
||||||
const selectedIds = useViewer((state) => state.selection.selectedIds);
|
|
||||||
const isSelected = selectedIds.includes(node.id);
|
|
||||||
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
|
||||||
const setSelection = useViewer((state) => state.setSelection);
|
|
||||||
const setHoveredId = useViewer((state) => state.setHoveredId);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedIds.length === 0) return;
|
|
||||||
const nodes = useScene.getState().nodes;
|
|
||||||
let isDescendant = false;
|
|
||||||
for (const id of selectedIds) {
|
|
||||||
let current = nodes[id as AnyNodeId];
|
|
||||||
while (current && current.parentId) {
|
|
||||||
if (current.parentId === node.id) {
|
|
||||||
isDescendant = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
current = nodes[current.parentId as AnyNodeId];
|
|
||||||
}
|
|
||||||
if (isDescendant) break;
|
|
||||||
}
|
|
||||||
if (isDescendant) {
|
|
||||||
setExpanded(true);
|
|
||||||
}
|
|
||||||
}, [selectedIds, node.id]);
|
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection);
|
|
||||||
if (!handled && useEditor.getState().phase === "structure") {
|
|
||||||
useEditor.getState().setPhase("furnish");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDoubleClick = () => {
|
|
||||||
setIsEditing(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
|
||||||
setHoveredId(node.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
|
||||||
setHoveredId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultName = node.asset.name || "Item";
|
|
||||||
const hasChildren = node.children && node.children.length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TreeNodeWrapper
|
|
||||||
nodeId={node.id}
|
|
||||||
icon={<Image src={iconSrc} alt="" width={14} height={14} className="object-contain" />}
|
|
||||||
label={
|
|
||||||
<InlineRenameInput
|
|
||||||
node={node}
|
|
||||||
isEditing={isEditing}
|
|
||||||
onStopEditing={() => setIsEditing(false)}
|
|
||||||
onStartEditing={() => setIsEditing(true)}
|
|
||||||
defaultName={defaultName}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
depth={depth}
|
|
||||||
hasChildren={hasChildren}
|
|
||||||
expanded={expanded}
|
|
||||||
onToggle={() => setExpanded(!expanded)}
|
|
||||||
onClick={handleClick}
|
|
||||||
onDoubleClick={handleDoubleClick}
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
isSelected={isSelected}
|
|
||||||
isHovered={isHovered}
|
|
||||||
isVisible={node.visible !== false}
|
|
||||||
isLast={isLast}
|
|
||||||
actions={<TreeNodeActions node={node} />}
|
|
||||||
>
|
|
||||||
{hasChildren && node.children.map((childId, index) => (
|
|
||||||
<TreeNode key={childId} nodeId={childId} depth={depth + 1} isLast={index === node.children.length - 1} />
|
|
||||||
))}
|
|
||||||
</TreeNodeWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import { type AnyNode, type AnyNodeId, emitter, useScene } from "@pascal-app/core";
|
|
||||||
import { useViewer } from "@pascal-app/viewer";
|
|
||||||
import { Camera, Eye, EyeOff, Trash2 } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/primitives/popover";
|
|
||||||
|
|
||||||
interface TreeNodeActionsProps {
|
|
||||||
node: AnyNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TreeNodeActions({ node }: TreeNodeActionsProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const updateNode = useScene((state) => state.updateNode);
|
|
||||||
const updateNodes = useScene((state) => state.updateNodes);
|
|
||||||
const selectedIds = useViewer((state) => state.selection.selectedIds);
|
|
||||||
const hasCamera = !!node.camera;
|
|
||||||
const isVisible = node.visible !== false;
|
|
||||||
|
|
||||||
const toggleVisibility = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const newVisibility = !isVisible;
|
|
||||||
if (selectedIds && selectedIds.includes(node.id)) {
|
|
||||||
updateNodes(
|
|
||||||
selectedIds.map((id) => ({
|
|
||||||
id: id as AnyNodeId,
|
|
||||||
data: { visible: newVisibility },
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
updateNode(node.id, { visible: newVisibility });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCaptureCamera = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
emitter.emit("camera-controls:capture", { nodeId: node.id });
|
|
||||||
setOpen(false);
|
|
||||||
};
|
|
||||||
const handleViewCamera = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
emitter.emit("camera-controls:view", { nodeId: node.id });
|
|
||||||
setOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClearCamera = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
updateNode(node.id, { camera: undefined });
|
|
||||||
setOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-0.5">
|
|
||||||
<button
|
|
||||||
className="w-6 h-6 flex items-center justify-center rounded-md cursor-pointer hover:bg-black/5 dark:hover:bg-white/10 text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
onClick={toggleVisibility}
|
|
||||||
title={isVisible ? "Hide" : "Show"}
|
|
||||||
>
|
|
||||||
{isVisible ? (
|
|
||||||
<Eye className="w-3 h-3" />
|
|
||||||
) : (
|
|
||||||
<EyeOff className="w-3 h-3 opacity-50" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<button
|
|
||||||
className="relative w-6 h-6 flex items-center justify-center rounded-md cursor-pointer hover:bg-black/5 dark:hover:bg-white/10 text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
title="Camera snapshot"
|
|
||||||
>
|
|
||||||
<Camera className="w-3 h-3" />
|
|
||||||
{hasCamera && (
|
|
||||||
<span className="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-primary" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
side="right"
|
|
||||||
align="start"
|
|
||||||
className="w-auto p-1"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
{hasCamera && (
|
|
||||||
<button
|
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
|
|
||||||
onClick={handleViewCamera}
|
|
||||||
>
|
|
||||||
<Camera className="w-3.5 h-3.5" />
|
|
||||||
View snapshot
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
|
|
||||||
onClick={handleCaptureCamera}
|
|
||||||
>
|
|
||||||
<Camera className="w-3.5 h-3.5" />
|
|
||||||
{hasCamera ? "Update snapshot" : "Take snapshot"}
|
|
||||||
</button>
|
|
||||||
{hasCamera && (
|
|
||||||
<button
|
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-destructive hover:text-destructive-foreground text-left w-full"
|
|
||||||
onClick={handleClearCamera}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
Clear snapshot
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
import { AnyNodeId, useScene } from "@pascal-app/core";
|
|
||||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
|
||||||
import { forwardRef, useEffect, useRef } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
|
||||||
|
|
||||||
export function handleTreeSelection(
|
|
||||||
e: React.MouseEvent,
|
|
||||||
nodeId: string,
|
|
||||||
selectedIds: string[],
|
|
||||||
setSelection: (s: any) => void
|
|
||||||
) {
|
|
||||||
if (e.metaKey || e.ctrlKey) {
|
|
||||||
if (selectedIds.includes(nodeId)) {
|
|
||||||
setSelection({ selectedIds: selectedIds.filter((id) => id !== nodeId) });
|
|
||||||
} else {
|
|
||||||
setSelection({ selectedIds: [...selectedIds, nodeId] });
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.shiftKey && selectedIds.length > 0) {
|
|
||||||
const lastSelectedId = selectedIds[selectedIds.length - 1];
|
|
||||||
if (lastSelectedId) {
|
|
||||||
const nodes = Array.from(document.querySelectorAll('[data-treenode-id]'));
|
|
||||||
const nodeIds = nodes.map(n => n.getAttribute('data-treenode-id') as string);
|
|
||||||
|
|
||||||
const startIndex = nodeIds.indexOf(lastSelectedId);
|
|
||||||
const endIndex = nodeIds.indexOf(nodeId);
|
|
||||||
|
|
||||||
if (startIndex !== -1 && endIndex !== -1) {
|
|
||||||
const start = Math.min(startIndex, endIndex);
|
|
||||||
const end = Math.max(startIndex, endIndex);
|
|
||||||
const range = nodeIds.slice(start, end + 1);
|
|
||||||
|
|
||||||
// We can keep the previous selections that were outside the range if we want,
|
|
||||||
// but standard file system shift-click replaces the selection with the range.
|
|
||||||
setSelection({ selectedIds: range });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback: if range selection fails (e.g. node not visible in tree), just add to selection
|
|
||||||
if (!selectedIds.includes(nodeId)) {
|
|
||||||
setSelection({ selectedIds: [...selectedIds, nodeId] });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelection({ selectedIds: [nodeId] });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
import { BuildingTreeNode } from "./building-tree-node";
|
|
||||||
import { CeilingTreeNode } from "./ceiling-tree-node";
|
|
||||||
import { DoorTreeNode } from "./door-tree-node";
|
|
||||||
import { ItemTreeNode } from "./item-tree-node";
|
|
||||||
import { LevelTreeNode } from "./level-tree-node";
|
|
||||||
import { RoofTreeNode } from "./roof-tree-node";
|
|
||||||
import { SlabTreeNode } from "./slab-tree-node";
|
|
||||||
import { WallTreeNode } from "./wall-tree-node";
|
|
||||||
import { WindowTreeNode } from "./window-tree-node";
|
|
||||||
import { ZoneTreeNode } from "./zone-tree-node";
|
|
||||||
|
|
||||||
interface TreeNodeProps {
|
|
||||||
nodeId: AnyNodeId;
|
|
||||||
depth?: number;
|
|
||||||
isLast?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
|
|
||||||
const node = useScene((state) => state.nodes[nodeId]);
|
|
||||||
|
|
||||||
if (!node) return null;
|
|
||||||
|
|
||||||
switch (node.type) {
|
|
||||||
case "building":
|
|
||||||
return <BuildingTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
|
||||||
case "ceiling":
|
|
||||||
return <CeilingTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
|
||||||
case "level":
|
|
||||||
return <LevelTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
|
||||||
case "slab":
|
|
||||||
return <SlabTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
|
||||||
case "wall":
|
|
||||||
return <WallTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
|
||||||
case "roof":
|
|
||||||
return <RoofTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
|
||||||
case "item":
|
|
||||||
return <ItemTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
|
||||||
case "door":
|
|
||||||
return <DoorTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
|
||||||
case "window":
|
|
||||||
return <WindowTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
|
||||||
case "zone":
|
|
||||||
return <ZoneTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TreeNodeWrapperProps {
|
|
||||||
nodeId?: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
label: React.ReactNode;
|
|
||||||
depth: number;
|
|
||||||
hasChildren: boolean;
|
|
||||||
expanded: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
onClick: (e: React.MouseEvent) => void;
|
|
||||||
onDoubleClick?: () => void;
|
|
||||||
onMouseEnter?: () => void;
|
|
||||||
onMouseLeave?: () => void;
|
|
||||||
actions?: React.ReactNode;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
isSelected?: boolean;
|
|
||||||
isHovered?: boolean;
|
|
||||||
isVisible?: boolean;
|
|
||||||
isLast?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
|
|
||||||
function TreeNodeWrapper(
|
|
||||||
{
|
|
||||||
nodeId,
|
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
depth,
|
|
||||||
hasChildren,
|
|
||||||
expanded,
|
|
||||||
onToggle,
|
|
||||||
onClick,
|
|
||||||
onDoubleClick,
|
|
||||||
onMouseEnter,
|
|
||||||
onMouseLeave,
|
|
||||||
actions,
|
|
||||||
children,
|
|
||||||
isSelected,
|
|
||||||
isHovered,
|
|
||||||
isVisible = true,
|
|
||||||
isLast,
|
|
||||||
},
|
|
||||||
ref
|
|
||||||
) {
|
|
||||||
const rowRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isSelected && rowRef.current) {
|
|
||||||
rowRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
||||||
}
|
|
||||||
}, [isSelected]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref} data-treenode-id={nodeId}>
|
|
||||||
<div
|
|
||||||
ref={rowRef}
|
|
||||||
className={cn(
|
|
||||||
"relative flex items-center h-8 cursor-pointer group/row text-sm select-none border-b border-r border-border/50 border-r-transparent transition-all duration-200",
|
|
||||||
isSelected
|
|
||||||
? "bg-accent/50 text-foreground border-r-white border-r-3"
|
|
||||||
: isHovered
|
|
||||||
? "bg-accent/30 text-foreground"
|
|
||||||
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground",
|
|
||||||
!isVisible && "opacity-50"
|
|
||||||
)}
|
|
||||||
style={{ paddingLeft: depth * 12 + 12, paddingRight: 12 }}
|
|
||||||
onMouseEnter={onMouseEnter}
|
|
||||||
onMouseLeave={onMouseLeave}
|
|
||||||
>
|
|
||||||
{/* Vertical tree line */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute w-px bg-border/50 pointer-events-none",
|
|
||||||
isLast ? "top-0 bottom-1/2" : "top-0 bottom-0"
|
|
||||||
)}
|
|
||||||
style={{ left: (depth - 1) * 12 + 20 }}
|
|
||||||
/>
|
|
||||||
{/* Horizontal branch line */}
|
|
||||||
<div
|
|
||||||
className="absolute top-1/2 h-px bg-border/50 pointer-events-none"
|
|
||||||
style={{ left: (depth - 1) * 12 + 20, width: 4 }}
|
|
||||||
/>
|
|
||||||
{/* Line down to children */}
|
|
||||||
{hasChildren && expanded && (
|
|
||||||
<div
|
|
||||||
className="absolute top-1/2 bottom-0 w-px bg-border/50 pointer-events-none"
|
|
||||||
style={{ left: depth * 12 + 20 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="w-4 h-4 flex items-center justify-center shrink-0 z-10 bg-inherit"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onToggle();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{hasChildren ? (
|
|
||||||
expanded ? (
|
|
||||||
<ChevronDown className="w-3 h-3" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="w-3 h-3" />
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-1.5 flex-1 min-w-0"
|
|
||||||
onClick={onClick}
|
|
||||||
onDoubleClick={onDoubleClick}
|
|
||||||
>
|
|
||||||
<span className={cn(
|
|
||||||
"w-4 h-4 flex items-center justify-center shrink-0 transition-all duration-200",
|
|
||||||
!isSelected && "opacity-60 grayscale"
|
|
||||||
)}>
|
|
||||||
{icon}
|
|
||||||
</span>
|
|
||||||
<div className={cn(
|
|
||||||
"flex-1 min-w-0 truncate",
|
|
||||||
!isVisible && "line-through text-muted-foreground"
|
|
||||||
)}>
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{actions && (
|
|
||||||
<div className={cn(
|
|
||||||
"opacity-0 group-hover/row:opacity-100 pr-1 transition-opacity duration-200",
|
|
||||||
!isVisible && "opacity-100"
|
|
||||||
)}>
|
|
||||||
{actions}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<AnimatePresence initial={false}>
|
|
||||||
{expanded && children && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ height: 0, opacity: 0 }}
|
|
||||||
animate={{ height: "auto", opacity: 1 }}
|
|
||||||
exit={{ height: 0, opacity: 0 }}
|
|
||||||
transition={{ type: "spring", bounce: 0, duration: 0.3 }}
|
|
||||||
className="overflow-hidden"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import { type AnyNodeId, WallNode, useScene } from "@pascal-app/core";
|
|
||||||
import { useViewer } from "@pascal-app/viewer";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import useEditor from "@/store/use-editor";
|
|
||||||
import { InlineRenameInput } from "./inline-rename-input";
|
|
||||||
import { TreeNode, TreeNodeWrapper, handleTreeSelection } from "./tree-node";
|
|
||||||
import { TreeNodeActions } from "./tree-node-actions";
|
|
||||||
|
|
||||||
interface WallTreeNodeProps {
|
|
||||||
node: WallNode;
|
|
||||||
depth: number;
|
|
||||||
isLast?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WallTreeNode({ node, depth, isLast }: WallTreeNodeProps) {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const selectedIds = useViewer((state) => state.selection.selectedIds);
|
|
||||||
const isSelected = selectedIds.includes(node.id);
|
|
||||||
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
|
||||||
const setSelection = useViewer((state) => state.setSelection);
|
|
||||||
const setHoveredId = useViewer((state) => state.setHoveredId);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedIds.length === 0) return;
|
|
||||||
const nodes = useScene.getState().nodes;
|
|
||||||
let isDescendant = false;
|
|
||||||
for (const id of selectedIds) {
|
|
||||||
let current = nodes[id as AnyNodeId];
|
|
||||||
while (current && current.parentId) {
|
|
||||||
if (current.parentId === node.id) {
|
|
||||||
isDescendant = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
current = nodes[current.parentId as AnyNodeId];
|
|
||||||
}
|
|
||||||
if (isDescendant) break;
|
|
||||||
}
|
|
||||||
if (isDescendant) {
|
|
||||||
setExpanded(true);
|
|
||||||
}
|
|
||||||
}, [selectedIds, node.id]);
|
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection);
|
|
||||||
if (!handled && useEditor.getState().phase === "furnish") {
|
|
||||||
useEditor.getState().setPhase("structure");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDoubleClick = () => {
|
|
||||||
setIsEditing(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
|
||||||
setHoveredId(node.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
|
||||||
setHoveredId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultName = "Wall";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TreeNodeWrapper
|
|
||||||
nodeId={node.id}
|
|
||||||
icon={<Image src="/icons/wall.png" alt="" width={14} height={14} className="object-contain" />}
|
|
||||||
label={
|
|
||||||
<InlineRenameInput
|
|
||||||
node={node}
|
|
||||||
isEditing={isEditing}
|
|
||||||
onStopEditing={() => setIsEditing(false)}
|
|
||||||
onStartEditing={() => setIsEditing(true)}
|
|
||||||
defaultName={defaultName}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
depth={depth}
|
|
||||||
hasChildren={node.children.length > 0}
|
|
||||||
expanded={expanded}
|
|
||||||
onToggle={() => setExpanded(!expanded)}
|
|
||||||
onClick={handleClick}
|
|
||||||
onDoubleClick={handleDoubleClick}
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
isSelected={isSelected}
|
|
||||||
isHovered={isHovered}
|
|
||||||
isVisible={node.visible !== false}
|
|
||||||
isLast={isLast}
|
|
||||||
actions={<TreeNodeActions node={node} />}
|
|
||||||
>
|
|
||||||
{node.children.map((childId, index) => (
|
|
||||||
<TreeNode key={childId} nodeId={childId} depth={depth + 1} isLast={index === node.children.length - 1} />
|
|
||||||
))}
|
|
||||||
</TreeNodeWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import { ZoneNode, useScene } from "@pascal-app/core";
|
|
||||||
import { useViewer } from "@pascal-app/viewer";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { ColorDot } from "@/components/ui/primitives/color-dot";
|
|
||||||
import { InlineRenameInput } from "./inline-rename-input";
|
|
||||||
import { TreeNodeWrapper } from "./tree-node";
|
|
||||||
import { TreeNodeActions } from "./tree-node-actions";
|
|
||||||
|
|
||||||
interface ZoneTreeNodeProps {
|
|
||||||
node: ZoneNode;
|
|
||||||
depth: number;
|
|
||||||
isLast?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ZoneTreeNode({ node, depth, isLast }: ZoneTreeNodeProps) {
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const updateNode = useScene((state) => state.updateNode);
|
|
||||||
const isSelected = useViewer((state) => state.selection.zoneId === node.id);
|
|
||||||
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
|
||||||
const setSelection = useViewer((state) => state.setSelection);
|
|
||||||
const setHoveredId = useViewer((state) => state.setHoveredId);
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
setSelection({ zoneId: node.id });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDoubleClick = () => {
|
|
||||||
setIsEditing(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
|
||||||
setHoveredId(node.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
|
||||||
setHoveredId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate approximate area from polygon
|
|
||||||
const area = calculatePolygonArea(node.polygon).toFixed(1);
|
|
||||||
const defaultName = `Zone (${area}m²)`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TreeNodeWrapper
|
|
||||||
icon={
|
|
||||||
<ColorDot
|
|
||||||
color={node.color}
|
|
||||||
onChange={(color) => updateNode(node.id, { color })}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={
|
|
||||||
<InlineRenameInput
|
|
||||||
node={node}
|
|
||||||
isEditing={isEditing}
|
|
||||||
onStopEditing={() => setIsEditing(false)}
|
|
||||||
onStartEditing={() => setIsEditing(true)}
|
|
||||||
defaultName={defaultName}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
depth={depth}
|
|
||||||
hasChildren={false}
|
|
||||||
expanded={false}
|
|
||||||
onToggle={() => {}}
|
|
||||||
onClick={handleClick}
|
|
||||||
onDoubleClick={handleDoubleClick}
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
isSelected={isSelected}
|
|
||||||
isHovered={isHovered}
|
|
||||||
isLast={isLast}
|
|
||||||
actions={<TreeNodeActions node={node} />}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate the area of a polygon using the shoelace formula
|
|
||||||
*/
|
|
||||||
function calculatePolygonArea(polygon: Array<[number, number]>): number {
|
|
||||||
if (polygon.length < 3) return 0;
|
|
||||||
|
|
||||||
let area = 0;
|
|
||||||
const n = polygon.length;
|
|
||||||
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const j = (i + 1) % n;
|
|
||||||
area += polygon[i]![0] * polygon[j]![1];
|
|
||||||
area -= polygon[j]![0] * polygon[i]![1];
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.abs(area) / 2;
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
import { emitter, useScene, type ZoneNode } from "@pascal-app/core";
|
|
||||||
import { useViewer } from "@pascal-app/viewer";
|
|
||||||
import { Camera, Hexagon, Trash2 } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import useEditor from "@/store/use-editor";
|
|
||||||
import { ColorDot } from "@/components/ui/primitives/color-dot";
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/primitives/popover";
|
|
||||||
|
|
||||||
function ZoneItem({ zone }: { zone: ZoneNode }) {
|
|
||||||
const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false);
|
|
||||||
const deleteNode = useScene((state) => state.deleteNode);
|
|
||||||
const updateNode = useScene((state) => state.updateNode);
|
|
||||||
const selectedZoneId = useViewer((state) => state.selection.zoneId);
|
|
||||||
const setSelection = useViewer((state) => state.setSelection);
|
|
||||||
|
|
||||||
const isSelected = selectedZoneId === zone.id;
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
setSelection({ zoneId: zone.id });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
deleteNode(zone.id);
|
|
||||||
if (isSelected) {
|
|
||||||
setSelection({ zoneId: null });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleColorChange = (color: string) => {
|
|
||||||
updateNode(zone.id, { color });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center h-8 cursor-pointer group/row text-sm px-2 mx-1 mb-0.5 select-none rounded-lg border transition-all duration-200",
|
|
||||||
isSelected
|
|
||||||
? "bg-white dark:bg-accent/50 border-neutral-200/60 dark:border-border/50 shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] ring-1 ring-white/50 dark:ring-white/10 ring-inset text-foreground"
|
|
||||||
: "border-transparent text-muted-foreground hover:bg-white/40 dark:hover:bg-accent/30 hover:border-neutral-200/50 dark:hover:border-border/40 hover:text-foreground"
|
|
||||||
)}
|
|
||||||
onClick={handleClick}
|
|
||||||
>
|
|
||||||
<span className="mr-2">
|
|
||||||
<ColorDot color={zone.color} onChange={handleColorChange} />
|
|
||||||
</span>
|
|
||||||
<Hexagon className="w-3.5 h-3.5 mr-1.5 shrink-0" />
|
|
||||||
<span className="truncate flex-1">{zone.name}</span>
|
|
||||||
{/* Camera snapshot button */}
|
|
||||||
<Popover open={cameraPopoverOpen} onOpenChange={setCameraPopoverOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<button
|
|
||||||
className="relative opacity-0 group-hover/row:opacity-100 w-6 h-6 flex items-center justify-center rounded-md cursor-pointer hover:bg-black/5 dark:hover:bg-white/10 text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
title="Camera snapshot"
|
|
||||||
>
|
|
||||||
<Camera className="w-3 h-3" />
|
|
||||||
{zone.camera && (
|
|
||||||
<span className="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-primary" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
side="right"
|
|
||||||
align="start"
|
|
||||||
className="w-auto p-1"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
{zone.camera && (
|
|
||||||
<button
|
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
emitter.emit("camera-controls:view", { nodeId: zone.id });
|
|
||||||
setCameraPopoverOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Camera className="w-3.5 h-3.5" />
|
|
||||||
View snapshot
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
emitter.emit("camera-controls:capture", { nodeId: zone.id });
|
|
||||||
setCameraPopoverOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Camera className="w-3.5 h-3.5" />
|
|
||||||
{zone.camera ? "Update snapshot" : "Take snapshot"}
|
|
||||||
</button>
|
|
||||||
{zone.camera && (
|
|
||||||
<button
|
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-destructive hover:text-destructive-foreground text-left w-full"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
updateNode(zone.id, { camera: undefined });
|
|
||||||
setCameraPopoverOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
Clear snapshot
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
<button
|
|
||||||
className="opacity-0 group-hover/row:opacity-100 w-6 h-6 flex items-center justify-center rounded-md cursor-pointer hover:bg-black/5 dark:hover:bg-white/10 text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
onClick={handleDelete}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ZonePanel() {
|
|
||||||
const nodes = useScene((state) => state.nodes);
|
|
||||||
const currentLevelId = useViewer((state) => state.selection.levelId);
|
|
||||||
const setPhase = useEditor((state) => state.setPhase);
|
|
||||||
const setMode = useEditor((state) => state.setMode);
|
|
||||||
const setTool = useEditor((state) => state.setTool);
|
|
||||||
|
|
||||||
// Filter nodes to get zones for the current level
|
|
||||||
const levelZones = Object.values(nodes).filter(
|
|
||||||
(node): node is ZoneNode =>
|
|
||||||
node.type === "zone" && node.parentId === currentLevelId
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAddZone = () => {
|
|
||||||
if (currentLevelId) {
|
|
||||||
setPhase("structure");
|
|
||||||
setMode("build");
|
|
||||||
setTool("zone");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!currentLevelId) {
|
|
||||||
return (
|
|
||||||
<div className="px-3 py-4 text-sm text-muted-foreground">
|
|
||||||
Select a level to view and create zones
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="py-1">
|
|
||||||
{levelZones.length === 0 ? (
|
|
||||||
<div className="px-3 py-4 text-sm text-muted-foreground">
|
|
||||||
No zones on this level.{" "}
|
|
||||||
<button
|
|
||||||
className="text-primary hover:underline cursor-pointer"
|
|
||||||
onClick={handleAddZone}
|
|
||||||
>
|
|
||||||
Add one
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
levelZones.map((zone) => <ZoneItem key={zone.id} zone={zone} />)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
# Community Feature
|
|
||||||
|
|
||||||
This directory contains the **optional** community features (cloud synchronization and authentication) for the Pascal Editor. This feature is specific to the Pascal platform and can be safely removed if you're using the editor standalone.
|
|
||||||
|
|
||||||
## What This Does
|
|
||||||
|
|
||||||
The community feature provides:
|
|
||||||
|
|
||||||
- **Authentication** - Sign in with magic link via Better Auth
|
|
||||||
- **Property Management** - Create and manage properties with Google Maps address search
|
|
||||||
- **Scene Loading** - Automatically load property scenes from the database when a property is selected
|
|
||||||
- **Auto-Save** - Automatically save scene changes to the database (2-second debounce)
|
|
||||||
- **Database Sync** - Save and load editor state from a PostgreSQL database via Supabase
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
features/community/
|
|
||||||
├── lib/
|
|
||||||
│ ├── auth/
|
|
||||||
│ │ ├── client.ts # Re-exports from @pascal-app/auth
|
|
||||||
│ │ ├── server.ts # Server-side session handling
|
|
||||||
│ │ └── hooks.ts # useAuth React hook
|
|
||||||
│ ├── properties/
|
|
||||||
│ │ ├── actions.ts # Server actions for CRUD operations
|
|
||||||
│ │ ├── types.ts # TypeScript types for properties
|
|
||||||
│ │ ├── hooks.ts # Property React hooks
|
|
||||||
│ │ └── store.ts # Zustand store for property state
|
|
||||||
│ ├── models/
|
|
||||||
│ │ ├── actions.ts # Scene model CRUD operations
|
|
||||||
│ │ └── hooks.ts # Scene loading and auto-save hooks
|
|
||||||
│ ├── database/
|
|
||||||
│ │ └── server.ts # Re-exports from @pascal-app/db
|
|
||||||
│ └── utils/
|
|
||||||
│ └── id-generator.ts # nanoid-based ID generation
|
|
||||||
├── components/
|
|
||||||
│ ├── cloud-save-button.tsx # Main UI entry point (top-right button)
|
|
||||||
│ ├── sign-in-dialog.tsx # Magic link sign-in dialog
|
|
||||||
│ ├── profile-dropdown.tsx # User profile menu
|
|
||||||
│ ├── property-dropdown.tsx # Property selector dropdown
|
|
||||||
│ ├── new-property-dialog.tsx # Create new property dialog
|
|
||||||
│ └── google-address-search.tsx # Google Maps autocomplete
|
|
||||||
└── README.md # This file
|
|
||||||
```
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
### Authentication Flow
|
|
||||||
1. User clicks "Save to cloud" button
|
|
||||||
2. Signs in with magic link (email-based, no password)
|
|
||||||
3. Better Auth session is stored in cookies
|
|
||||||
4. Server actions validate session using Better Auth API
|
|
||||||
|
|
||||||
### Property Management
|
|
||||||
1. User creates a property with a real-world address (Google Maps)
|
|
||||||
2. Address and property are saved to PostgreSQL via Supabase
|
|
||||||
3. Properties are associated with the authenticated user
|
|
||||||
4. User can switch between properties
|
|
||||||
|
|
||||||
### Scene Management
|
|
||||||
1. When a property is selected, its scene is loaded from `properties_models` table
|
|
||||||
2. If no scene exists, loads default empty scene
|
|
||||||
3. Scene changes are auto-saved every 2 seconds (debounced)
|
|
||||||
4. Updates existing model (highest version) instead of creating new ones
|
|
||||||
5. Scene graph includes all nodes and hierarchy
|
|
||||||
|
|
||||||
### Database Integration
|
|
||||||
- Uses Supabase (PostgreSQL) for database access
|
|
||||||
- Better Auth manages authentication tables directly
|
|
||||||
- Server actions use service role key to bypass RLS
|
|
||||||
- Permissions enforced by filtering on `owner_id`
|
|
||||||
- Tables: `users`, `sessions`, `properties`, `properties_addresses`, `properties_models`
|
|
||||||
|
|
||||||
## Required Environment Variables
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Database Connection
|
|
||||||
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
|
|
||||||
|
|
||||||
# Supabase Configuration
|
|
||||||
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
|
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key_here
|
|
||||||
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key_here
|
|
||||||
|
|
||||||
# Better Auth
|
|
||||||
BETTER_AUTH_SECRET=<generate_with_openssl_rand_base64_32>
|
|
||||||
BETTER_AUTH_URL=http://localhost:3000
|
|
||||||
|
|
||||||
# Google Maps API Key (for address search)
|
|
||||||
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your_google_maps_key_here
|
|
||||||
```
|
|
||||||
|
|
||||||
Generate `BETTER_AUTH_SECRET`:
|
|
||||||
```bash
|
|
||||||
openssl rand -base64 32
|
|
||||||
```
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
The community feature requires these packages:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"better-auth": "^1.4.18",
|
|
||||||
"@supabase/supabase-js": "^2.95.3",
|
|
||||||
"@react-google-maps/api": "^2.20.8",
|
|
||||||
"nanoid": "^5.1.6"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## How to Remove (For Open Source Users)
|
|
||||||
|
|
||||||
If you want to use the editor without community features:
|
|
||||||
|
|
||||||
### 1. Delete this directory
|
|
||||||
```bash
|
|
||||||
rm -rf features/community
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Remove the CloudSaveButton from the editor
|
|
||||||
Edit `components/editor/index.tsx`:
|
|
||||||
```diff
|
|
||||||
- import { CloudSaveButton } from '@/features/community/components/cloud-save-button'
|
|
||||||
|
|
||||||
export default function Editor() {
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full">
|
|
||||||
<ActionMenu />
|
|
||||||
<PanelManager />
|
|
||||||
- <CloudSaveButton />
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Remove dependencies (optional)
|
|
||||||
Edit `package.json`:
|
|
||||||
```diff
|
|
||||||
- "better-auth": "^1.4.18",
|
|
||||||
- "@supabase/supabase-js": "^2.95.3",
|
|
||||||
- "@react-google-maps/api": "^2.20.8",
|
|
||||||
- "nanoid": "^5.1.6"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Remove environment variables
|
|
||||||
Delete from `.env.local` and `.env.example`:
|
|
||||||
```diff
|
|
||||||
- NEXT_PUBLIC_API_URL=...
|
|
||||||
- NEXT_PUBLIC_SUPABASE_URL=...
|
|
||||||
- NEXT_PUBLIC_SUPABASE_ANON_KEY=...
|
|
||||||
- SUPABASE_SERVICE_ROLE_KEY=...
|
|
||||||
- NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=...
|
|
||||||
```
|
|
||||||
|
|
||||||
That's it! The editor will work as a standalone application without any cloud features.
|
|
||||||
|
|
||||||
## Backend Requirements
|
|
||||||
|
|
||||||
This feature requires:
|
|
||||||
- Supabase local development instance
|
|
||||||
- PostgreSQL database with the following tables:
|
|
||||||
- `users` - User accounts (Better Auth)
|
|
||||||
- `sessions` - Authentication sessions (Better Auth)
|
|
||||||
- `verification_tokens` - Magic link tokens (Better Auth)
|
|
||||||
- `properties` - Property records
|
|
||||||
- `properties_addresses` - Property addresses
|
|
||||||
- `properties_models` - Scene graph models
|
|
||||||
- Database migrations are managed in `supabase/migrations/`
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
To work on this feature:
|
|
||||||
|
|
||||||
1. Install dependencies:
|
|
||||||
```bash
|
|
||||||
bun install
|
|
||||||
```
|
|
||||||
2. Start Supabase local development:
|
|
||||||
```bash
|
|
||||||
bun db:start
|
|
||||||
```
|
|
||||||
3. Run database migrations:
|
|
||||||
```bash
|
|
||||||
bun db:reset
|
|
||||||
```
|
|
||||||
4. Configure all environment variables in `apps/editor/.env.local`
|
|
||||||
5. Run the editor: `bun dev`
|
|
||||||
|
|
||||||
The editor will be available at `http://localhost:3000`.
|
|
||||||
|
|
||||||
For detailed setup instructions, see [SETUP.md](../../../SETUP.md) in the root directory.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- This feature uses **server actions** (Next.js App Router) for all database operations
|
|
||||||
- Authentication is handled by **Better Auth** with magic link support
|
|
||||||
- Better Auth server is configured in `packages/auth` and mounted at `/api/auth/*`
|
|
||||||
- The editor queries the database directly using Supabase with service role key
|
|
||||||
- IDs are generated using nanoid with custom alphabet
|
|
||||||
- Scene state is managed with a **Zustand store** for reliable property switching
|
|
||||||
- Scene changes are auto-saved with 2-second debouncing to the currently selected property
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Home } from 'lucide-react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
import { useAuth } from '../lib/auth/hooks'
|
|
||||||
import { useProjectStore } from '../lib/projects/store'
|
|
||||||
import { ProfileDropdown } from './profile-dropdown'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CloudSaveButton - Shows authentication state and project management
|
|
||||||
*
|
|
||||||
* Guest: Shows "Home" button
|
|
||||||
* Authenticated: Shows ProfileDropdown
|
|
||||||
*/
|
|
||||||
export function CloudSaveButton() {
|
|
||||||
const { isAuthenticated, isLoading } = useAuth()
|
|
||||||
const initialize = useProjectStore(state => state.initialize)
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
// Initialize project store when authenticated
|
|
||||||
useEffect(() => {
|
|
||||||
if (isAuthenticated) {
|
|
||||||
initialize()
|
|
||||||
}
|
|
||||||
}, [isAuthenticated, initialize])
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="pointer-events-auto">
|
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 shadow-lg backdrop-blur-md">
|
|
||||||
<div className="h-4 w-4 animate-pulse rounded-full bg-muted" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
return (
|
|
||||||
<div className="pointer-events-auto">
|
|
||||||
<button
|
|
||||||
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 text-sm font-medium shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground"
|
|
||||||
onClick={() => router.push('/')}
|
|
||||||
>
|
|
||||||
<Home className="h-4 w-4" />
|
|
||||||
Home
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pointer-events-auto">
|
|
||||||
<ProfileDropdown />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import Image from 'next/image'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useAuth } from '../lib/auth/hooks'
|
|
||||||
import { getPublicProjects, getUserProjects } from '../lib/projects/actions'
|
|
||||||
import type { Project } from '../lib/projects/types'
|
|
||||||
import { CreateProjectButton } from './create-project-button'
|
|
||||||
import { HubFooter } from './hub-footer'
|
|
||||||
import { NewProjectDialog } from './new-project-dialog'
|
|
||||||
import { ProfileDropdown } from './profile-dropdown'
|
|
||||||
import { ProjectGrid } from './project-grid'
|
|
||||||
import { SignInDialog } from './sign-in-dialog'
|
|
||||||
|
|
||||||
export default function CommunityHub() {
|
|
||||||
const { isAuthenticated, isLoading: authLoading } = useAuth()
|
|
||||||
const router = useRouter()
|
|
||||||
const [isSignInDialogOpen, setIsSignInDialogOpen] = useState(false)
|
|
||||||
const [isNewProjectDialogOpen, setIsNewProjectDialogOpen] = useState(false)
|
|
||||||
const [publicProjects, setPublicProjects] = useState<Project[]>([])
|
|
||||||
const [userProjects, setUserProjects] = useState<Project[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function loadProjects() {
|
|
||||||
setLoading(true)
|
|
||||||
|
|
||||||
const publicResult = await getPublicProjects()
|
|
||||||
if (publicResult.success) {
|
|
||||||
setPublicProjects(publicResult.data || [])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAuthenticated) {
|
|
||||||
const userResult = await getUserProjects()
|
|
||||||
if (userResult.success) {
|
|
||||||
setUserProjects(userResult.data || [])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!authLoading) {
|
|
||||||
loadProjects()
|
|
||||||
}
|
|
||||||
}, [isAuthenticated, authLoading])
|
|
||||||
|
|
||||||
const handleProjectCreated = (projectId: string) => {
|
|
||||||
router.push(`/editor/${projectId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleProjectClick = (projectId: string) => {
|
|
||||||
router.push(`/editor/${projectId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleViewProject = (projectId: string) => {
|
|
||||||
router.push(`/viewer/${projectId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authLoading || loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-screen w-full items-center justify-center">
|
|
||||||
<p className="text-muted-foreground">Loading...</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-background">
|
|
||||||
{/* Header */}
|
|
||||||
<header className="border-b border-border bg-background/95 backdrop-blur sticky top-0 z-10">
|
|
||||||
<div className="container mx-auto px-6 py-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Image
|
|
||||||
src="/pascal-logo-shape.svg"
|
|
||||||
alt="Pascal"
|
|
||||||
width={64}
|
|
||||||
height={64}
|
|
||||||
className="h-5 w-5"
|
|
||||||
/>
|
|
||||||
<h1 className="text-2xl font-bold">Pascal Editor</h1>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<a
|
|
||||||
href="https://github.com/pascalorg/editor"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-muted-foreground transition-colors hover:border-foreground/20 hover:text-foreground"
|
|
||||||
>
|
|
||||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
|
||||||
</svg>
|
|
||||||
<span className="hidden sm:inline text-sm font-medium">Open Source</span>
|
|
||||||
</a>
|
|
||||||
{!isAuthenticated ? (
|
|
||||||
<button
|
|
||||||
onClick={() => setIsSignInDialogOpen(true)}
|
|
||||||
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
Sign In
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<ProfileDropdown />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="container mx-auto px-6 py-8 space-y-12">
|
|
||||||
{/* User's Projects Section */}
|
|
||||||
{isAuthenticated && (
|
|
||||||
<section>
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<h2 className="text-xl font-semibold">My Projects</h2>
|
|
||||||
<CreateProjectButton onCreateProject={() => setIsNewProjectDialogOpen(true)} />
|
|
||||||
</div>
|
|
||||||
{userProjects.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-16 text-center">
|
|
||||||
<p className="text-muted-foreground">You don't have any projects yet.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ProjectGrid
|
|
||||||
projects={userProjects}
|
|
||||||
onProjectClick={handleProjectClick}
|
|
||||||
onViewClick={handleViewProject}
|
|
||||||
showOwner={false}
|
|
||||||
canEdit
|
|
||||||
onUpdate={() => {
|
|
||||||
if (!authLoading) {
|
|
||||||
getUserProjects().then((result) => {
|
|
||||||
if (result.success) {
|
|
||||||
setUserProjects(result.data || [])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Sign-in CTA for unauthenticated users */}
|
|
||||||
{!isAuthenticated && (
|
|
||||||
<section className="rounded-2xl border border-border bg-neutral-50 dark:bg-neutral-900/50 px-8 py-12 text-center">
|
|
||||||
<h2 className="text-2xl font-semibold mb-2">Build with Pascal</h2>
|
|
||||||
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
|
|
||||||
Create and share 3D architectural projects. Sign in to get started.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsSignInDialogOpen(true)}
|
|
||||||
className="rounded-lg bg-primary px-6 py-2.5 text-primary-foreground font-medium hover:bg-primary/90 transition-colors"
|
|
||||||
>
|
|
||||||
Sign in to create a project
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Public Projects Section */}
|
|
||||||
<section>
|
|
||||||
<h2 className="text-xl font-semibold mb-6">Community Projects</h2>
|
|
||||||
{publicProjects.length > 0 ? (
|
|
||||||
<ProjectGrid
|
|
||||||
projects={publicProjects}
|
|
||||||
onProjectClick={handleViewProject}
|
|
||||||
showOwner
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-12 text-muted-foreground">No public projects yet</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<HubFooter />
|
|
||||||
|
|
||||||
<SignInDialog open={isSignInDialogOpen} onOpenChange={setIsSignInDialogOpen} />
|
|
||||||
<NewProjectDialog
|
|
||||||
open={isNewProjectDialogOpen}
|
|
||||||
onOpenChange={setIsNewProjectDialogOpen}
|
|
||||||
onSuccess={handleProjectCreated}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Plus } from 'lucide-react'
|
|
||||||
|
|
||||||
interface CreateProjectButtonProps {
|
|
||||||
onCreateProject: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreateProjectButton({ onCreateProject }: CreateProjectButtonProps) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onCreateProject}
|
|
||||||
className="flex items-center gap-2 rounded-full bg-primary px-5 py-2 text-primary-foreground hover:bg-primary/90 transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
<span>Create Project</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Autocomplete, LoadScript } from '@react-google-maps/api'
|
|
||||||
import { MapPin } from 'lucide-react'
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
|
|
||||||
const libraries: ('places')[] = ['places']
|
|
||||||
|
|
||||||
interface AddressComponents {
|
|
||||||
streetNumber?: string
|
|
||||||
route?: string
|
|
||||||
city?: string
|
|
||||||
state?: string
|
|
||||||
postalCode?: string
|
|
||||||
country?: string
|
|
||||||
center: [number, number]
|
|
||||||
formattedAddress: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GoogleAddressSearchProps {
|
|
||||||
onAddressSelect: (address: AddressComponents) => void
|
|
||||||
disabled?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GoogleAddressSearch({ onAddressSelect, disabled }: GoogleAddressSearchProps) {
|
|
||||||
const [autocomplete, setAutocomplete] = useState<google.maps.places.Autocomplete | null>(null)
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
|
||||||
|
|
||||||
const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
|
|
||||||
|
|
||||||
// Fix Google Maps autocomplete dropdown z-index and pointer events to work with dialog
|
|
||||||
useEffect(() => {
|
|
||||||
const style = document.createElement('style')
|
|
||||||
style.textContent = `
|
|
||||||
.pac-container {
|
|
||||||
z-index: 9999 !important;
|
|
||||||
pointer-events: auto !important;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
document.head.appendChild(style)
|
|
||||||
return () => {
|
|
||||||
document.head.removeChild(style)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (!apiKey) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
|
|
||||||
<p className="font-medium">Google Maps API Key Missing</p>
|
|
||||||
<p className="mt-1 text-xs">
|
|
||||||
Add NEXT_PUBLIC_GOOGLE_MAPS_API_KEY to your .env.local file
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onLoad = (autocompleteInstance: google.maps.places.Autocomplete) => {
|
|
||||||
setAutocomplete(autocompleteInstance)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onPlaceChanged = () => {
|
|
||||||
if (!autocomplete) return
|
|
||||||
|
|
||||||
const place = autocomplete.getPlace()
|
|
||||||
if (!place.geometry?.location || !place.address_components) return
|
|
||||||
|
|
||||||
const components: AddressComponents = {
|
|
||||||
center: [place.geometry.location.lng(), place.geometry.location.lat()],
|
|
||||||
formattedAddress: place.formatted_address || '',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse address components
|
|
||||||
for (const component of place.address_components) {
|
|
||||||
const types = component.types
|
|
||||||
|
|
||||||
if (types.includes('street_number')) {
|
|
||||||
components.streetNumber = component.long_name
|
|
||||||
} else if (types.includes('route')) {
|
|
||||||
components.route = component.long_name
|
|
||||||
} else if (types.includes('locality')) {
|
|
||||||
components.city = component.long_name
|
|
||||||
} else if (types.includes('administrative_area_level_1')) {
|
|
||||||
components.state = component.short_name
|
|
||||||
} else if (types.includes('postal_code')) {
|
|
||||||
components.postalCode = component.long_name
|
|
||||||
} else if (types.includes('country')) {
|
|
||||||
components.country = component.short_name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onAddressSelect(components)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LoadScript googleMapsApiKey={apiKey} libraries={libraries}>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="flex items-center gap-2 font-medium text-sm">
|
|
||||||
<MapPin className="h-4 w-4" />
|
|
||||||
Project Address
|
|
||||||
</label>
|
|
||||||
<Autocomplete onLoad={onLoad} onPlaceChanged={onPlaceChanged}>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
disabled={disabled}
|
|
||||||
placeholder="Search for an address..."
|
|
||||||
type="text"
|
|
||||||
/>
|
|
||||||
</Autocomplete>
|
|
||||||
</div>
|
|
||||||
</LoadScript>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import Link from 'next/link'
|
|
||||||
|
|
||||||
function GitHubIcon({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NpmIcon({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg className={className} viewBox="0 0 256 256">
|
|
||||||
<rect width="256" height="256" rx="0" fill="#C12127" />
|
|
||||||
<polygon points="48,48 208,48 208,208 176,208 176,80 128,80 128,208 48,208" fill="#FFFFFF" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HubFooter() {
|
|
||||||
return (
|
|
||||||
<footer className="border-t border-border mt-16">
|
|
||||||
<div className="container mx-auto px-6 py-8">
|
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Editor by{' '}
|
|
||||||
<a
|
|
||||||
href="https://pascal.app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-foreground hover:underline"
|
|
||||||
>
|
|
||||||
Pascal
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<a
|
|
||||||
href="https://github.com/pascalorg/editor"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors text-sm"
|
|
||||||
>
|
|
||||||
<GitHubIcon className="h-4 w-4" />
|
|
||||||
GitHub
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://www.npmjs.com/package/@pascal-app/viewer"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors text-sm"
|
|
||||||
>
|
|
||||||
<NpmIcon className="h-4 w-4" />
|
|
||||||
Viewer
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://www.npmjs.com/package/@pascal-app/core"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors text-sm"
|
|
||||||
>
|
|
||||||
<NpmIcon className="h-4 w-4" />
|
|
||||||
Core
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { X } from 'lucide-react'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/primitives/dialog'
|
|
||||||
import { Switch } from '@/components/ui/primitives/switch'
|
|
||||||
import { useScene } from '@pascal-app/core'
|
|
||||||
import { createProject } from '../lib/projects/actions'
|
|
||||||
|
|
||||||
interface NewProjectDialogProps {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
onSuccess?: (projectId: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* NewProjectDialog - Dialog for creating a new project
|
|
||||||
*/
|
|
||||||
export function NewProjectDialog({ open, onOpenChange, onSuccess }: NewProjectDialogProps) {
|
|
||||||
const [projectName, setProjectName] = useState('')
|
|
||||||
const [isPrivate, setIsPrivate] = useState(false)
|
|
||||||
const [isCreating, setIsCreating] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
const name = projectName.trim() || 'Untitled Project'
|
|
||||||
|
|
||||||
setIsCreating(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get the default scene graph
|
|
||||||
useScene.getState().clearScene()
|
|
||||||
const { nodes, rootNodeIds } = useScene.getState()
|
|
||||||
const sceneGraph = { nodes, rootNodeIds }
|
|
||||||
|
|
||||||
const result = await createProject({ name, isPrivate, sceneGraph })
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
onOpenChange(false)
|
|
||||||
setProjectName('')
|
|
||||||
setIsPrivate(false)
|
|
||||||
onSuccess?.(result.data.id)
|
|
||||||
} else {
|
|
||||||
setError(result.error || 'Failed to create project')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'An unexpected error occurred')
|
|
||||||
} finally {
|
|
||||||
setIsCreating(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
if (!isCreating) {
|
|
||||||
onOpenChange(false)
|
|
||||||
setProjectName('')
|
|
||||||
setIsPrivate(false)
|
|
||||||
setError(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={handleClose} modal={false}>
|
|
||||||
<DialogContent
|
|
||||||
className="sm:max-w-125"
|
|
||||||
onInteractOutside={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Create New Project</DialogTitle>
|
|
||||||
<button
|
|
||||||
className="absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 disabled:pointer-events-none"
|
|
||||||
disabled={isCreating}
|
|
||||||
onClick={handleClose}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</button>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
|
||||||
{/* Project Name */}
|
|
||||||
<div>
|
|
||||||
<label htmlFor="project-name" className="text-sm font-medium">
|
|
||||||
Project Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="project-name"
|
|
||||||
type="text"
|
|
||||||
value={projectName}
|
|
||||||
onChange={(e) => setProjectName(e.target.value)}
|
|
||||||
placeholder="My Project"
|
|
||||||
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
disabled={isCreating}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Privacy Toggle */}
|
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-3">
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-sm">Privacy</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{isPrivate ? 'Only you can view this project' : 'Anyone can view this project'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm text-muted-foreground">Public</span>
|
|
||||||
<Switch checked={!isPrivate} onCheckedChange={(checked) => setIsPrivate(!checked)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
className="rounded-md border border-input px-4 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
|
|
||||||
disabled={isCreating}
|
|
||||||
type="button"
|
|
||||||
onClick={handleClose}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm transition-colors hover:bg-primary/90 disabled:opacity-50"
|
|
||||||
disabled={isCreating}
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
{isCreating ? 'Creating...' : 'Create Project'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import Image from 'next/image'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { useAuth } from '../lib/auth/hooks'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/primitives/dropdown-menu'
|
|
||||||
|
|
||||||
function getInitials(name: string): string {
|
|
||||||
return name
|
|
||||||
.split(' ')
|
|
||||||
.map((n) => n[0])
|
|
||||||
.join('')
|
|
||||||
.toUpperCase()
|
|
||||||
.slice(0, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ProfileDropdown - User profile menu with avatar, settings, and sign out
|
|
||||||
*/
|
|
||||||
export function ProfileDropdown() {
|
|
||||||
const { user, signOut } = useAuth()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
|
||||||
await signOut()
|
|
||||||
}
|
|
||||||
|
|
||||||
const initials = user?.name ? getInitials(user.name) : user?.email?.[0]?.toUpperCase() || 'U'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
className="flex h-9 w-9 items-center justify-center overflow-hidden rounded-full bg-muted font-medium text-xs shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_3px_rgba(0,0,0,0.1)] transition-opacity hover:opacity-80 focus:outline-none"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{user?.image ? (
|
|
||||||
<Image
|
|
||||||
src={user.image}
|
|
||||||
alt={user.name || 'Profile'}
|
|
||||||
width={36}
|
|
||||||
height={36}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
initials
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
|
||||||
<div className="flex items-center gap-3 px-2 py-2">
|
|
||||||
{user?.image ? (
|
|
||||||
<Image
|
|
||||||
src={user.image}
|
|
||||||
alt={user.name || 'Profile'}
|
|
||||||
width={32}
|
|
||||||
height={32}
|
|
||||||
className="h-8 w-8 rounded-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted font-medium text-xs">
|
|
||||||
{initials}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
{user?.name && <div className="truncate font-medium text-sm">{user.name}</div>}
|
|
||||||
{user?.email && (
|
|
||||||
<div className="truncate text-muted-foreground text-xs">{user.email}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem className="cursor-pointer" onClick={() => router.push('/settings')}>
|
|
||||||
Settings
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem className="cursor-pointer" variant="destructive" onClick={handleSignOut}>
|
|
||||||
Sign out
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Check, ChevronDown, Home, Plus } from 'lucide-react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/primitives/dropdown-menu'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { useProjectStore } from '../lib/projects/store'
|
|
||||||
import { NewProjectDialog } from './new-project-dialog'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ProjectDropdown - Shows active project and allows switching between projects
|
|
||||||
* Note: useProjectScene() is called in the Editor component, not here.
|
|
||||||
* Having it in both places caused duplicate subscriptions and 2x server action calls.
|
|
||||||
*/
|
|
||||||
export function ProjectDropdown() {
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
// Use project store
|
|
||||||
const projects = useProjectStore((state) => state.projects)
|
|
||||||
const activeProject = useProjectStore((state) => state.activeProject)
|
|
||||||
const isLoading = useProjectStore((state) => state.isLoading)
|
|
||||||
const setActiveProject = useProjectStore((state) => state.setActiveProject)
|
|
||||||
const fetchProjects = useProjectStore((state) => state.fetchProjects)
|
|
||||||
|
|
||||||
const [isNewProjectDialogOpen, setIsNewProjectDialogOpen] = useState(false)
|
|
||||||
|
|
||||||
const handleProjectSelect = async (projectId: string) => {
|
|
||||||
await setActiveProject(projectId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddNew = () => {
|
|
||||||
setIsNewProjectDialogOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleProjectCreated = (projectId: string) => {
|
|
||||||
router.push(`/editor/${projectId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
className="flex h-9 items-center gap-2 rounded-lg border border-border bg-background/95 px-3 text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50 focus:outline-none"
|
|
||||||
disabled={isLoading}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Home className="h-4 w-4" />
|
|
||||||
<span className="max-w-[150px] truncate">
|
|
||||||
{activeProject
|
|
||||||
? activeProject.name
|
|
||||||
: projects.length > 0
|
|
||||||
? 'Select Project'
|
|
||||||
: 'Add Project'}
|
|
||||||
</span>
|
|
||||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-[280px]">
|
|
||||||
{/* Project list */}
|
|
||||||
{projects.length > 0 ? (
|
|
||||||
<div className="max-h-[300px] overflow-y-auto">
|
|
||||||
{projects.map((project) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
className={cn(
|
|
||||||
'cursor-pointer text-sm',
|
|
||||||
activeProject?.id === project.id && 'cursor-default bg-accent',
|
|
||||||
)}
|
|
||||||
key={project.id}
|
|
||||||
onClick={() =>
|
|
||||||
activeProject?.id === project.id ? null : handleProjectSelect(project.id)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="flex w-full items-center justify-between gap-2">
|
|
||||||
<div className="flex-1 truncate font-medium">{project.name}</div>
|
|
||||||
{activeProject?.id === project.id && (
|
|
||||||
<Check className="h-4 w-4 shrink-0 text-primary" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="px-2 py-3 text-center text-muted-foreground text-sm">
|
|
||||||
No projects yet
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Add new project option */}
|
|
||||||
<DropdownMenuItem className="cursor-pointer" onClick={handleAddNew}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
<span>Add new project</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<NewProjectDialog
|
|
||||||
open={isNewProjectDialogOpen}
|
|
||||||
onOpenChange={setIsNewProjectDialogOpen}
|
|
||||||
onSuccess={handleProjectCreated}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Eye, Heart, Settings } from 'lucide-react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useAuth } from '../lib/auth/hooks'
|
|
||||||
import { getUserProjectLikes, toggleProjectLike } from '../lib/projects/actions'
|
|
||||||
import type { Project } from '../lib/projects/types'
|
|
||||||
import { ProjectSettingsDialog } from './project-settings-dialog'
|
|
||||||
|
|
||||||
interface ProjectGridProps {
|
|
||||||
projects: Project[]
|
|
||||||
onProjectClick: (id: string) => void
|
|
||||||
onViewClick?: (id: string) => void
|
|
||||||
showOwner: boolean
|
|
||||||
canEdit?: boolean
|
|
||||||
onUpdate?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProjectGrid({
|
|
||||||
projects,
|
|
||||||
onProjectClick,
|
|
||||||
onViewClick,
|
|
||||||
showOwner,
|
|
||||||
canEdit = false,
|
|
||||||
onUpdate,
|
|
||||||
}: ProjectGridProps) {
|
|
||||||
const { isAuthenticated } = useAuth()
|
|
||||||
const [settingsProject, setSettingsProject] = useState<Project | null>(null)
|
|
||||||
const [userLikes, setUserLikes] = useState<Record<string, boolean>>({})
|
|
||||||
const [likeCounts, setLikeCounts] = useState<Record<string, number>>({})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const counts: Record<string, number> = {}
|
|
||||||
projects.forEach((proj) => {
|
|
||||||
counts[proj.id] = proj.likes
|
|
||||||
})
|
|
||||||
setLikeCounts(counts)
|
|
||||||
}, [projects])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
setUserLikes({})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectIds = projects.map((p) => p.id)
|
|
||||||
if (projectIds.length === 0) return
|
|
||||||
|
|
||||||
getUserProjectLikes(projectIds).then((result) => {
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setUserLikes(result.data)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [projects, isAuthenticated])
|
|
||||||
|
|
||||||
const handleSettingsClick = (e: React.MouseEvent, project: Project) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setSettingsProject(project)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleViewClick = (e: React.MouseEvent, projectId: string) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onViewClick?.(projectId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleLikeClick = async (e: React.MouseEvent, projectId: string) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
|
|
||||||
if (!isAuthenticated) return
|
|
||||||
|
|
||||||
const wasLiked = userLikes[projectId] || false
|
|
||||||
const currentCount = likeCounts[projectId] || 0
|
|
||||||
|
|
||||||
setUserLikes((prev) => ({ ...prev, [projectId]: !wasLiked }))
|
|
||||||
setLikeCounts((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[projectId]: wasLiked ? currentCount - 1 : currentCount + 1,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const result = await toggleProjectLike(projectId)
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
const data = result.data
|
|
||||||
setUserLikes((prev) => ({ ...prev, [projectId]: data.liked }))
|
|
||||||
setLikeCounts((prev) => ({ ...prev, [projectId]: data.likes }))
|
|
||||||
} else {
|
|
||||||
setUserLikes((prev) => ({ ...prev, [projectId]: wasLiked }))
|
|
||||||
setLikeCounts((prev) => ({ ...prev, [projectId]: currentCount }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
|
||||||
{projects.map((project) => {
|
|
||||||
const owner = project.owner
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={project.id}
|
|
||||||
onClick={() => onProjectClick(project.id)}
|
|
||||||
className="group text-left cursor-pointer"
|
|
||||||
>
|
|
||||||
{/* Thumbnail card */}
|
|
||||||
<div className="relative aspect-[4/3] rounded-xl rounded-smooth-xl bg-neutral-50 overflow-hidden shadow-[0_1px_3px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.04)] transition-shadow group-hover:shadow-[0_4px_12px_rgba(0,0,0,0.08),0_0_0_1px_rgba(0,0,0,0.04)]">
|
|
||||||
{project.thumbnail_url ? (
|
|
||||||
<img
|
|
||||||
src={project.thumbnail_url}
|
|
||||||
alt={project.name}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-sm">
|
|
||||||
No preview
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{canEdit && (
|
|
||||||
<div className="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
{onViewClick && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => handleViewClick(e, project.id)}
|
|
||||||
className="bg-background/80 hover:bg-background rounded-md p-1.5"
|
|
||||||
aria-label="View"
|
|
||||||
title="View in viewer mode"
|
|
||||||
>
|
|
||||||
<Eye className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={(e) => handleSettingsClick(e, project)}
|
|
||||||
className="bg-background/80 hover:bg-background rounded-md p-1.5"
|
|
||||||
aria-label="Settings"
|
|
||||||
title="Project settings"
|
|
||||||
>
|
|
||||||
<Settings className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info row below the card */}
|
|
||||||
<div className="flex items-center gap-3 mt-3">
|
|
||||||
{showOwner && owner ? (
|
|
||||||
<Link
|
|
||||||
href={owner.username ? `/u/${owner.username}` : '#'}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="shrink-0"
|
|
||||||
>
|
|
||||||
{owner.image ? (
|
|
||||||
<img
|
|
||||||
src={owner.image}
|
|
||||||
alt={owner.name}
|
|
||||||
className="w-9 h-9 rounded-full object-cover shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_3px_rgba(0,0,0,0.1)]"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-9 h-9 rounded-full bg-neutral-100 flex items-center justify-center text-sm font-medium shadow-[0_0_0_1px_rgba(0,0,0,0.06)]">
|
|
||||||
{owner.name?.[0]?.toUpperCase() || '?'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<h3 className="font-medium text-sm truncate">{project.name}</h3>
|
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
|
|
||||||
{showOwner && owner && (
|
|
||||||
<>
|
|
||||||
<Link
|
|
||||||
href={owner.username ? `/u/${owner.username}` : '#'}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="hover:text-foreground transition-colors truncate"
|
|
||||||
>
|
|
||||||
{owner.username || owner.name}
|
|
||||||
</Link>
|
|
||||||
<span className="shrink-0">·</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-0.5 shrink-0">
|
|
||||||
<Eye className="w-3.5 h-3.5" />
|
|
||||||
<span>{project.views}</span>
|
|
||||||
</div>
|
|
||||||
<span className="shrink-0">·</span>
|
|
||||||
<button
|
|
||||||
onClick={(e) => handleLikeClick(e, project.id)}
|
|
||||||
className="flex items-center gap-0.5 shrink-0 hover:text-red-500 transition-colors"
|
|
||||||
disabled={!isAuthenticated}
|
|
||||||
>
|
|
||||||
<Heart
|
|
||||||
className={`w-3.5 h-3.5 ${
|
|
||||||
userLikes[project.id] ? 'fill-red-500 text-red-500' : ''
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<span>{likeCounts[project.id] ?? project.likes}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{settingsProject && (
|
|
||||||
<ProjectSettingsDialog
|
|
||||||
project={settingsProject}
|
|
||||||
open={!!settingsProject}
|
|
||||||
onOpenChange={(open) => !open && setSettingsProject(null)}
|
|
||||||
onUpdate={onUpdate}
|
|
||||||
onDelete={() => {
|
|
||||||
setSettingsProject(null)
|
|
||||||
onUpdate?.()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useRef, useState } from 'react'
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/primitives/dialog'
|
|
||||||
import { Switch } from '@/components/ui/primitives/switch'
|
|
||||||
import { updateProjectName, updateProjectVisibility, deleteProject } from '../lib/projects/actions'
|
|
||||||
import type { Project } from '../lib/projects/types'
|
|
||||||
|
|
||||||
interface ProjectSettingsDialogProps {
|
|
||||||
project: Project
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
onUpdate?: () => void
|
|
||||||
onDelete?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProjectSettingsDialog({
|
|
||||||
project,
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
onUpdate,
|
|
||||||
onDelete,
|
|
||||||
}: ProjectSettingsDialogProps) {
|
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
|
||||||
const [name, setName] = useState(project.name || '')
|
|
||||||
const [isPrivate, setIsPrivate] = useState(project.is_private)
|
|
||||||
const [showScansPublic, setShowScansPublic] = useState(project.show_scans_public ?? true)
|
|
||||||
const [showGuidesPublic, setShowGuidesPublic] = useState(project.show_guides_public ?? true)
|
|
||||||
const nameTimerRef = useRef<ReturnType<typeof setTimeout>>(null)
|
|
||||||
|
|
||||||
const handleNameChange = (value: string) => {
|
|
||||||
setName(value)
|
|
||||||
if (nameTimerRef.current) clearTimeout(nameTimerRef.current)
|
|
||||||
nameTimerRef.current = setTimeout(async () => {
|
|
||||||
const trimmed = value.trim()
|
|
||||||
if (trimmed && trimmed !== (project.name || '')) {
|
|
||||||
await updateProjectName(project.id, trimmed)
|
|
||||||
onUpdate?.()
|
|
||||||
}
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleVisibilityChange = async (
|
|
||||||
field: 'isPrivate' | 'showScansPublic' | 'showGuidesPublic',
|
|
||||||
value: boolean,
|
|
||||||
) => {
|
|
||||||
if (field === 'isPrivate') setIsPrivate(value)
|
|
||||||
if (field === 'showScansPublic') setShowScansPublic(value)
|
|
||||||
if (field === 'showGuidesPublic') setShowGuidesPublic(value)
|
|
||||||
|
|
||||||
await updateProjectVisibility(project.id, { [field]: value })
|
|
||||||
onUpdate?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!confirm('Are you sure you want to delete this project? This action cannot be undone.')) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsDeleting(true)
|
|
||||||
try {
|
|
||||||
const result = await deleteProject(project.id)
|
|
||||||
if (result.success) {
|
|
||||||
onDelete?.()
|
|
||||||
onOpenChange(false)
|
|
||||||
} else {
|
|
||||||
alert(`Failed to delete project: ${result.error}`)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
alert('Failed to delete project')
|
|
||||||
} finally {
|
|
||||||
setIsDeleting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Project Settings</DialogTitle>
|
|
||||||
<DialogDescription>Changes are saved automatically</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-6 py-4">
|
|
||||||
{/* Project Name */}
|
|
||||||
<div>
|
|
||||||
<label htmlFor="project-name" className="font-medium text-sm">
|
|
||||||
Project Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="project-name"
|
|
||||||
type="text"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => handleNameChange(e.target.value)}
|
|
||||||
placeholder="My Project"
|
|
||||||
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Privacy Toggle */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">Privacy</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{isPrivate ? 'Only you can view this project' : 'Anyone can view this project'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm text-muted-foreground">Public</span>
|
|
||||||
<Switch checked={!isPrivate} onCheckedChange={(checked) => handleVisibilityChange('isPrivate', !checked)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Public Visibility Toggles */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">Show 3D Scans</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Visible to public viewers
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Switch checked={showScansPublic} onCheckedChange={(checked) => handleVisibilityChange('showScansPublic', checked)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">Show Floorplans</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Visible to public viewers
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Switch checked={showGuidesPublic} onCheckedChange={(checked) => handleVisibilityChange('showGuidesPublic', checked)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Danger Zone */}
|
|
||||||
<div className="border-t border-border pt-6">
|
|
||||||
<h3 className="font-medium text-destructive mb-2">Danger Zone</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mb-3">
|
|
||||||
Once you delete a project, there is no going back. Please be certain.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleDelete}
|
|
||||||
className="rounded-md border border-destructive bg-destructive/10 px-4 py-2 text-sm text-destructive hover:bg-destructive/20"
|
|
||||||
disabled={isDeleting}
|
|
||||||
>
|
|
||||||
{isDeleting ? 'Deleting...' : 'Delete Project'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import Image from 'next/image'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { ProjectGrid } from './project-grid'
|
|
||||||
import { HubFooter } from './hub-footer'
|
|
||||||
import type { Project } from '../lib/projects/types'
|
|
||||||
|
|
||||||
function GitHubIcon({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function XIcon({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function YouTubeIcon({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PublicProfilePageProps {
|
|
||||||
profile: {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
image: string | null
|
|
||||||
username: string
|
|
||||||
githubUrl: string | null
|
|
||||||
xUrl: string | null
|
|
||||||
youtubeUrl: string | null
|
|
||||||
}
|
|
||||||
projects: Project[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PublicProfilePage({ profile, projects }: PublicProfilePageProps) {
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-background">
|
|
||||||
{/* Header — same layout as the community home */}
|
|
||||||
<header className="border-b border-border bg-background/95 backdrop-blur sticky top-0 z-10">
|
|
||||||
<div className="container mx-auto px-6 py-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Link href="/" className="flex items-center gap-2">
|
|
||||||
<Image
|
|
||||||
src="/pascal-logo-shape.svg"
|
|
||||||
alt="Pascal"
|
|
||||||
width={64}
|
|
||||||
height={64}
|
|
||||||
className="h-5 w-5"
|
|
||||||
/>
|
|
||||||
<span className="text-2xl font-bold">Pascal Editor</span>
|
|
||||||
</Link>
|
|
||||||
<a
|
|
||||||
href="https://github.com/pascalorg/editor"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-muted-foreground transition-colors hover:border-foreground/20 hover:text-foreground"
|
|
||||||
>
|
|
||||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
|
||||||
</svg>
|
|
||||||
<span className="hidden sm:inline text-sm font-medium">Open Source</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="container mx-auto max-w-4xl px-6 py-8 space-y-8">
|
|
||||||
{/* Profile Header */}
|
|
||||||
<div className="flex items-center gap-6">
|
|
||||||
{profile.image ? (
|
|
||||||
<Image
|
|
||||||
src={profile.image}
|
|
||||||
alt={profile.name}
|
|
||||||
width={80}
|
|
||||||
height={80}
|
|
||||||
className="h-20 w-20 rounded-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted font-bold text-2xl">
|
|
||||||
{profile.name[0]?.toUpperCase() || '?'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h1 className="text-2xl font-bold">{profile.name}</h1>
|
|
||||||
<p className="text-muted-foreground">@{profile.username}</p>
|
|
||||||
{(profile.githubUrl || profile.xUrl || profile.youtubeUrl) && (
|
|
||||||
<div className="flex items-center gap-3 pt-1">
|
|
||||||
{profile.githubUrl && (
|
|
||||||
<a
|
|
||||||
href={profile.githubUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<GitHubIcon className="h-5 w-5" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{profile.xUrl && (
|
|
||||||
<a
|
|
||||||
href={profile.xUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<XIcon className="h-5 w-5" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{profile.youtubeUrl && (
|
|
||||||
<a
|
|
||||||
href={profile.youtubeUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<YouTubeIcon className="h-5 w-5" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Projects */}
|
|
||||||
<section>
|
|
||||||
<h2 className="text-lg font-semibold mb-4">Public Projects</h2>
|
|
||||||
{projects.length > 0 ? (
|
|
||||||
<ProjectGrid
|
|
||||||
projects={projects}
|
|
||||||
onProjectClick={(id) => router.push(`/viewer/${id}`)}
|
|
||||||
showOwner={false}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-12 text-muted-foreground">
|
|
||||||
No public projects yet
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<HubFooter />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,440 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import Image from 'next/image'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { ArrowLeft, Pencil } from 'lucide-react'
|
|
||||||
import { useRef, useState } from 'react'
|
|
||||||
import { authClient } from '../lib/auth/client'
|
|
||||||
import { updateUsername, updateProfile, uploadAvatar, updateEmailNotifications } from '../lib/auth/actions'
|
|
||||||
|
|
||||||
function GoogleIcon({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg className={className} viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
|
|
||||||
fill="#4285F4"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
|
||||||
fill="#34A853"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
|
||||||
fill="#FBBC05"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
|
||||||
fill="#EA4335"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SettingsPageProps {
|
|
||||||
user: {
|
|
||||||
id: string
|
|
||||||
name?: string | null
|
|
||||||
email?: string | null
|
|
||||||
image?: string | null
|
|
||||||
}
|
|
||||||
currentUsername: string | null
|
|
||||||
currentGithubUrl: string | null
|
|
||||||
currentXUrl: string | null
|
|
||||||
currentYoutubeUrl: string | null
|
|
||||||
currentEmailNotifications: boolean
|
|
||||||
connectedAccounts: { providerId: string; accountId: string }[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsPage({
|
|
||||||
user,
|
|
||||||
currentUsername,
|
|
||||||
currentGithubUrl,
|
|
||||||
currentXUrl,
|
|
||||||
currentYoutubeUrl,
|
|
||||||
currentEmailNotifications,
|
|
||||||
connectedAccounts,
|
|
||||||
}: SettingsPageProps) {
|
|
||||||
const [username, setUsername] = useState(currentUsername ?? '')
|
|
||||||
const [githubUrl, setGithubUrl] = useState(currentGithubUrl ?? '')
|
|
||||||
const [xUrl, setXUrl] = useState(currentXUrl ?? '')
|
|
||||||
const [youtubeUrl, setYoutubeUrl] = useState(currentYoutubeUrl ?? '')
|
|
||||||
const [avatarUrl, setAvatarUrl] = useState(user.image)
|
|
||||||
const [isSavingUsername, setIsSavingUsername] = useState(false)
|
|
||||||
const [isSavingSocial, setIsSavingSocial] = useState(false)
|
|
||||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
|
|
||||||
const [isConnectingGoogle, setIsConnectingGoogle] = useState(false)
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const [usernameMessage, setUsernameMessage] = useState<{
|
|
||||||
type: 'success' | 'error'
|
|
||||||
text: string
|
|
||||||
} | null>(null)
|
|
||||||
const [socialMessage, setSocialMessage] = useState<{
|
|
||||||
type: 'success' | 'error'
|
|
||||||
text: string
|
|
||||||
} | null>(null)
|
|
||||||
const [emailNotifications, setEmailNotifications] = useState(currentEmailNotifications)
|
|
||||||
const [isSavingNotifications, setIsSavingNotifications] = useState(false)
|
|
||||||
|
|
||||||
const isGoogleConnected = connectedAccounts.some((a) => a.providerId === 'google')
|
|
||||||
const initials = currentUsername
|
|
||||||
? currentUsername.slice(0, 2).toUpperCase()
|
|
||||||
: user.name
|
|
||||||
? user.name
|
|
||||||
.split(' ')
|
|
||||||
.map((n) => n[0])
|
|
||||||
.join('')
|
|
||||||
.toUpperCase()
|
|
||||||
.slice(0, 2)
|
|
||||||
: user.email?.[0]?.toUpperCase() || 'U'
|
|
||||||
|
|
||||||
const handleAvatarClick = () => {
|
|
||||||
fileInputRef.current?.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (!file) return
|
|
||||||
|
|
||||||
setIsUploadingAvatar(true)
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append('avatar', file)
|
|
||||||
|
|
||||||
const result = await uploadAvatar(formData)
|
|
||||||
if (result.success && result.imageUrl) {
|
|
||||||
setAvatarUrl(result.imageUrl)
|
|
||||||
}
|
|
||||||
setIsUploadingAvatar(false)
|
|
||||||
// Reset input so the same file can be selected again
|
|
||||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleConnectGoogle = async () => {
|
|
||||||
setIsConnectingGoogle(true)
|
|
||||||
try {
|
|
||||||
await authClient.signIn.social({
|
|
||||||
provider: 'google',
|
|
||||||
callbackURL: '/settings',
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
setIsConnectingGoogle(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSaveUsername = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setUsernameMessage(null)
|
|
||||||
setIsSavingUsername(true)
|
|
||||||
|
|
||||||
const result = await updateUsername(username)
|
|
||||||
setUsernameMessage({
|
|
||||||
type: result.success ? 'success' : 'error',
|
|
||||||
text: result.success ? 'Username updated successfully' : (result.error ?? 'Failed'),
|
|
||||||
})
|
|
||||||
setIsSavingUsername(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSaveSocial = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setSocialMessage(null)
|
|
||||||
setIsSavingSocial(true)
|
|
||||||
|
|
||||||
const result = await updateProfile({
|
|
||||||
githubUrl: githubUrl.trim() || null,
|
|
||||||
xUrl: xUrl.trim() || null,
|
|
||||||
youtubeUrl: youtubeUrl.trim() || null,
|
|
||||||
})
|
|
||||||
setSocialMessage({
|
|
||||||
type: result.success ? 'success' : 'error',
|
|
||||||
text: result.success
|
|
||||||
? 'Social links updated successfully'
|
|
||||||
: (result.error ?? 'Failed'),
|
|
||||||
})
|
|
||||||
setIsSavingSocial(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const usernameChanged = username.trim() !== (currentUsername ?? '')
|
|
||||||
const socialChanged =
|
|
||||||
(githubUrl.trim() || '') !== (currentGithubUrl ?? '') ||
|
|
||||||
(xUrl.trim() || '') !== (currentXUrl ?? '') ||
|
|
||||||
(youtubeUrl.trim() || '') !== (currentYoutubeUrl ?? '')
|
|
||||||
|
|
||||||
const handleToggleEmailNotifications = async () => {
|
|
||||||
const newValue = !emailNotifications
|
|
||||||
setEmailNotifications(newValue)
|
|
||||||
setIsSavingNotifications(true)
|
|
||||||
await updateEmailNotifications(newValue)
|
|
||||||
setIsSavingNotifications(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-background">
|
|
||||||
<header className="border-b border-border bg-background/95 backdrop-blur sticky top-0 z-10">
|
|
||||||
<div className="container mx-auto px-6 py-4">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="flex items-center gap-1 text-muted-foreground transition-colors hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
<span className="text-sm">Back</span>
|
|
||||||
</Link>
|
|
||||||
<h1 className="text-xl font-bold">Settings</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="container mx-auto max-w-2xl px-6 py-8 space-y-8">
|
|
||||||
{/* Profile Section */}
|
|
||||||
<section className="space-y-4">
|
|
||||||
<h2 className="text-lg font-semibold">Profile</h2>
|
|
||||||
<div className="rounded-lg border border-border p-6 space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
{/* Avatar with upload */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleAvatarClick}
|
|
||||||
disabled={isUploadingAvatar}
|
|
||||||
className="relative group shrink-0"
|
|
||||||
>
|
|
||||||
{avatarUrl ? (
|
|
||||||
<Image
|
|
||||||
src={avatarUrl}
|
|
||||||
alt={user.name || 'Profile'}
|
|
||||||
width={64}
|
|
||||||
height={64}
|
|
||||||
className="h-16 w-16 rounded-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted font-semibold text-lg">
|
|
||||||
{initials}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<Pencil className="h-4 w-4 text-white" />
|
|
||||||
</div>
|
|
||||||
{isUploadingAvatar && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40">
|
|
||||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handleAvatarUpload}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
{user.name && <div className="font-medium">{user.name}</div>}
|
|
||||||
{user.email && (
|
|
||||||
<div className="text-muted-foreground text-sm">{user.email}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSaveUsername} className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="username" className="font-medium text-sm">
|
|
||||||
Public Username
|
|
||||||
</label>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
Your public display name on the community hub.
|
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
id="username"
|
|
||||||
type="text"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => {
|
|
||||||
setUsername(e.target.value)
|
|
||||||
setUsernameMessage(null)
|
|
||||||
}}
|
|
||||||
placeholder="your-username"
|
|
||||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
disabled={isSavingUsername}
|
|
||||||
minLength={3}
|
|
||||||
maxLength={30}
|
|
||||||
pattern="[a-zA-Z0-9_-]+"
|
|
||||||
/>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
3-30 characters. Letters, numbers, hyphens, and underscores only.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{usernameMessage && (
|
|
||||||
<div
|
|
||||||
className={`rounded-md border p-3 text-sm ${
|
|
||||||
usernameMessage.type === 'success'
|
|
||||||
? 'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900/20 dark:text-green-400'
|
|
||||||
: 'border-destructive/50 bg-destructive/10 text-destructive'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{usernameMessage.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSavingUsername || !usernameChanged || !username.trim()}
|
|
||||||
className="rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isSavingUsername ? 'Saving...' : 'Save Username'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Connected Accounts Section */}
|
|
||||||
<section className="space-y-4">
|
|
||||||
<h2 className="text-lg font-semibold">Connected Accounts</h2>
|
|
||||||
<div className="rounded-lg border border-border p-6 space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<GoogleIcon className="h-5 w-5" />
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium">Google</div>
|
|
||||||
{isGoogleConnected ? (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Connected
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Not connected
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isGoogleConnected ? (
|
|
||||||
<span className="text-xs text-green-600 dark:text-green-400 font-medium px-2 py-1 rounded-full bg-green-50 dark:bg-green-900/20">
|
|
||||||
Connected
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleConnectGoogle}
|
|
||||||
disabled={isConnectingGoogle}
|
|
||||||
className="rounded-md border border-input px-3 py-1.5 text-sm transition-colors hover:bg-accent disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isConnectingGoogle ? 'Connecting...' : 'Connect'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Notifications Section */}
|
|
||||||
<section className="space-y-4">
|
|
||||||
<h2 className="text-lg font-semibold">Notifications</h2>
|
|
||||||
<div className="rounded-lg border border-border p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="text-sm font-medium">Email notifications</div>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
Receive emails about new features and updates.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
aria-checked={emailNotifications}
|
|
||||||
onClick={handleToggleEmailNotifications}
|
|
||||||
disabled={isSavingNotifications}
|
|
||||||
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${
|
|
||||||
emailNotifications ? 'bg-primary' : 'bg-input'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform ${
|
|
||||||
emailNotifications ? 'translate-x-5' : 'translate-x-0.5'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Social Links Section */}
|
|
||||||
<section className="space-y-4">
|
|
||||||
<h2 className="text-lg font-semibold">Social Links</h2>
|
|
||||||
<div className="rounded-lg border border-border p-6">
|
|
||||||
<form onSubmit={handleSaveSocial} className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="github" className="font-medium text-sm">
|
|
||||||
GitHub
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="github"
|
|
||||||
type="url"
|
|
||||||
value={githubUrl}
|
|
||||||
onChange={(e) => {
|
|
||||||
setGithubUrl(e.target.value)
|
|
||||||
setSocialMessage(null)
|
|
||||||
}}
|
|
||||||
placeholder="https://github.com/yourusername"
|
|
||||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
disabled={isSavingSocial}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="x" className="font-medium text-sm">
|
|
||||||
X (Twitter)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="x"
|
|
||||||
type="url"
|
|
||||||
value={xUrl}
|
|
||||||
onChange={(e) => {
|
|
||||||
setXUrl(e.target.value)
|
|
||||||
setSocialMessage(null)
|
|
||||||
}}
|
|
||||||
placeholder="https://x.com/yourusername"
|
|
||||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
disabled={isSavingSocial}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="youtube" className="font-medium text-sm">
|
|
||||||
YouTube
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="youtube"
|
|
||||||
type="url"
|
|
||||||
value={youtubeUrl}
|
|
||||||
onChange={(e) => {
|
|
||||||
setYoutubeUrl(e.target.value)
|
|
||||||
setSocialMessage(null)
|
|
||||||
}}
|
|
||||||
placeholder="https://youtube.com/@yourchannel"
|
|
||||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
disabled={isSavingSocial}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{socialMessage && (
|
|
||||||
<div
|
|
||||||
className={`rounded-md border p-3 text-sm ${
|
|
||||||
socialMessage.type === 'success'
|
|
||||||
? 'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900/20 dark:text-green-400'
|
|
||||||
: 'border-destructive/50 bg-destructive/10 text-destructive'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{socialMessage.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSavingSocial || !socialChanged}
|
|
||||||
className="rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isSavingSocial ? 'Saving...' : 'Save Social Links'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { Mail, X } from 'lucide-react'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { authClient } from '../lib/auth/client'
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/primitives/dialog'
|
|
||||||
|
|
||||||
interface SignInDialogProps {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function GoogleIcon({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg className={className} viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
|
|
||||||
fill="#4285F4"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
|
||||||
fill="#34A853"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
|
||||||
fill="#FBBC05"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
|
||||||
fill="#EA4335"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SignInDialog - Authentication dialog with Google OAuth and magic link
|
|
||||||
*/
|
|
||||||
const LOGIN_METHOD_LABELS: Record<string, string> = {
|
|
||||||
google: 'Google',
|
|
||||||
'magic-link': 'email link',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SignInDialog({ open, onOpenChange }: SignInDialogProps) {
|
|
||||||
const [email, setEmail] = useState('')
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [success, setSuccess] = useState(false)
|
|
||||||
|
|
||||||
const lastMethod = authClient.getLastUsedLoginMethod?.()
|
|
||||||
const lastMethodLabel = lastMethod ? LOGIN_METHOD_LABELS[lastMethod] ?? lastMethod : null
|
|
||||||
|
|
||||||
const handleGoogleSignIn = async () => {
|
|
||||||
setError(null)
|
|
||||||
setIsGoogleLoading(true)
|
|
||||||
try {
|
|
||||||
await authClient.signIn.social({
|
|
||||||
provider: 'google',
|
|
||||||
callbackURL: window.location.origin,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to sign in with Google')
|
|
||||||
setIsGoogleLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setError(null)
|
|
||||||
setIsLoading(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await authClient.signIn.magicLink({
|
|
||||||
email,
|
|
||||||
callbackURL: window.location.origin,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
setError(result.error.message || 'Failed to send magic link')
|
|
||||||
} else {
|
|
||||||
setSuccess(true)
|
|
||||||
setEmail('')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'An unexpected error occurred')
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
if (!isLoading && !isGoogleLoading) {
|
|
||||||
onOpenChange(false)
|
|
||||||
setTimeout(() => {
|
|
||||||
setEmail('')
|
|
||||||
setError(null)
|
|
||||||
setSuccess(false)
|
|
||||||
}, 200)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const anyLoading = isLoading || isGoogleLoading
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
|
||||||
<DialogContent className="sm:max-w-[400px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Sign in to Pascal</DialogTitle>
|
|
||||||
<button
|
|
||||||
className="absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 disabled:pointer-events-none"
|
|
||||||
disabled={anyLoading}
|
|
||||||
onClick={handleClose}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</button>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{success ? (
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<div className="flex flex-col items-center gap-4 text-center">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/20">
|
|
||||||
<Mail className="h-6 w-6 text-green-600 dark:text-green-400" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h3 className="font-semibold text-lg">Check your email</h3>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
We've sent a magic link to <strong>{email}</strong>
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Click the link in the email to sign in to your account.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="w-full rounded-md border border-input px-4 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
|
||||||
onClick={handleClose}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{lastMethodLabel && (
|
|
||||||
<p className="text-center text-muted-foreground text-xs">
|
|
||||||
Last signed in with {lastMethodLabel}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Google Sign-In */}
|
|
||||||
<button
|
|
||||||
className="flex w-full items-center justify-center gap-2 rounded-md border border-input bg-background px-4 py-2.5 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
|
|
||||||
disabled={anyLoading}
|
|
||||||
onClick={handleGoogleSignIn}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{isGoogleLoading ? (
|
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-foreground border-t-transparent" />
|
|
||||||
) : (
|
|
||||||
<GoogleIcon className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Continue with Google
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<span className="w-full border-t border-border" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
|
||||||
<span className="bg-background px-2 text-muted-foreground">or</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Magic Link Form */}
|
|
||||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="font-medium text-sm" htmlFor="email">
|
|
||||||
Email address
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
autoComplete="email"
|
|
||||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
disabled={anyLoading}
|
|
||||||
id="email"
|
|
||||||
placeholder="you@example.com"
|
|
||||||
required
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="flex w-full items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm transition-colors hover:bg-primary/90 disabled:opacity-50"
|
|
||||||
disabled={anyLoading || !email}
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<>
|
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent" />
|
|
||||||
Sending magic link...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Mail className="h-4 w-4" />
|
|
||||||
Send magic link
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p className="text-center text-muted-foreground text-xs">
|
|
||||||
By signing in, you agree to our{' '}
|
|
||||||
<Link href="/terms" className="underline hover:text-foreground">
|
|
||||||
Terms of Service
|
|
||||||
</Link>{' '}
|
|
||||||
and{' '}
|
|
||||||
<Link href="/privacy" className="underline hover:text-foreground">
|
|
||||||
Privacy Policy
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useAuth } from '../lib/auth/hooks'
|
|
||||||
import { getUsername } from '../lib/auth/actions'
|
|
||||||
import { UsernameOnboardingDialog } from './username-onboarding-dialog'
|
|
||||||
|
|
||||||
export function UsernameGate({ children }: { children: React.ReactNode }) {
|
|
||||||
const { isAuthenticated, isLoading } = useAuth()
|
|
||||||
const [needsUsername, setNeedsUsername] = useState(false)
|
|
||||||
const [checking, setChecking] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isLoading) return
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
setChecking(false)
|
|
||||||
setNeedsUsername(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
getUsername()
|
|
||||||
.then((username) => {
|
|
||||||
setNeedsUsername(!username)
|
|
||||||
setChecking(false)
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setNeedsUsername(false)
|
|
||||||
setChecking(false)
|
|
||||||
})
|
|
||||||
}, [isAuthenticated, isLoading])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{children}
|
|
||||||
<UsernameOnboardingDialog
|
|
||||||
open={needsUsername && !checking}
|
|
||||||
onComplete={() => setNeedsUsername(false)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/primitives/dialog'
|
|
||||||
import { updateUsername, checkUsernameAvailability } from '../lib/auth/actions'
|
|
||||||
|
|
||||||
interface UsernameOnboardingDialogProps {
|
|
||||||
open: boolean
|
|
||||||
onComplete: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UsernameOnboardingDialog({ open, onComplete }: UsernameOnboardingDialogProps) {
|
|
||||||
const [username, setUsername] = useState('')
|
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [availability, setAvailability] = useState<'idle' | 'checking' | 'available' | 'taken'>(
|
|
||||||
'idle',
|
|
||||||
)
|
|
||||||
|
|
||||||
const validate = (value: string): string | null => {
|
|
||||||
if (value.length < 3) return 'Must be at least 3 characters'
|
|
||||||
if (value.length > 30) return 'Must be at most 30 characters'
|
|
||||||
if (!/^[a-zA-Z0-9_-]+$/.test(value))
|
|
||||||
return 'Only letters, numbers, hyphens, and underscores'
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkAvailability = useCallback(async (value: string) => {
|
|
||||||
const validationError = validate(value)
|
|
||||||
if (validationError) {
|
|
||||||
setAvailability('idle')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setAvailability('checking')
|
|
||||||
const result = await checkUsernameAvailability(value)
|
|
||||||
setAvailability(result.available ? 'available' : 'taken')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!username.trim()) {
|
|
||||||
setAvailability('idle')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const timer = setTimeout(() => checkAvailability(username.trim()), 300)
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}, [username, checkAvailability])
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
const trimmed = username.trim()
|
|
||||||
const validationError = validate(trimmed)
|
|
||||||
if (validationError) {
|
|
||||||
setError(validationError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setError(null)
|
|
||||||
setIsSaving(true)
|
|
||||||
|
|
||||||
const result = await updateUsername(trimmed)
|
|
||||||
if (result.success) {
|
|
||||||
onComplete()
|
|
||||||
} else {
|
|
||||||
setError(result.error ?? 'Failed to set username')
|
|
||||||
}
|
|
||||||
setIsSaving(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const validationError = username.trim() ? validate(username.trim()) : null
|
|
||||||
const canSubmit = !isSaving && !validationError && availability === 'available'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={() => {}}>
|
|
||||||
<DialogContent className="sm:max-w-[420px] [&>button]:hidden">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Choose your username</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Pick a public username for the community hub. This will be visible on projects you share.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="relative">
|
|
||||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm">
|
|
||||||
@
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => {
|
|
||||||
setUsername(e.target.value)
|
|
||||||
setError(null)
|
|
||||||
}}
|
|
||||||
placeholder="your-username"
|
|
||||||
className="w-full rounded-md border border-input bg-background pl-7 pr-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
disabled={isSaving}
|
|
||||||
autoFocus
|
|
||||||
minLength={3}
|
|
||||||
maxLength={30}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status indicators */}
|
|
||||||
{username.trim() && !validationError && (
|
|
||||||
<div className="text-xs">
|
|
||||||
{availability === 'checking' && (
|
|
||||||
<span className="text-muted-foreground">Checking availability...</span>
|
|
||||||
)}
|
|
||||||
{availability === 'available' && (
|
|
||||||
<span className="text-green-600 dark:text-green-400">Username is available</span>
|
|
||||||
)}
|
|
||||||
{availability === 'taken' && (
|
|
||||||
<span className="text-destructive">Username is already taken</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{validationError && (
|
|
||||||
<p className="text-destructive text-xs">{validationError}</p>
|
|
||||||
)}
|
|
||||||
{!username.trim() && (
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
3-30 characters. Letters, numbers, hyphens, and underscores only.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!canSubmit}
|
|
||||||
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isSaving ? 'Setting username...' : 'Continue'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
'use server'
|
|
||||||
|
|
||||||
import { createServerSupabaseClient } from '../database/server'
|
|
||||||
import { getSession } from '../auth/server'
|
|
||||||
import { createId } from '../utils/id-generator'
|
|
||||||
|
|
||||||
const BUCKET = 'project-assets'
|
|
||||||
|
|
||||||
export type AssetType = 'scan' | 'guide'
|
|
||||||
|
|
||||||
export type UploadAssetResult =
|
|
||||||
| { success: true; url: string }
|
|
||||||
| { success: false; error: string }
|
|
||||||
|
|
||||||
export type CreateUploadUrlResult =
|
|
||||||
| { success: true; signedUrl: string; storageKey: string; assetId: string }
|
|
||||||
| { success: false; error: string }
|
|
||||||
|
|
||||||
export type ConfirmUploadResult =
|
|
||||||
| { success: true; url: string }
|
|
||||||
| { success: false; error: string }
|
|
||||||
|
|
||||||
export type DeleteAssetResult =
|
|
||||||
| { success: true }
|
|
||||||
| { success: false; error: string }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload a scan or guide file to Supabase Storage and record it in project_assets.
|
|
||||||
* Returns the public HTTPS URL that can be stored directly on the scene node.
|
|
||||||
*/
|
|
||||||
export async function uploadProjectAsset(
|
|
||||||
projectId: string,
|
|
||||||
file: File,
|
|
||||||
type: AssetType,
|
|
||||||
): Promise<UploadAssetResult> {
|
|
||||||
try {
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return { success: false, error: 'Not authenticated' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = await createServerSupabaseClient()
|
|
||||||
|
|
||||||
// Verify the user owns this project
|
|
||||||
const { data: project, error: projectError } = await supabase
|
|
||||||
.from('projects')
|
|
||||||
.select('owner_id')
|
|
||||||
.eq('id', projectId)
|
|
||||||
.single()
|
|
||||||
|
|
||||||
if (projectError || !project) {
|
|
||||||
return { success: false, error: 'Project not found' }
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((project as any).owner_id !== session.user.id) {
|
|
||||||
return { success: false, error: 'Not authorized to upload to this project' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive extension from file name
|
|
||||||
const ext = file.name.includes('.') ? file.name.split('.').pop()! : ''
|
|
||||||
const assetId = createId('asset')
|
|
||||||
const storageKey = ext ? `${projectId}/${assetId}.${ext}` : `${projectId}/${assetId}`
|
|
||||||
|
|
||||||
const arrayBuffer = await file.arrayBuffer()
|
|
||||||
const bytes = new Uint8Array(arrayBuffer)
|
|
||||||
|
|
||||||
const { data: uploadData, error: uploadError } = await supabase.storage
|
|
||||||
.from(BUCKET)
|
|
||||||
.upload(storageKey, bytes, {
|
|
||||||
contentType: file.type || 'application/octet-stream',
|
|
||||||
upsert: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (uploadError) {
|
|
||||||
return { success: false, error: `Upload failed: ${uploadError.message}` }
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: urlData } = supabase.storage
|
|
||||||
.from(BUCKET)
|
|
||||||
.getPublicUrl(uploadData.path)
|
|
||||||
|
|
||||||
const url = urlData.publicUrl
|
|
||||||
|
|
||||||
// Record in project_assets table
|
|
||||||
const { error: insertError } = await (supabase as any).from('project_assets').insert({
|
|
||||||
id: assetId,
|
|
||||||
project_id: projectId,
|
|
||||||
storage_key: storageKey,
|
|
||||||
url,
|
|
||||||
type,
|
|
||||||
original_name: file.name,
|
|
||||||
mime_type: file.type || null,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (insertError) {
|
|
||||||
// Best-effort cleanup: remove the uploaded file
|
|
||||||
await supabase.storage.from(BUCKET).remove([storageKey])
|
|
||||||
return { success: false, error: `Failed to record asset: ${insertError.message}` }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, url }
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to upload asset',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a signed upload URL so the client can upload directly to Supabase Storage.
|
|
||||||
* Bypasses Next.js body-size limits — supports files up to the bucket limit (500 MB).
|
|
||||||
*/
|
|
||||||
export async function createAssetUploadUrl(
|
|
||||||
projectId: string,
|
|
||||||
fileName: string,
|
|
||||||
contentType: string,
|
|
||||||
type: AssetType,
|
|
||||||
): Promise<CreateUploadUrlResult> {
|
|
||||||
try {
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return { success: false, error: 'Not authenticated' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = await createServerSupabaseClient()
|
|
||||||
|
|
||||||
const { data: project, error: projectError } = await supabase
|
|
||||||
.from('projects')
|
|
||||||
.select('owner_id')
|
|
||||||
.eq('id', projectId)
|
|
||||||
.single()
|
|
||||||
|
|
||||||
if (projectError || !project) {
|
|
||||||
return { success: false, error: 'Project not found' }
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((project as any).owner_id !== session.user.id) {
|
|
||||||
return { success: false, error: 'Not authorized to upload to this project' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const ext = fileName.includes('.') ? fileName.split('.').pop()! : ''
|
|
||||||
const assetId = createId('asset')
|
|
||||||
const storageKey = ext ? `${projectId}/${assetId}.${ext}` : `${projectId}/${assetId}`
|
|
||||||
|
|
||||||
const { data, error } = await supabase.storage
|
|
||||||
.from(BUCKET)
|
|
||||||
.createSignedUploadUrl(storageKey)
|
|
||||||
|
|
||||||
if (error || !data) {
|
|
||||||
return { success: false, error: `Failed to create upload URL: ${error?.message}` }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, signedUrl: data.signedUrl, storageKey, assetId }
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to create upload URL',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Record a successfully uploaded asset in the project_assets table.
|
|
||||||
* Called after the client uploads the file directly to Supabase Storage.
|
|
||||||
*/
|
|
||||||
export async function confirmAssetUpload(
|
|
||||||
projectId: string,
|
|
||||||
assetId: string,
|
|
||||||
storageKey: string,
|
|
||||||
originalName: string,
|
|
||||||
mimeType: string | null,
|
|
||||||
type: AssetType,
|
|
||||||
): Promise<ConfirmUploadResult> {
|
|
||||||
try {
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return { success: false, error: 'Not authenticated' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = await createServerSupabaseClient()
|
|
||||||
|
|
||||||
const { data: project, error: projectError } = await supabase
|
|
||||||
.from('projects')
|
|
||||||
.select('owner_id')
|
|
||||||
.eq('id', projectId)
|
|
||||||
.single()
|
|
||||||
|
|
||||||
if (projectError || !project) {
|
|
||||||
return { success: false, error: 'Project not found' }
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((project as any).owner_id !== session.user.id) {
|
|
||||||
return { success: false, error: 'Not authorized' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: urlData } = supabase.storage
|
|
||||||
.from(BUCKET)
|
|
||||||
.getPublicUrl(storageKey)
|
|
||||||
|
|
||||||
const url = urlData.publicUrl
|
|
||||||
|
|
||||||
const { error: insertError } = await (supabase as any).from('project_assets').insert({
|
|
||||||
id: assetId,
|
|
||||||
project_id: projectId,
|
|
||||||
storage_key: storageKey,
|
|
||||||
url,
|
|
||||||
type,
|
|
||||||
original_name: originalName,
|
|
||||||
mime_type: mimeType,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (insertError) {
|
|
||||||
await supabase.storage.from(BUCKET).remove([storageKey])
|
|
||||||
return { success: false, error: `Failed to record asset: ${insertError.message}` }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, url }
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to confirm upload',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a project asset by its public URL.
|
|
||||||
* Removes both the storage file and the project_assets row.
|
|
||||||
*/
|
|
||||||
export async function deleteProjectAssetByUrl(
|
|
||||||
projectId: string,
|
|
||||||
url: string,
|
|
||||||
): Promise<DeleteAssetResult> {
|
|
||||||
try {
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return { success: false, error: 'Not authenticated' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = await createServerSupabaseClient()
|
|
||||||
|
|
||||||
// Verify ownership
|
|
||||||
const { data: project, error: projectError } = await supabase
|
|
||||||
.from('projects')
|
|
||||||
.select('owner_id')
|
|
||||||
.eq('id', projectId)
|
|
||||||
.single()
|
|
||||||
|
|
||||||
if (projectError || !project) {
|
|
||||||
return { success: false, error: 'Project not found' }
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((project as any).owner_id !== session.user.id) {
|
|
||||||
return { success: false, error: 'Not authorized' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive storage_key from the public URL
|
|
||||||
// URL format: https://<project>.supabase.co/storage/v1/object/public/project-assets/<storageKey>
|
|
||||||
const storageKeyFromUrl = url.split(`/${BUCKET}/`)[1]?.split('?')[0]
|
|
||||||
|
|
||||||
if (!storageKeyFromUrl) {
|
|
||||||
return { success: false, error: 'Could not derive storage key from URL' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete from storage directly — remove() is a no-op if the file doesn't exist
|
|
||||||
const { error: storageError } = await supabase.storage.from(BUCKET).remove([storageKeyFromUrl])
|
|
||||||
if (storageError) {
|
|
||||||
return { success: false, error: `Storage delete failed: ${storageError.message}` }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete DB row by storage_key scoped to this project
|
|
||||||
const { error: dbError } = await (supabase as any).from('project_assets')
|
|
||||||
.delete()
|
|
||||||
.eq('project_id', projectId)
|
|
||||||
.eq('storage_key', storageKeyFromUrl)
|
|
||||||
|
|
||||||
if (dbError) {
|
|
||||||
return { success: false, error: `DB delete failed: ${dbError.message}` }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true }
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to delete asset',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
'use server'
|
|
||||||
|
|
||||||
import { revalidatePath } from 'next/cache'
|
|
||||||
import { redirect } from 'next/navigation'
|
|
||||||
import { db, schema } from '@pascal-app/db'
|
|
||||||
import { eq, and, ne, sql } from 'drizzle-orm'
|
|
||||||
import { auth } from '@/lib/auth'
|
|
||||||
import { getSession } from './server'
|
|
||||||
import { createServerSupabaseClient } from '../database/server'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sign in with a social provider (Google)
|
|
||||||
*/
|
|
||||||
export async function signInSocial(provider: 'google', callbackURL?: string) {
|
|
||||||
const result = await auth.api.signInSocial({
|
|
||||||
body: { provider, callbackURL: callbackURL ?? '/' },
|
|
||||||
})
|
|
||||||
revalidatePath('/')
|
|
||||||
if (result.url && result.redirect) {
|
|
||||||
redirect(result.url as '/')
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the current user's public username
|
|
||||||
*/
|
|
||||||
export async function updateUsername(
|
|
||||||
username: string,
|
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session?.user) {
|
|
||||||
return { success: false, error: 'Not authenticated' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate username format
|
|
||||||
const trimmed = username.trim()
|
|
||||||
if (trimmed.length < 3) {
|
|
||||||
return { success: false, error: 'Username must be at least 3 characters' }
|
|
||||||
}
|
|
||||||
if (trimmed.length > 30) {
|
|
||||||
return { success: false, error: 'Username must be at most 30 characters' }
|
|
||||||
}
|
|
||||||
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Username can only contain letters, numbers, hyphens, and underscores',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if username is already taken (case-insensitive)
|
|
||||||
const existing = await db
|
|
||||||
.select({ id: schema.users.id })
|
|
||||||
.from(schema.users)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
sql`lower(${schema.users.username}) = lower(${trimmed})`,
|
|
||||||
ne(schema.users.id, session.user.id),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (existing.length > 0) {
|
|
||||||
return { success: false, error: 'Username is already taken' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await db
|
|
||||||
.update(schema.users)
|
|
||||||
.set({ username: trimmed })
|
|
||||||
.where(eq(schema.users.id, session.user.id))
|
|
||||||
.returning({ id: schema.users.id })
|
|
||||||
|
|
||||||
if (updated.length === 0) {
|
|
||||||
return { success: false, error: 'User not found. Please sign out and sign in again.' }
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath('/')
|
|
||||||
revalidatePath('/settings')
|
|
||||||
return { success: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current user's username
|
|
||||||
*/
|
|
||||||
export async function getUsername(): Promise<string | null> {
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session?.user) return null
|
|
||||||
|
|
||||||
const result = await db
|
|
||||||
.select({ username: schema.users.username })
|
|
||||||
.from(schema.users)
|
|
||||||
.where(eq(schema.users.id, session.user.id))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
return result[0]?.username ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a username is available
|
|
||||||
*/
|
|
||||||
export async function checkUsernameAvailability(
|
|
||||||
username: string,
|
|
||||||
): Promise<{ available: boolean }> {
|
|
||||||
const trimmed = username.trim()
|
|
||||||
if (trimmed.length < 3 || !/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
|
||||||
return { available: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await db
|
|
||||||
.select({ id: schema.users.id })
|
|
||||||
.from(schema.users)
|
|
||||||
.where(sql`lower(${schema.users.username}) = lower(${trimmed})`)
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
return { available: existing.length === 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current user's full profile
|
|
||||||
*/
|
|
||||||
export async function getUserProfile(): Promise<{
|
|
||||||
username: string | null
|
|
||||||
githubUrl: string | null
|
|
||||||
xUrl: string | null
|
|
||||||
youtubeUrl: string | null
|
|
||||||
emailNotifications: boolean
|
|
||||||
} | null> {
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session?.user) return null
|
|
||||||
|
|
||||||
const result = await db
|
|
||||||
.select({
|
|
||||||
username: schema.users.username,
|
|
||||||
githubUrl: schema.users.githubUrl,
|
|
||||||
xUrl: schema.users.xUrl,
|
|
||||||
youtubeUrl: schema.users.youtubeUrl,
|
|
||||||
emailNotifications: schema.users.emailNotifications,
|
|
||||||
})
|
|
||||||
.from(schema.users)
|
|
||||||
.where(eq(schema.users.id, session.user.id))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
return result[0] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the current user's social profile links
|
|
||||||
*/
|
|
||||||
export async function updateProfile(data: {
|
|
||||||
githubUrl?: string | null
|
|
||||||
xUrl?: string | null
|
|
||||||
youtubeUrl?: string | null
|
|
||||||
}): Promise<{ success: boolean; error?: string }> {
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session?.user) {
|
|
||||||
return { success: false, error: 'Not authenticated' }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.githubUrl && !/^https:\/\/(www\.)?github\.com\/.+/.test(data.githubUrl)) {
|
|
||||||
return { success: false, error: 'Invalid GitHub URL' }
|
|
||||||
}
|
|
||||||
if (data.xUrl && !/^https:\/\/(www\.)?(x|twitter)\.com\/.+/.test(data.xUrl)) {
|
|
||||||
return { success: false, error: 'Invalid X/Twitter URL' }
|
|
||||||
}
|
|
||||||
if (data.youtubeUrl && !/^https:\/\/(www\.)?(youtube\.com|youtu\.be)\/.+/.test(data.youtubeUrl)) {
|
|
||||||
return { success: false, error: 'Invalid YouTube URL' }
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(schema.users)
|
|
||||||
.set({
|
|
||||||
githubUrl: data.githubUrl ?? null,
|
|
||||||
xUrl: data.xUrl ?? null,
|
|
||||||
youtubeUrl: data.youtubeUrl ?? null,
|
|
||||||
})
|
|
||||||
.where(eq(schema.users.id, session.user.id))
|
|
||||||
|
|
||||||
revalidatePath('/settings')
|
|
||||||
return { success: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a user's public profile by username
|
|
||||||
*/
|
|
||||||
export async function getPublicProfile(username: string): Promise<{
|
|
||||||
success: boolean
|
|
||||||
data?: {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
image: string | null
|
|
||||||
username: string
|
|
||||||
githubUrl: string | null
|
|
||||||
xUrl: string | null
|
|
||||||
youtubeUrl: string | null
|
|
||||||
}
|
|
||||||
error?: string
|
|
||||||
}> {
|
|
||||||
const result = await db
|
|
||||||
.select({
|
|
||||||
id: schema.users.id,
|
|
||||||
name: schema.users.name,
|
|
||||||
image: schema.users.image,
|
|
||||||
username: schema.users.username,
|
|
||||||
githubUrl: schema.users.githubUrl,
|
|
||||||
xUrl: schema.users.xUrl,
|
|
||||||
youtubeUrl: schema.users.youtubeUrl,
|
|
||||||
})
|
|
||||||
.from(schema.users)
|
|
||||||
.where(sql`lower(${schema.users.username}) = lower(${username})`)
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
const user = result[0]
|
|
||||||
if (!user || !user.username) {
|
|
||||||
return { success: false, error: 'User not found' }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, data: user as typeof user & { username: string } }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get connected accounts for the current user
|
|
||||||
*/
|
|
||||||
export async function getConnectedAccounts(): Promise<
|
|
||||||
{ providerId: string; accountId: string }[]
|
|
||||||
> {
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session?.user) return []
|
|
||||||
|
|
||||||
const result = await db
|
|
||||||
.select({
|
|
||||||
providerId: schema.accounts.providerId,
|
|
||||||
accountId: schema.accounts.accountId,
|
|
||||||
})
|
|
||||||
.from(schema.accounts)
|
|
||||||
.where(eq(schema.accounts.userId, session.user.id))
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload avatar image to Supabase Storage and update user record
|
|
||||||
*/
|
|
||||||
export async function uploadAvatar(
|
|
||||||
formData: FormData,
|
|
||||||
): Promise<{ success: boolean; imageUrl?: string; error?: string }> {
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session?.user) {
|
|
||||||
return { success: false, error: 'Not authenticated' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = formData.get('avatar') as File | null
|
|
||||||
if (!file) {
|
|
||||||
return { success: false, error: 'No file provided' }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.size > 5 * 1024 * 1024) {
|
|
||||||
return { success: false, error: 'File too large (max 5MB)' }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!file.type.startsWith('image/')) {
|
|
||||||
return { success: false, error: 'File must be an image' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = await createServerSupabaseClient()
|
|
||||||
const ext = file.name.split('.').pop() || 'png'
|
|
||||||
const filename = `${session.user.id}/avatar.${ext}`
|
|
||||||
|
|
||||||
const { data: uploadData, error: uploadError } = await supabase.storage
|
|
||||||
.from('avatars')
|
|
||||||
.upload(filename, file, {
|
|
||||||
contentType: file.type,
|
|
||||||
upsert: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (uploadError) {
|
|
||||||
return { success: false, error: `Upload failed: ${uploadError.message}` }
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: urlData } = supabase.storage.from('avatars').getPublicUrl(uploadData.path)
|
|
||||||
const imageUrl = `${urlData.publicUrl}?t=${Date.now()}`
|
|
||||||
|
|
||||||
// Update user image in database
|
|
||||||
await db
|
|
||||||
.update(schema.users)
|
|
||||||
.set({ image: imageUrl })
|
|
||||||
.where(eq(schema.users.id, session.user.id))
|
|
||||||
|
|
||||||
revalidatePath('/')
|
|
||||||
revalidatePath('/settings')
|
|
||||||
return { success: true, imageUrl }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the current user's email notification preference
|
|
||||||
*/
|
|
||||||
export async function updateEmailNotifications(
|
|
||||||
enabled: boolean,
|
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session?.user) {
|
|
||||||
return { success: false, error: 'Not authenticated' }
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(schema.users)
|
|
||||||
.set({ emailNotifications: enabled })
|
|
||||||
.where(eq(schema.users.id, session.user.id))
|
|
||||||
|
|
||||||
revalidatePath('/settings')
|
|
||||||
return { success: true }
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
/**
|
|
||||||
* Auth client for the editor using better-auth
|
|
||||||
* Re-exports from @pascal-app/auth package
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { authClient } from '@pascal-app/auth/client'
|
|
||||||
export type { AuthState, User, Session } from '@pascal-app/auth/client'
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { authClient } from './client'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to access authentication state using better-auth
|
|
||||||
* @returns Current auth state including user, session, and loading status
|
|
||||||
*/
|
|
||||||
export function useAuth() {
|
|
||||||
const session = authClient.useSession()
|
|
||||||
|
|
||||||
return {
|
|
||||||
user: session.data?.user ?? null,
|
|
||||||
session: session.data?.session ?? null,
|
|
||||||
isAuthenticated: !!session.data?.user && !!session.data?.session,
|
|
||||||
isLoading: session.isPending,
|
|
||||||
signOut: () => authClient.signOut(),
|
|
||||||
signIn: authClient.signIn,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { headers as nextHeaders } from 'next/headers'
|
|
||||||
import { BASE_URL } from '@/lib/utils'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current session from Better Auth backend (server-side)
|
|
||||||
*/
|
|
||||||
export async function getSession() {
|
|
||||||
try {
|
|
||||||
const headersList = await nextHeaders()
|
|
||||||
|
|
||||||
// Make authenticated request to the auth backend to get session
|
|
||||||
const response = await fetch(`${BASE_URL}/api/auth/get-session`, {
|
|
||||||
headers: {
|
|
||||||
cookie: headersList.get('cookie') || '',
|
|
||||||
},
|
|
||||||
credentials: 'include',
|
|
||||||
cache: 'no-store',
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
// Better Auth returns the session data directly
|
|
||||||
if (data?.user && data?.session) {
|
|
||||||
return {
|
|
||||||
user: data.user,
|
|
||||||
session: data.session,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to get session:', error)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current user from the session
|
|
||||||
*/
|
|
||||||
export async function getUser() {
|
|
||||||
const session = await getSession()
|
|
||||||
return session?.user ?? null
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/**
|
|
||||||
* Supabase server client for database access
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { supabaseAdmin } from '@/lib/supabase/server'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a Supabase client for server-side use with service role key
|
|
||||||
* This bypasses RLS and allows server actions to query the database directly
|
|
||||||
* Authentication is handled by Better Auth, permissions enforced by filtering on user_id
|
|
||||||
*/
|
|
||||||
export async function createServerSupabaseClient() {
|
|
||||||
return supabaseAdmin
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
'use server'
|
|
||||||
|
|
||||||
import { createId } from '@pascal-app/db'
|
|
||||||
import { createServerSupabaseClient } from '../database/server'
|
|
||||||
import { getSession } from '../auth/server'
|
|
||||||
|
|
||||||
const MAX_IMAGES = 5
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create signed upload URLs so the client can upload images directly to
|
|
||||||
* Supabase Storage — bypasses Vercel's 4.5 MB serverless body-size limit.
|
|
||||||
*/
|
|
||||||
export async function createImageUploadUrls(
|
|
||||||
files: { name: string; type: string }[],
|
|
||||||
): Promise<
|
|
||||||
| { success: true; uploads: { path: string; signedUrl: string }[] }
|
|
||||||
| { success: false; error: string }
|
|
||||||
> {
|
|
||||||
try {
|
|
||||||
if (files.length > MAX_IMAGES) {
|
|
||||||
return { success: false, error: `Maximum ${MAX_IMAGES} images allowed` }
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = await createServerSupabaseClient()
|
|
||||||
const uploads: { path: string; signedUrl: string }[] = []
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
if (!file.type.startsWith('image/')) continue
|
|
||||||
|
|
||||||
const ext = file.name.split('.').pop() || 'jpg'
|
|
||||||
const path = `${createId('img')}.${ext}`
|
|
||||||
|
|
||||||
const { data, error } = await (
|
|
||||||
supabase as ReturnType<typeof import('@supabase/supabase-js').createClient>
|
|
||||||
).storage
|
|
||||||
.from('feedback-images')
|
|
||||||
.createSignedUploadUrl(path)
|
|
||||||
|
|
||||||
if (error || !data) {
|
|
||||||
console.error(`Failed to create signed URL for ${file.name}:`, error)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
uploads.push({ path, signedUrl: data.signedUrl })
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, uploads }
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: err instanceof Error ? err.message : 'Failed to create upload URLs',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Submit feedback with pre-uploaded image paths.
|
|
||||||
* Images are already in Supabase Storage — this just records the metadata.
|
|
||||||
*/
|
|
||||||
export async function submitFeedback(data: {
|
|
||||||
message: string
|
|
||||||
projectId?: string | null
|
|
||||||
sceneGraph?: unknown
|
|
||||||
imagePaths?: string[]
|
|
||||||
}): Promise<{ success: true } | { success: false; error: string }> {
|
|
||||||
try {
|
|
||||||
const { message, projectId, sceneGraph, imagePaths } = data
|
|
||||||
if (!message?.trim()) return { success: false, error: 'Message cannot be empty' }
|
|
||||||
|
|
||||||
const session = await getSession()
|
|
||||||
const supabase = await createServerSupabaseClient()
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const { error } = await (supabase as any).from('feedback').insert({
|
|
||||||
id: createId('feedback'),
|
|
||||||
user_id: session?.user?.id ?? null,
|
|
||||||
project_id: projectId ?? null,
|
|
||||||
message: message.trim(),
|
|
||||||
images: imagePaths && imagePaths.length > 0 ? imagePaths : null,
|
|
||||||
scene_graph: sceneGraph ?? null,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (error) return { success: false, error: error.message }
|
|
||||||
|
|
||||||
return { success: true }
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: err instanceof Error ? err.message : 'Failed to submit feedback',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,308 +0,0 @@
|
|||||||
/**
|
|
||||||
* Hooks for project model (scene) loading and auto-saving
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { useScene } from '@pascal-app/core'
|
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
|
||||||
import { useEffect, useRef } from 'react'
|
|
||||||
import useEditor from '@/store/use-editor'
|
|
||||||
import { useProjectStore } from '../projects/store'
|
|
||||||
import { getProjectModel, saveProjectModel, type SceneGraph } from './actions'
|
|
||||||
|
|
||||||
/** Debounce interval for cloud auto-save (ms). */
|
|
||||||
const AUTOSAVE_DEBOUNCE_MS = 1_000
|
|
||||||
|
|
||||||
function syncEditorSelectionFromCurrentScene() {
|
|
||||||
const sceneNodes = useScene.getState().nodes as Record<string, any>
|
|
||||||
const sceneRootIds = useScene.getState().rootNodeIds
|
|
||||||
const siteNode = sceneRootIds[0] ? sceneNodes[sceneRootIds[0]] : null
|
|
||||||
const resolve = (child: any) =>
|
|
||||||
typeof child === 'string' ? sceneNodes[child] : child
|
|
||||||
const firstBuilding = siteNode?.children?.map(resolve).find((n: any) => n?.type === 'building')
|
|
||||||
const firstLevel = firstBuilding?.children?.map(resolve).find((n: any) => n?.type === 'level')
|
|
||||||
|
|
||||||
if (firstBuilding && firstLevel) {
|
|
||||||
useViewer.getState().setSelection({
|
|
||||||
buildingId: firstBuilding.id,
|
|
||||||
levelId: firstLevel.id,
|
|
||||||
selectedIds: [],
|
|
||||||
zoneId: null,
|
|
||||||
})
|
|
||||||
useEditor.getState().setPhase('structure')
|
|
||||||
useEditor.getState().setStructureLayer('elements')
|
|
||||||
|
|
||||||
// Auto-select the wall tool if the level is empty (e.g., brand new project)
|
|
||||||
if (!firstLevel.children || firstLevel.children.length === 0) {
|
|
||||||
useEditor.getState().setMode('build')
|
|
||||||
useEditor.getState().setTool('wall')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
useEditor.getState().setPhase('site')
|
|
||||||
useViewer.getState().setSelection({
|
|
||||||
buildingId: null,
|
|
||||||
levelId: null,
|
|
||||||
selectedIds: [],
|
|
||||||
zoneId: null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) {
|
|
||||||
if (sceneGraph?.nodes && sceneGraph.rootNodeIds) {
|
|
||||||
const { nodes, rootNodeIds } = sceneGraph
|
|
||||||
useScene.getState().setScene(nodes, rootNodeIds)
|
|
||||||
} else {
|
|
||||||
useScene.getState().clearScene()
|
|
||||||
}
|
|
||||||
|
|
||||||
syncEditorSelectionFromCurrentScene()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load the scene when a project becomes active.
|
|
||||||
* Saves changes automatically with debouncing.
|
|
||||||
*
|
|
||||||
* ⚠️ This hook must be mounted in exactly ONE component (the Editor).
|
|
||||||
* Mounting it in multiple components causes duplicate save calls.
|
|
||||||
*/
|
|
||||||
export function useProjectScene() {
|
|
||||||
// Subscribe to project store
|
|
||||||
const activeProject = useProjectStore((state) => state.activeProject)
|
|
||||||
const isLoadingProject = useProjectStore((state) => state.isLoading)
|
|
||||||
const isVersionPreviewMode = useProjectStore((state) => state.isVersionPreviewMode)
|
|
||||||
const setAutosaveStatus = useProjectStore((state) => state.setAutosaveStatus)
|
|
||||||
|
|
||||||
const lastProjectIdRef = useRef<string | null>(null)
|
|
||||||
const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
|
|
||||||
const isSavingRef = useRef(false)
|
|
||||||
const currentProjectIdRef = useRef<string | null>(null)
|
|
||||||
// Track whether the scene was just loaded from the server so we can skip
|
|
||||||
// the first store update (which is the load itself, not a user edit).
|
|
||||||
const isLoadingSceneRef = useRef(false)
|
|
||||||
// Track whether there are pending changes that arrived while a save was
|
|
||||||
// in-flight so we can coalesce them into one follow-up save.
|
|
||||||
const pendingSaveRef = useRef(false)
|
|
||||||
const executeSaveRef = useRef<(() => Promise<void>) | null>(null)
|
|
||||||
|
|
||||||
// Extract project ID for dependency tracking
|
|
||||||
const projectId = activeProject?.id ?? null
|
|
||||||
|
|
||||||
// Load scene when active project changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (isLoadingProject) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!projectId) {
|
|
||||||
useProjectStore.getState().setIsVersionPreviewMode(false)
|
|
||||||
setAutosaveStatus('idle')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip if same project
|
|
||||||
if (lastProjectIdRef.current === projectId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
lastProjectIdRef.current = projectId
|
|
||||||
|
|
||||||
// Load the project's scene
|
|
||||||
async function loadScene() {
|
|
||||||
// Suppress auto-save for the store update caused by setScene/clearScene
|
|
||||||
isLoadingSceneRef.current = true
|
|
||||||
useProjectStore.getState().setIsVersionPreviewMode(false)
|
|
||||||
setAutosaveStatus('idle')
|
|
||||||
|
|
||||||
useProjectStore.getState().setIsSceneLoading(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await getProjectModel(projectId || '')
|
|
||||||
|
|
||||||
applySceneGraphToEditor(result.success ? result.data?.model?.scene_graph ?? null : null)
|
|
||||||
} catch (error) {
|
|
||||||
// Fall back to an empty scene while preserving editor selection sync.
|
|
||||||
applySceneGraphToEditor(null)
|
|
||||||
} finally {
|
|
||||||
useProjectStore.getState().setIsSceneLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow auto-save again after a tick (let the store update propagate)
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
isLoadingSceneRef.current = false
|
|
||||||
setAutosaveStatus('saved')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
loadScene()
|
|
||||||
}, [projectId, isLoadingProject, setAutosaveStatus])
|
|
||||||
|
|
||||||
// Track whether there are unsaved changes (dirty flag for flush-on-exit).
|
|
||||||
const hasDirtyChangesRef = useRef(false)
|
|
||||||
|
|
||||||
// Auto-save scene changes with debouncing
|
|
||||||
useEffect(() => {
|
|
||||||
if (!projectId) {
|
|
||||||
currentProjectIdRef.current = null
|
|
||||||
executeSaveRef.current = null
|
|
||||||
setAutosaveStatus('idle')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
currentProjectIdRef.current = projectId
|
|
||||||
|
|
||||||
// Use JSON stringification to detect node changes, not just count
|
|
||||||
let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes)
|
|
||||||
|
|
||||||
const unsubscribe = useScene.subscribe((state) => {
|
|
||||||
// Skip saves triggered by loading a scene from the server
|
|
||||||
if (isLoadingSceneRef.current) {
|
|
||||||
// Update the snapshot so the next real edit is compared correctly
|
|
||||||
lastNodesSnapshot = JSON.stringify(state.nodes)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (useProjectStore.getState().isVersionPreviewMode) {
|
|
||||||
// Do not autosave preview scenes. Keep snapshot aligned so returning to
|
|
||||||
// latest does not schedule a false-positive save.
|
|
||||||
setAutosaveStatus('paused')
|
|
||||||
lastNodesSnapshot = JSON.stringify(state.nodes)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentNodesSnapshot = JSON.stringify(state.nodes)
|
|
||||||
|
|
||||||
// Only trigger save if nodes actually changed
|
|
||||||
if (currentNodesSnapshot === lastNodesSnapshot) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
lastNodesSnapshot = currentNodesSnapshot
|
|
||||||
hasDirtyChangesRef.current = true
|
|
||||||
setAutosaveStatus('pending')
|
|
||||||
|
|
||||||
// If a save is in-flight, mark pending so we do one follow-up save
|
|
||||||
// instead of queuing unlimited concurrent saves.
|
|
||||||
if (isSavingRef.current) {
|
|
||||||
pendingSaveRef.current = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear existing timeout (debounce reset)
|
|
||||||
if (saveTimeoutRef.current) {
|
|
||||||
clearTimeout(saveTimeoutRef.current)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debounce save
|
|
||||||
saveTimeoutRef.current = setTimeout(() => {
|
|
||||||
saveTimeoutRef.current = undefined
|
|
||||||
executeSave()
|
|
||||||
}, AUTOSAVE_DEBOUNCE_MS)
|
|
||||||
})
|
|
||||||
|
|
||||||
async function executeSave() {
|
|
||||||
const currentProjectId = currentProjectIdRef.current
|
|
||||||
if (!currentProjectId) return
|
|
||||||
|
|
||||||
if (isLoadingSceneRef.current || useProjectStore.getState().isVersionPreviewMode) {
|
|
||||||
// Save is paused while previewing older versions.
|
|
||||||
pendingSaveRef.current = true
|
|
||||||
setAutosaveStatus('paused')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const { nodes, rootNodeIds } = useScene.getState()
|
|
||||||
const sceneGraph = { nodes, rootNodeIds }
|
|
||||||
|
|
||||||
isSavingRef.current = true
|
|
||||||
pendingSaveRef.current = false
|
|
||||||
setAutosaveStatus('saving')
|
|
||||||
|
|
||||||
try {
|
|
||||||
await saveProjectModel(currentProjectId, sceneGraph)
|
|
||||||
hasDirtyChangesRef.current = false
|
|
||||||
setAutosaveStatus('saved')
|
|
||||||
} finally {
|
|
||||||
isSavingRef.current = false
|
|
||||||
|
|
||||||
// If changes arrived while we were saving, schedule one more save
|
|
||||||
if (pendingSaveRef.current) {
|
|
||||||
pendingSaveRef.current = false
|
|
||||||
setAutosaveStatus('pending')
|
|
||||||
saveTimeoutRef.current = setTimeout(() => {
|
|
||||||
saveTimeoutRef.current = undefined
|
|
||||||
executeSave()
|
|
||||||
}, AUTOSAVE_DEBOUNCE_MS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
executeSaveRef.current = executeSave
|
|
||||||
|
|
||||||
// Flush unsaved changes when the user leaves the page / closes the tab.
|
|
||||||
// Uses sendBeacon via keepalive fetch so the request survives page unload.
|
|
||||||
function flushOnExit() {
|
|
||||||
if (!hasDirtyChangesRef.current || !currentProjectIdRef.current) return
|
|
||||||
|
|
||||||
const { nodes, rootNodeIds } = useScene.getState()
|
|
||||||
const sceneGraph = { nodes, rootNodeIds }
|
|
||||||
|
|
||||||
// Best-effort fire-and-forget save. We use the server action directly
|
|
||||||
// (it's just a POST to a Next.js endpoint). If the browser kills it,
|
|
||||||
// localStorage still has the data and will sync on next load.
|
|
||||||
saveProjectModel(currentProjectIdRef.current, sceneGraph).catch(() => {
|
|
||||||
// Swallow — nothing we can do during unload
|
|
||||||
})
|
|
||||||
hasDirtyChangesRef.current = false
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('beforeunload', flushOnExit)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
executeSaveRef.current = null
|
|
||||||
window.removeEventListener('beforeunload', flushOnExit)
|
|
||||||
|
|
||||||
if (saveTimeoutRef.current) {
|
|
||||||
clearTimeout(saveTimeoutRef.current)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flush on unmount (e.g. navigating away within the SPA)
|
|
||||||
flushOnExit()
|
|
||||||
|
|
||||||
unsubscribe()
|
|
||||||
}
|
|
||||||
}, [projectId, setAutosaveStatus])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!projectId) return
|
|
||||||
|
|
||||||
if (isVersionPreviewMode) {
|
|
||||||
if (saveTimeoutRef.current) {
|
|
||||||
clearTimeout(saveTimeoutRef.current)
|
|
||||||
saveTimeoutRef.current = undefined
|
|
||||||
}
|
|
||||||
if (hasDirtyChangesRef.current) {
|
|
||||||
pendingSaveRef.current = true
|
|
||||||
}
|
|
||||||
setAutosaveStatus('paused')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSavingRef.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasDirtyChangesRef.current) {
|
|
||||||
setAutosaveStatus('pending')
|
|
||||||
if (!saveTimeoutRef.current) {
|
|
||||||
saveTimeoutRef.current = setTimeout(() => {
|
|
||||||
saveTimeoutRef.current = undefined
|
|
||||||
executeSaveRef.current?.()
|
|
||||||
}, AUTOSAVE_DEBOUNCE_MS)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setAutosaveStatus('saved')
|
|
||||||
}, [isVersionPreviewMode, projectId, setAutosaveStatus])
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import type { SceneGraph } from './actions'
|
|
||||||
|
|
||||||
const DEFAULT_NODE_TYPES = ['site', 'building', 'level']
|
|
||||||
|
|
||||||
export function isSceneGraphEmpty(sceneGraph: SceneGraph | any): boolean {
|
|
||||||
if (!sceneGraph?.nodes) return true
|
|
||||||
|
|
||||||
const nodes = Object.values(sceneGraph.nodes) as any[]
|
|
||||||
|
|
||||||
if (nodes.length > 3) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasNonDefaultNodes = nodes.some((n) => !DEFAULT_NODE_TYPES.includes(n.type))
|
|
||||||
if (hasNonDefaultNodes) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const levelNode = nodes.find((n) => n.type === 'level')
|
|
||||||
if (Array.isArray(levelNode?.children) && levelNode.children.length > 0) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,122 +0,0 @@
|
|||||||
/**
|
|
||||||
* Project store - Zustand store for project state management
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { create } from 'zustand'
|
|
||||||
import type { Project } from './types'
|
|
||||||
import {
|
|
||||||
getActiveProject,
|
|
||||||
getUserProjects,
|
|
||||||
getProjectById,
|
|
||||||
} from './actions'
|
|
||||||
|
|
||||||
interface ProjectStore {
|
|
||||||
// Autosave lifecycle for the latest draft scene
|
|
||||||
autosaveStatus: 'idle' | 'pending' | 'saving' | 'saved' | 'paused'
|
|
||||||
|
|
||||||
// State
|
|
||||||
activeProject: Project | null
|
|
||||||
projects: Project[]
|
|
||||||
isLoading: boolean
|
|
||||||
isSceneLoading: boolean
|
|
||||||
isVersionPreviewMode: boolean
|
|
||||||
error: string | null
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
fetchProjects: () => Promise<void>
|
|
||||||
fetchActiveProject: () => Promise<void>
|
|
||||||
setActiveProject: (projectId: string) => Promise<void>
|
|
||||||
setIsSceneLoading: (loading: boolean) => void
|
|
||||||
setIsVersionPreviewMode: (preview: boolean) => void
|
|
||||||
setAutosaveStatus: (status: ProjectStore['autosaveStatus']) => void
|
|
||||||
initialize: () => Promise<void>
|
|
||||||
updateActiveThumbnail: (thumbnailUrl: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useProjectStore = create<ProjectStore>((set, get) => ({
|
|
||||||
// Initial state
|
|
||||||
autosaveStatus: 'idle',
|
|
||||||
activeProject: null,
|
|
||||||
projects: [],
|
|
||||||
isLoading: true,
|
|
||||||
isSceneLoading: false,
|
|
||||||
isVersionPreviewMode: false,
|
|
||||||
error: null,
|
|
||||||
|
|
||||||
// Fetch all projects
|
|
||||||
fetchProjects: async () => {
|
|
||||||
const result = await getUserProjects()
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
set({ projects: result.data || [], error: null })
|
|
||||||
} else {
|
|
||||||
set({ error: result.error || 'Failed to fetch projects', projects: [] })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Fetch the active project from database
|
|
||||||
fetchActiveProject: async () => {
|
|
||||||
set({ isLoading: true })
|
|
||||||
|
|
||||||
const result = await getActiveProject()
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
set({
|
|
||||||
activeProject: result.data || null,
|
|
||||||
isLoading: false,
|
|
||||||
error: null
|
|
||||||
})
|
|
||||||
// Note: Auto-select logic removed - now using URL-based routing
|
|
||||||
// The URL parameter determines which project to load
|
|
||||||
} else {
|
|
||||||
set({
|
|
||||||
error: result.error || 'Failed to fetch active project',
|
|
||||||
activeProject: null,
|
|
||||||
isLoading: false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Set active project by fetching it directly by ID (URL-based, no session update)
|
|
||||||
setActiveProject: async (projectId: string) => {
|
|
||||||
set({ isLoading: true })
|
|
||||||
|
|
||||||
const result = await getProjectById(projectId)
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
set({ activeProject: result.data, isLoading: false, error: null })
|
|
||||||
} else {
|
|
||||||
set({ isLoading: false, error: result.error || 'Project not found' })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
setIsSceneLoading: (loading: boolean) => {
|
|
||||||
set({ isSceneLoading: loading })
|
|
||||||
},
|
|
||||||
|
|
||||||
setIsVersionPreviewMode: (preview: boolean) => {
|
|
||||||
set({ isVersionPreviewMode: preview })
|
|
||||||
},
|
|
||||||
|
|
||||||
setAutosaveStatus: (status) => {
|
|
||||||
set({ autosaveStatus: status })
|
|
||||||
},
|
|
||||||
|
|
||||||
// Patch the active project's thumbnail URL in place (no refetch)
|
|
||||||
updateActiveThumbnail: (thumbnailUrl: string) => {
|
|
||||||
set((state) => ({
|
|
||||||
activeProject: state.activeProject
|
|
||||||
? { ...state.activeProject, thumbnail_url: thumbnailUrl }
|
|
||||||
: null,
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
// Initialize - fetch both projects and active project
|
|
||||||
initialize: async () => {
|
|
||||||
set({ isLoading: true })
|
|
||||||
await Promise.all([
|
|
||||||
get().fetchProjects(),
|
|
||||||
get().fetchActiveProject(),
|
|
||||||
])
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
/**
|
|
||||||
* Project-related type definitions
|
|
||||||
* Isolated from monorepo database schema
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Database table row types
|
|
||||||
export type DbProject = {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
owner_id: string
|
|
||||||
organization_id: string | null
|
|
||||||
address_id: string | null
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
is_private: boolean
|
|
||||||
is_empty: boolean
|
|
||||||
show_scans_public: boolean
|
|
||||||
show_guides_public: boolean
|
|
||||||
views: number
|
|
||||||
likes: number
|
|
||||||
thumbnail_url: string | null
|
|
||||||
published_model_version: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DbProjectAddress = {
|
|
||||||
id: string
|
|
||||||
street_number?: string
|
|
||||||
route?: string
|
|
||||||
city?: string
|
|
||||||
state?: string
|
|
||||||
postal_code?: string
|
|
||||||
country?: string
|
|
||||||
latitude?: string
|
|
||||||
longitude?: string
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DbProjectModel = {
|
|
||||||
id: string
|
|
||||||
project_id: string
|
|
||||||
version: number
|
|
||||||
scene_graph: any
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
deleted_at: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DbProjectLike = {
|
|
||||||
id: string
|
|
||||||
project_id: string
|
|
||||||
user_id: string
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Database schema type for Supabase
|
|
||||||
export type Database = {
|
|
||||||
public: {
|
|
||||||
Tables: {
|
|
||||||
projects: {
|
|
||||||
Row: DbProject
|
|
||||||
Insert: Omit<DbProject, 'created_at' | 'updated_at' | 'views' | 'likes' | 'show_scans_public' | 'show_guides_public' | 'published_model_version'> & { show_scans_public?: boolean; show_guides_public?: boolean; published_model_version?: number | null }
|
|
||||||
Update: Partial<Omit<DbProject, 'id' | 'created_at' | 'updated_at'>>
|
|
||||||
}
|
|
||||||
projects_addresses: {
|
|
||||||
Row: DbProjectAddress
|
|
||||||
Insert: Omit<DbProjectAddress, 'created_at' | 'updated_at'>
|
|
||||||
Update: Partial<Omit<DbProjectAddress, 'id' | 'created_at' | 'updated_at'>>
|
|
||||||
}
|
|
||||||
projects_models: {
|
|
||||||
Row: DbProjectModel
|
|
||||||
Insert: Omit<DbProjectModel, 'created_at' | 'updated_at' | 'deleted_at'>
|
|
||||||
Update: Partial<Omit<DbProjectModel, 'id' | 'created_at' | 'updated_at'>>
|
|
||||||
}
|
|
||||||
projects_likes: {
|
|
||||||
Row: DbProjectLike
|
|
||||||
Insert: Omit<DbProjectLike, 'created_at'>
|
|
||||||
Update: Partial<Omit<DbProjectLike, 'id' | 'created_at'>>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Functions: {
|
|
||||||
increment_project_views: {
|
|
||||||
Args: { project_id: string }
|
|
||||||
Returns: undefined
|
|
||||||
}
|
|
||||||
get_project_like_count: {
|
|
||||||
Args: { project_id: string }
|
|
||||||
Returns: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ProjectOwner = {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
username: string | null
|
|
||||||
image: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Project = {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
owner_id: string
|
|
||||||
organization_id: string | null
|
|
||||||
address_id: string | null
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
// Community features
|
|
||||||
is_private: boolean
|
|
||||||
is_empty: boolean
|
|
||||||
show_scans_public: boolean
|
|
||||||
show_guides_public: boolean
|
|
||||||
views: number
|
|
||||||
likes: number
|
|
||||||
thumbnail_url: string | null
|
|
||||||
published_model_version: number | null
|
|
||||||
address: {
|
|
||||||
id: string
|
|
||||||
street_number?: string
|
|
||||||
route?: string
|
|
||||||
city?: string
|
|
||||||
state?: string
|
|
||||||
postal_code?: string
|
|
||||||
country?: string
|
|
||||||
latitude?: string
|
|
||||||
longitude?: string
|
|
||||||
} | null
|
|
||||||
owner?: ProjectOwner | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CreateProjectParams = {
|
|
||||||
name: string
|
|
||||||
center?: [number, number]
|
|
||||||
streetNumber?: string
|
|
||||||
route?: string
|
|
||||||
routeShort?: string
|
|
||||||
neighborhood?: string
|
|
||||||
city?: string
|
|
||||||
county?: string
|
|
||||||
state?: string
|
|
||||||
stateLong?: string
|
|
||||||
postalCode?: string
|
|
||||||
postalCodeSuffix?: string
|
|
||||||
country?: string
|
|
||||||
countryLong?: string
|
|
||||||
rawJson?: Record<string, unknown>
|
|
||||||
isPrivate?: boolean
|
|
||||||
sceneGraph?: any
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { customAlphabet } from 'nanoid'
|
|
||||||
|
|
||||||
const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
|
||||||
const nanoid = customAlphabet(alphabet, 16)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a unique ID with optional prefix (matches monorepo implementation)
|
|
||||||
* @example createId('user') => 'user_Abc123...'
|
|
||||||
*/
|
|
||||||
export const createId = (prefix?: string) => {
|
|
||||||
const id = nanoid()
|
|
||||||
return prefix ? `${prefix}_${id}` : id
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
export async function register() {
|
|
||||||
// Only run on the server
|
|
||||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
|
||||||
const { createClient } = await import('@supabase/supabase-js')
|
|
||||||
|
|
||||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL
|
|
||||||
const key = process.env.SUPABASE_SERVICE_ROLE_KEY
|
|
||||||
if (!url || !key) return
|
|
||||||
|
|
||||||
const supabase = createClient(url, key)
|
|
||||||
|
|
||||||
const { data: buckets } = await supabase.storage.listBuckets()
|
|
||||||
const bucketNames = new Set(buckets?.map((b) => b.name))
|
|
||||||
|
|
||||||
if (!bucketNames.has('avatars')) {
|
|
||||||
await supabase.storage.createBucket('avatars', {
|
|
||||||
public: true,
|
|
||||||
fileSizeLimit: 5 * 1024 * 1024, // 5MB
|
|
||||||
allowedMimeTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
|
|
||||||
})
|
|
||||||
console.log('Created "avatars" storage bucket')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!bucketNames.has('project-thumbnails')) {
|
|
||||||
await supabase.storage.createBucket('project-thumbnails', {
|
|
||||||
public: true,
|
|
||||||
fileSizeLimit: 10 * 1024 * 1024, // 10MB (matches uploadProjectThumbnail validation)
|
|
||||||
allowedMimeTypes: ['image/png'],
|
|
||||||
})
|
|
||||||
console.log('Created "project-thumbnails" storage bucket')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!bucketNames.has('project-assets')) {
|
|
||||||
await supabase.storage.createBucket('project-assets', {
|
|
||||||
public: true,
|
|
||||||
fileSizeLimit: 500 * 1024 * 1024, // 500MB for GLB/GLTF scans
|
|
||||||
})
|
|
||||||
console.log('Created "project-assets" storage bucket')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!bucketNames.has('preset-thumbnails')) {
|
|
||||||
await supabase.storage.createBucket('preset-thumbnails', {
|
|
||||||
public: true,
|
|
||||||
fileSizeLimit: 5 * 1024 * 1024, // 5MB
|
|
||||||
allowedMimeTypes: ['image/png', 'image/jpeg', 'image/webp'],
|
|
||||||
})
|
|
||||||
console.log('Created "preset-thumbnails" storage bucket')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { createAuth } from '@pascal-app/auth/server'
|
|
||||||
import { db } from '@pascal-app/db'
|
|
||||||
import { Resend } from 'resend'
|
|
||||||
import { env } from '@/env.mjs'
|
|
||||||
import { BASE_URL } from './utils'
|
|
||||||
|
|
||||||
// Initialize Resend only if API key is available
|
|
||||||
const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null
|
|
||||||
|
|
||||||
export const auth = createAuth({
|
|
||||||
db,
|
|
||||||
appName: 'Pascal Editor',
|
|
||||||
baseURL: BASE_URL,
|
|
||||||
secret: env.BETTER_AUTH_SECRET,
|
|
||||||
googleClientId: env.GOOGLE_CLIENT_ID,
|
|
||||||
googleClientSecret: env.GOOGLE_CLIENT_SECRET,
|
|
||||||
sendMagicLink: async ({ email, url }) => {
|
|
||||||
if (!resend) {
|
|
||||||
console.log(`[DEV] Magic link for ${email}: ${url}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await resend.emails.send({
|
|
||||||
from: 'Pascal <noreply@pascal.app>',
|
|
||||||
to: email,
|
|
||||||
subject: 'Sign in to Pascal Editor',
|
|
||||||
html: `
|
|
||||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
|
||||||
<h2>Sign in to Pascal Editor</h2>
|
|
||||||
<p>Click the button below to sign in to your account:</p>
|
|
||||||
<a href="${url}" style="display: inline-block; background-color: #000; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 6px; margin: 20px 0;">
|
|
||||||
Sign In
|
|
||||||
</a>
|
|
||||||
<p style="color: #666; font-size: 14px;">This link will expire in 5 minutes.</p>
|
|
||||||
<p style="color: #666; font-size: 14px;">If you didn't request this email, you can safely ignore it.</p>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
console.log(`✓ Magic link email sent to ${email}`)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to send magic link email:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export type Session = typeof auth.$Infer.Session
|
|
||||||
export type User = typeof auth.$Infer.Session.user
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
/**
|
|
||||||
* Navigation helpers for project-based routing
|
|
||||||
*/
|
|
||||||
|
|
||||||
export function getEditorUrl(projectId: string): string {
|
|
||||||
return `/editor/${projectId}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getViewerUrl(projectId: string): string {
|
|
||||||
return `/viewer/${projectId}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getHomeUrl(): string {
|
|
||||||
return '/'
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { createClient } from '@supabase/supabase-js'
|
|
||||||
import type { SupabaseDatabase } from '@pascal-app/db'
|
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
|
|
||||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Supabase client for client-side use with anon key
|
|
||||||
* Uses Row Level Security (RLS) policies
|
|
||||||
*/
|
|
||||||
export const supabase = createClient<SupabaseDatabase>(supabaseUrl, supabaseAnonKey)
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { createClient } from '@supabase/supabase-js'
|
|
||||||
import type { SupabaseDatabase } from '@pascal-app/db'
|
|
||||||
import { env } from '@/env.mjs'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safety check: warn loudly if a Vercel preview deployment is using
|
|
||||||
* the production Supabase instance. This catches misconfigured branching.
|
|
||||||
*/
|
|
||||||
if (
|
|
||||||
process.env.VERCEL_ENV === 'preview' &&
|
|
||||||
process.env.SUPABASE_URL &&
|
|
||||||
env.NEXT_PUBLIC_SUPABASE_URL === process.env.SUPABASE_URL
|
|
||||||
) {
|
|
||||||
// If the Supabase integration set a branch-specific SUPABASE_URL,
|
|
||||||
// it should differ from NEXT_PUBLIC_SUPABASE_URL (which comes from the
|
|
||||||
// generic env vars pointing at production). When they match, the
|
|
||||||
// integration likely skipped branch creation for this PR.
|
|
||||||
console.warn(
|
|
||||||
'⚠️ [supabase] Preview deployment appears to be using the PRODUCTION ' +
|
|
||||||
'Supabase instance. Supabase branching may not be configured for this PR. ' +
|
|
||||||
'See: https://supabase.com/docs/guides/deployment/branching',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Supabase client for server-side use with service role key
|
|
||||||
* Bypasses Row Level Security (RLS) - use with caution
|
|
||||||
* Always filter by user_id to enforce permissions
|
|
||||||
*/
|
|
||||||
export const supabaseAdmin = createClient<SupabaseDatabase>(
|
|
||||||
env.NEXT_PUBLIC_SUPABASE_URL,
|
|
||||||
env.SUPABASE_SERVICE_ROLE_KEY,
|
|
||||||
{
|
|
||||||
auth: {
|
|
||||||
persistSession: false,
|
|
||||||
autoRefreshToken: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import {
|
|
||||||
type AnyNodeId,
|
|
||||||
ScanNode as ScanNodeSchema,
|
|
||||||
GuideNode as GuideNodeSchema,
|
|
||||||
useScene,
|
|
||||||
} from '@pascal-app/core'
|
|
||||||
import {
|
|
||||||
createAssetUploadUrl,
|
|
||||||
confirmAssetUpload,
|
|
||||||
type AssetType,
|
|
||||||
} from '@/features/community/lib/assets/actions'
|
|
||||||
import { useUploadStore } from '@/store/use-upload'
|
|
||||||
import useEditor from '@/store/use-editor'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload a file directly to Supabase Storage via signed URL with progress tracking.
|
|
||||||
* Runs entirely outside React — survives component unmounts.
|
|
||||||
*/
|
|
||||||
export function uploadAssetWithProgress(
|
|
||||||
projectId: string,
|
|
||||||
levelId: string,
|
|
||||||
file: File,
|
|
||||||
assetType: AssetType,
|
|
||||||
) {
|
|
||||||
const store = useUploadStore.getState()
|
|
||||||
store.startUpload(levelId, assetType, file.name)
|
|
||||||
|
|
||||||
// Run async work without blocking the caller
|
|
||||||
doUpload(projectId, levelId, file, assetType).catch(() => {
|
|
||||||
// errors are already recorded in the store by doUpload
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doUpload(
|
|
||||||
projectId: string,
|
|
||||||
levelId: string,
|
|
||||||
file: File,
|
|
||||||
assetType: AssetType,
|
|
||||||
) {
|
|
||||||
const store = () => useUploadStore.getState()
|
|
||||||
|
|
||||||
// Phase 1: Get signed URL
|
|
||||||
const urlResult = await createAssetUploadUrl(
|
|
||||||
projectId,
|
|
||||||
file.name,
|
|
||||||
file.type || 'application/octet-stream',
|
|
||||||
assetType,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!urlResult.success) {
|
|
||||||
store().setError(levelId, urlResult.error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: Upload directly to Supabase via XHR (for progress)
|
|
||||||
store().setStatus(levelId, 'uploading')
|
|
||||||
|
|
||||||
try {
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
const xhr = new XMLHttpRequest()
|
|
||||||
|
|
||||||
xhr.upload.addEventListener('progress', (e) => {
|
|
||||||
if (e.lengthComputable) {
|
|
||||||
const pct = Math.round((e.loaded / e.total) * 100)
|
|
||||||
useUploadStore.getState().setProgress(levelId, pct)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
xhr.addEventListener('load', () => {
|
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
|
||||||
resolve()
|
|
||||||
} else {
|
|
||||||
reject(new Error(`Upload failed: HTTP ${xhr.status}`))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
xhr.addEventListener('error', () => reject(new Error('Network error during upload')))
|
|
||||||
xhr.addEventListener('abort', () => reject(new Error('Upload aborted')))
|
|
||||||
|
|
||||||
xhr.open('PUT', urlResult.signedUrl)
|
|
||||||
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')
|
|
||||||
xhr.send(file)
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : 'Upload failed'
|
|
||||||
store().setError(levelId, msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 3: Confirm upload and record in DB
|
|
||||||
store().setStatus(levelId, 'confirming')
|
|
||||||
|
|
||||||
const confirmResult = await confirmAssetUpload(
|
|
||||||
projectId,
|
|
||||||
urlResult.assetId,
|
|
||||||
urlResult.storageKey,
|
|
||||||
file.name,
|
|
||||||
file.type || null,
|
|
||||||
assetType,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!confirmResult.success) {
|
|
||||||
store().setError(levelId, confirmResult.error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 4: Create scene node (works even if component is unmounted)
|
|
||||||
const Schema = assetType === 'scan' ? ScanNodeSchema : GuideNodeSchema
|
|
||||||
const node = Schema.parse({
|
|
||||||
url: confirmResult.url,
|
|
||||||
name: file.name,
|
|
||||||
parentId: levelId,
|
|
||||||
})
|
|
||||||
useScene.getState().createNode(node, levelId as AnyNodeId)
|
|
||||||
useEditor.getState().setSelectedReferenceId(node.id)
|
|
||||||
|
|
||||||
store().setResult(levelId, confirmResult.url)
|
|
||||||
|
|
||||||
// Auto-clear after a short delay so the UI shows "done" briefly
|
|
||||||
setTimeout(() => {
|
|
||||||
useUploadStore.getState().clearUpload(levelId)
|
|
||||||
}, 1500)
|
|
||||||
}
|
|
||||||
@@ -6,8 +6,7 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const isDevelopment =
|
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'
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+342
-1699
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": "@repo/typescript-config/nextjs.json",
|
"extends": "@pascal/typescript-config/nextjs.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
|
|||||||
+3
-10
@@ -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/*"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
# @pascal-app/auth
|
|
||||||
|
|
||||||
Authentication package for Pascal Editor using Better Auth.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Magic Link Authentication** - Passwordless email-based authentication
|
|
||||||
- **Session Management** - Secure cookie-based sessions
|
|
||||||
- **Supabase Integration** - Uses Supabase as the database adapter
|
|
||||||
- **Type-safe** - Full TypeScript support with type inference
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
### 1. Configure environment variables
|
|
||||||
|
|
||||||
Add these to `apps/editor/.env.local`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Better Auth
|
|
||||||
BETTER_AUTH_SECRET=<generate_with_openssl_rand_base64_32>
|
|
||||||
BETTER_AUTH_URL=http://localhost:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
Generate a secret for `BETTER_AUTH_SECRET`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
openssl rand -base64 32
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Ensure database is running
|
|
||||||
|
|
||||||
Make sure you have Supabase running with the auth tables created. See `@pascal-app/db` package for setup.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Server-side (API routes, server actions)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { auth } from '@pascal-app/auth/server'
|
|
||||||
|
|
||||||
// Get session in server component or action
|
|
||||||
const session = await auth.api.getSession({ headers: request.headers })
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return { error: 'Unauthorized' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Access user data
|
|
||||||
const userId = session.user.id
|
|
||||||
const email = session.user.email
|
|
||||||
```
|
|
||||||
|
|
||||||
### Client-side (React components)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { authClient } from '@pascal-app/auth/client'
|
|
||||||
|
|
||||||
function SignInButton() {
|
|
||||||
const { signIn } = authClient
|
|
||||||
|
|
||||||
const handleSignIn = async (email: string) => {
|
|
||||||
await signIn.magicLink({
|
|
||||||
email,
|
|
||||||
callbackURL: '/dashboard',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return <button onClick={() => handleSignIn('user@example.com')}>Sign In</button>
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using the auth hook
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { authClient } from '@pascal-app/auth/client'
|
|
||||||
|
|
||||||
function Profile() {
|
|
||||||
const { data: session, isPending } = authClient.useSession()
|
|
||||||
|
|
||||||
if (isPending) return <div>Loading...</div>
|
|
||||||
if (!session) return <div>Not signed in</div>
|
|
||||||
|
|
||||||
return <div>Signed in as {session.user.email}</div>
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Routes
|
|
||||||
|
|
||||||
The auth package requires an API route handler in your Next.js app:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// app/api/auth/[...all]/route.ts
|
|
||||||
import { auth } from '@pascal-app/auth/server'
|
|
||||||
import { toNextJsHandler } from 'better-auth/next-js'
|
|
||||||
|
|
||||||
export const { GET, POST } = toNextJsHandler(auth)
|
|
||||||
```
|
|
||||||
|
|
||||||
This handles all Better Auth endpoints:
|
|
||||||
- `/api/auth/sign-in/magic-link` - Send magic link
|
|
||||||
- `/api/auth/sign-in/magic-link/verify` - Verify magic link
|
|
||||||
- `/api/auth/sign-out` - Sign out
|
|
||||||
- `/api/auth/session` - Get session
|
|
||||||
- And more...
|
|
||||||
|
|
||||||
## Email Configuration
|
|
||||||
|
|
||||||
By default, magic links are logged to the console. To send actual emails, you'll need to configure an email provider in `packages/auth/src/server.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
magicLink({
|
|
||||||
sendMagicLink: async ({ email, url }) => {
|
|
||||||
// Use Resend, SendGrid, or your preferred email service
|
|
||||||
await sendEmail({
|
|
||||||
to: email,
|
|
||||||
subject: 'Sign in to Pascal Editor',
|
|
||||||
html: `Click here to sign in: <a href="${url}">${url}</a>`,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## Database Schema
|
|
||||||
|
|
||||||
The auth package requires these tables (created by `@pascal-app/db` migrations):
|
|
||||||
|
|
||||||
- `users` - User accounts
|
|
||||||
- `sessions` - Active sessions
|
|
||||||
- `accounts` - OAuth provider accounts (for future use)
|
|
||||||
- `verification_tokens` - Magic link tokens
|
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
- Session cookies are httpOnly and secure (in production)
|
|
||||||
- Sessions expire after 7 days
|
|
||||||
- Session cache is enabled for 5 minutes to reduce database queries
|
|
||||||
- Magic link tokens expire after 15 minutes
|
|
||||||
- All sensitive operations require valid session tokens
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@pascal-app/auth",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"exports": {
|
|
||||||
"./server": {
|
|
||||||
"types": "./src/server.ts",
|
|
||||||
"default": "./src/server.ts"
|
|
||||||
},
|
|
||||||
"./client": {
|
|
||||||
"types": "./src/client.ts",
|
|
||||||
"default": "./src/client.ts"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@pascal-app/db": "*",
|
|
||||||
"better-auth": "^1.5.2",
|
|
||||||
"resend": "^4.0.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@repo/typescript-config": "*",
|
|
||||||
"typescript": "5.9.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { lastLoginMethodClient, magicLinkClient } from 'better-auth/client/plugins'
|
|
||||||
import { createAuthClient } from 'better-auth/react'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the auth base URL
|
|
||||||
* In development: use the editor URL (localhost:3000)
|
|
||||||
* In production: use the same origin
|
|
||||||
*/
|
|
||||||
function getAuthURL(): string {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
return window.location.origin
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSR fallback - detect environment from Vercel variables
|
|
||||||
const isDevelopment =
|
|
||||||
process.env.NODE_ENV === 'development' ||
|
|
||||||
process.env.NEXT_PUBLIC_VERCEL_ENV === 'development'
|
|
||||||
const isPreview = process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview'
|
|
||||||
const isProduction =
|
|
||||||
process.env.NODE_ENV === 'production' || process.env.NEXT_PUBLIC_VERCEL_ENV === 'production'
|
|
||||||
|
|
||||||
if (isDevelopment) {
|
|
||||||
return process.env.NEXT_PUBLIC_APP_URL || `http://localhost:${process.env.PORT || 3000}`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPreview && process.env.NEXT_PUBLIC_VERCEL_URL) {
|
|
||||||
return `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isProduction) {
|
|
||||||
return (
|
|
||||||
process.env.NEXT_PUBLIC_APP_URL ||
|
|
||||||
(process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL
|
|
||||||
? `https://${process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL}`
|
|
||||||
: 'https://editor.pascal.app')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'http://localhost:3000'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auth client instance
|
|
||||||
* Configured for magic link authentication
|
|
||||||
*/
|
|
||||||
export const authClient = createAuthClient({
|
|
||||||
baseURL: getAuthURL(),
|
|
||||||
plugins: [magicLinkClient(), lastLoginMethodClient()],
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Export types for use in components
|
|
||||||
*/
|
|
||||||
export type AuthState = {
|
|
||||||
user: (typeof authClient)['$Infer']['Session']['user'] | null
|
|
||||||
session: (typeof authClient)['$Infer']['Session']['session'] | null
|
|
||||||
isLoading: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type User = NonNullable<AuthState['user']>
|
|
||||||
export type Session = NonNullable<AuthState['session']>
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import type { Database } from '@pascal-app/db'
|
|
||||||
import { schema } from '@pascal-app/db'
|
|
||||||
import type { BetterAuthOptions } from 'better-auth'
|
|
||||||
import { betterAuth } from 'better-auth'
|
|
||||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
|
||||||
import { lastLoginMethod, magicLink } from 'better-auth/plugins'
|
|
||||||
|
|
||||||
export interface SendMagicLinkParams {
|
|
||||||
email: string
|
|
||||||
url: string
|
|
||||||
token: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthConfig {
|
|
||||||
db: Database
|
|
||||||
appName: string
|
|
||||||
baseURL: string
|
|
||||||
secret: string
|
|
||||||
/** Google OAuth client ID */
|
|
||||||
googleClientId?: string
|
|
||||||
/** Google OAuth client secret */
|
|
||||||
googleClientSecret?: string
|
|
||||||
/** Callback to send magic link emails */
|
|
||||||
sendMagicLink?: (params: SendMagicLinkParams) => Promise<void>
|
|
||||||
/** Additional plugins to add (e.g., nextCookies for web) */
|
|
||||||
additionalPlugins?: BetterAuthOptions['plugins']
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a Better Auth instance with full configuration including:
|
|
||||||
* - Magic link authentication
|
|
||||||
* - Custom session with activePropertyId
|
|
||||||
* - Session cookie caching
|
|
||||||
*/
|
|
||||||
export function createAuth(config: AuthConfig) {
|
|
||||||
return betterAuth({
|
|
||||||
appName: config.appName,
|
|
||||||
baseURL: config.baseURL,
|
|
||||||
secret: config.secret,
|
|
||||||
basePath: '/api/auth',
|
|
||||||
database: drizzleAdapter(config.db, {
|
|
||||||
provider: 'pg',
|
|
||||||
usePlural: true,
|
|
||||||
schema,
|
|
||||||
}),
|
|
||||||
advanced: {
|
|
||||||
database: {
|
|
||||||
generateId: false, // Use our prefixed nanoid IDs from schema
|
|
||||||
},
|
|
||||||
},
|
|
||||||
session: {
|
|
||||||
// Session caching to reduce database queries
|
|
||||||
cookieCache: {
|
|
||||||
enabled: true,
|
|
||||||
maxAge: 5 * 60, // Cache duration in seconds (5 minutes)
|
|
||||||
},
|
|
||||||
additionalFields: {
|
|
||||||
// Additional fields for the session table
|
|
||||||
activePropertyId: {
|
|
||||||
type: 'string',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Account linking — always enabled so magic link + Google users can share accounts
|
|
||||||
account: {
|
|
||||||
accountLinking: {
|
|
||||||
enabled: true,
|
|
||||||
trustedProviders: ['google', 'email'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Google OAuth provider (only enabled when credentials are provided)
|
|
||||||
...(config.googleClientId &&
|
|
||||||
config.googleClientSecret && {
|
|
||||||
socialProviders: {
|
|
||||||
google: {
|
|
||||||
clientId: config.googleClientId,
|
|
||||||
clientSecret: config.googleClientSecret,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
plugins: [
|
|
||||||
...(config.additionalPlugins ?? []),
|
|
||||||
// Track which login method was last used (e.g., "google", "magic-link")
|
|
||||||
lastLoginMethod(),
|
|
||||||
// Magic link authentication
|
|
||||||
...(config.sendMagicLink
|
|
||||||
? [
|
|
||||||
magicLink({
|
|
||||||
sendMagicLink: config.sendMagicLink,
|
|
||||||
expiresIn: 300, // 5 minutes
|
|
||||||
disableSignUp: false, // Allow new users to sign up via magic link
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Auth = ReturnType<typeof createAuth>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "@repo/typescript-config/base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src"
|
|
||||||
},
|
|
||||||
"include": ["src"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
"zustand": "^5"
|
"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"
|
||||||
|
|||||||
@@ -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(
|
||||||
|
corners[i]![0],
|
||||||
|
corners[i]![1],
|
||||||
|
corners[j]![0],
|
||||||
|
corners[j]![1],
|
||||||
polygon,
|
polygon,
|
||||||
)) return true
|
)
|
||||||
|
)
|
||||||
|
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
Reference in New Issue
Block a user