splitting editor and community
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 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,89 @@
|
||||
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 })
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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 })
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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 })
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Editor, SceneLoader } from '@pascal-app/editor'
|
||||
import type { SceneGraph } from '@pascal-app/editor'
|
||||
import { CommunityAppMenu } from '@/features/community/components/community-app-menu'
|
||||
import { ProjectHeader } from '@/features/community/components/project-header'
|
||||
import { useAuth } from '@/features/community/lib/auth/hooks'
|
||||
import { getProjectModel, saveProjectModel } from '@/features/community/lib/models/actions'
|
||||
import { uploadProjectThumbnail, updateProjectVisibility } from '@/features/community/lib/projects/actions'
|
||||
import { useProjectStore } from '@/features/community/lib/projects/store'
|
||||
import { uploadAssetWithProgress } from '@/lib/upload-asset'
|
||||
import { deleteProjectAssetByUrl } from '@/features/community/lib/assets/actions'
|
||||
|
||||
export default function EditorPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
const { isAuthenticated, isLoading } = useAuth()
|
||||
const setActiveProject = useProjectStore((state) => state.setActiveProject)
|
||||
const setAutosaveStatus = useProjectStore((state) => state.setAutosaveStatus)
|
||||
const isProjectLoading = useProjectStore((state) => state.isLoading)
|
||||
const isVersionPreviewMode = useProjectStore((state) => state.isVersionPreviewMode)
|
||||
const activeProject = useProjectStore((state) => state.activeProject)
|
||||
const router = useRouter()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return
|
||||
if (!isAuthenticated) {
|
||||
router.replace('/')
|
||||
return
|
||||
}
|
||||
if (projectId) {
|
||||
setActiveProject(projectId)
|
||||
}
|
||||
}, [projectId, isAuthenticated, isLoading, setActiveProject, router])
|
||||
|
||||
const onLoad = useCallback(async (): Promise<SceneGraph | null> => {
|
||||
const result = await getProjectModel(projectId)
|
||||
return result.success ? (result.data?.model?.scene_graph ?? null) : null
|
||||
}, [projectId])
|
||||
|
||||
const onSave = useCallback(async (scene: SceneGraph) => {
|
||||
await saveProjectModel(projectId, scene)
|
||||
}, [projectId])
|
||||
|
||||
const onThumbnailCapture = useCallback(async (blob: Blob) => {
|
||||
const result = await uploadProjectThumbnail(projectId, blob)
|
||||
if (result.success) {
|
||||
useProjectStore.getState().updateActiveThumbnail(result.data.thumbnail_url)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
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
|
||||
appMenuButton={<CommunityAppMenu />}
|
||||
sidebarTop={<ProjectHeader />}
|
||||
onLoad={onLoad}
|
||||
onSave={onSave}
|
||||
onSaveStatusChange={setAutosaveStatus}
|
||||
isVersionPreviewMode={isVersionPreviewMode}
|
||||
isLoading={isProjectLoading}
|
||||
onThumbnailCapture={onThumbnailCapture}
|
||||
settingsPanelProps={{
|
||||
projectId,
|
||||
projectVisibility: activeProject ? {
|
||||
isPrivate: activeProject.is_private ?? false,
|
||||
showScansPublic: activeProject.show_scans_public ?? true,
|
||||
showGuidesPublic: activeProject.show_guides_public ?? true,
|
||||
} : undefined,
|
||||
onVisibilityChange: async (field, value) => {
|
||||
await updateProjectVisibility(projectId, { [field]: value })
|
||||
},
|
||||
}}
|
||||
sitePanelProps={{
|
||||
projectId,
|
||||
onUploadAsset: (pid, levelId, file, type) => {
|
||||
uploadAssetWithProgress(pid, levelId, file, type)
|
||||
},
|
||||
onDeleteAsset: (pid, url) => {
|
||||
deleteProjectAssetByUrl(pid, url)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,256 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@source "../../../packages/editor/src";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--font-sans: var(--font-barlow), var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
--font-barlow: var(--font-barlow), var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-barlow), sans-serif;
|
||||
--font-mono: var(--font-geist-mono), monospace;
|
||||
--font-barlow: var(--font-barlow), sans-serif;
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(0.998 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(0.998 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(0.998 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.205 0 0); /* ~171717 */
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.235 0 0); /* slightly lighter than background (0.205) but darker than previous (0.269) */
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.235 0 0); /* matching accent */
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
button,
|
||||
[role="button"],
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/* Apple-style smooth corners (squircle) — progressive enhancement */
|
||||
.rounded-smooth {
|
||||
border-radius: var(--radius-lg);
|
||||
corner-shape: squircle;
|
||||
}
|
||||
.rounded-smooth-xl {
|
||||
border-radius: var(--radius-xl);
|
||||
corner-shape: squircle;
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loaders */
|
||||
.pascal-loader-1 {
|
||||
width: 45px;
|
||||
aspect-ratio: 1;
|
||||
--c:no-repeat linear-gradient(currentColor 0 0);
|
||||
background: var(--c), var(--c), var(--c);
|
||||
animation:
|
||||
pascal-l1-1 1s infinite,
|
||||
pascal-l1-2 1s infinite;
|
||||
}
|
||||
@keyframes pascal-l1-1 {
|
||||
0%,100% {background-size:20% 100%}
|
||||
33%,66% {background-size:20% 20%}
|
||||
}
|
||||
@keyframes pascal-l1-2 {
|
||||
0%,33% {background-position: 0 0,50% 50%,100% 100%}
|
||||
66%,100% {background-position: 100% 0,50% 50%,0 100%}
|
||||
}
|
||||
|
||||
.pascal-loader-2 {
|
||||
width: 45px;
|
||||
aspect-ratio: .75;
|
||||
--c: no-repeat linear-gradient(currentColor 0 0);
|
||||
background:
|
||||
var(--c) 0% 50%,
|
||||
var(--c) 50% 50%,
|
||||
var(--c) 100% 50%;
|
||||
background-size: 20% 50%;
|
||||
animation: pascal-l2 1s infinite linear;
|
||||
}
|
||||
@keyframes pascal-l2 {
|
||||
20% {background-position: 0% 0% ,50% 50% ,100% 50% }
|
||||
40% {background-position: 0% 100%,50% 0% ,100% 50% }
|
||||
60% {background-position: 0% 50% ,50% 100%,100% 0% }
|
||||
80% {background-position: 0% 50% ,50% 50% ,100% 100%}
|
||||
}
|
||||
|
||||
.pascal-loader-3 {
|
||||
width: 45px;
|
||||
aspect-ratio: .75;
|
||||
--c:no-repeat linear-gradient(currentColor 0 0);
|
||||
background:
|
||||
var(--c) 0% 100%,
|
||||
var(--c) 50% 100%,
|
||||
var(--c) 100% 100%;
|
||||
background-size: 20% 65%;
|
||||
animation: pascal-l3 1s infinite linear;
|
||||
}
|
||||
@keyframes pascal-l3 {
|
||||
16.67% {background-position: 0% 0% ,50% 100%,100% 100%}
|
||||
33.33% {background-position: 0% 0% ,50% 0% ,100% 100%}
|
||||
50% {background-position: 0% 0% ,50% 0% ,100% 0% }
|
||||
66.67% {background-position: 0% 100%,50% 0% ,100% 0% }
|
||||
83.33% {background-position: 0% 100%,50% 100%,100% 0% }
|
||||
}
|
||||
|
||||
.pascal-loader-4 {
|
||||
width: 45px;
|
||||
aspect-ratio: 1;
|
||||
--c:no-repeat linear-gradient(currentColor 0 0);
|
||||
background: var(--c), var(--c), var(--c);
|
||||
animation:
|
||||
pascal-l4-1 1s infinite,
|
||||
pascal-l4-2 1s infinite;
|
||||
}
|
||||
@keyframes pascal-l4-1 {
|
||||
0%,100% {background-size:20% 100%}
|
||||
33%,66% {background-size:20% 40%}
|
||||
}
|
||||
@keyframes pascal-l4-2 {
|
||||
0%,33% {background-position: 0 0,50% 100%,100% 100%}
|
||||
66%,100% {background-position: 100% 0,0 100%,50% 100%}
|
||||
}
|
||||
|
||||
.pascal-loader-5 {
|
||||
width: 45px;
|
||||
aspect-ratio: 1;
|
||||
--c:no-repeat linear-gradient(currentColor 0 0);
|
||||
background: var(--c), var(--c), var(--c);
|
||||
animation:
|
||||
pascal-l5-1 1s infinite,
|
||||
pascal-l5-2 1s infinite;
|
||||
}
|
||||
@keyframes pascal-l5-1 {
|
||||
0%,100% {background-size:20% 100%}
|
||||
33%,66% {background-size:20% 40%}
|
||||
}
|
||||
@keyframes pascal-l5-2 {
|
||||
0%,33% {background-position: 0 0 ,50% 100%,100% 0}
|
||||
66%,100% {background-position: 0 100%,50% 0 ,100% 100%}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Script from 'next/script'
|
||||
import localFont from 'next/font/local'
|
||||
import { Barlow } from 'next/font/google'
|
||||
import { Analytics } from '@vercel/analytics/react'
|
||||
import { SpeedInsights } from '@vercel/speed-insights/next'
|
||||
import { VercelToolbar } from '@vercel/toolbar/next'
|
||||
import { UsernameGate } from '@/features/community/components/username-gate'
|
||||
import { siteConfig } from './seo'
|
||||
import './globals.css'
|
||||
|
||||
const geistSans = localFont({
|
||||
src: './fonts/GeistVF.woff',
|
||||
variable: '--font-geist-sans',
|
||||
})
|
||||
const geistMono = localFont({
|
||||
src: './fonts/GeistMonoVF.woff',
|
||||
variable: '--font-geist-mono',
|
||||
})
|
||||
|
||||
const barlow = Barlow({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-barlow',
|
||||
display: 'swap',
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(siteConfig.url),
|
||||
title: {
|
||||
default: siteConfig.name,
|
||||
template: '%s | Pascal Editor',
|
||||
},
|
||||
description: siteConfig.description,
|
||||
applicationName: siteConfig.name,
|
||||
keywords: [...siteConfig.keywords],
|
||||
authors: [{ name: 'Pascal', url: 'https://pascal.app' }],
|
||||
creator: 'Pascal',
|
||||
publisher: 'Pascal',
|
||||
alternates: {
|
||||
canonical: '/',
|
||||
},
|
||||
icons: [{ rel: 'icon', url: '/favicon.ico' }],
|
||||
openGraph: {
|
||||
title: siteConfig.name,
|
||||
description: siteConfig.description,
|
||||
url: siteConfig.url,
|
||||
siteName: siteConfig.name,
|
||||
images: [{ url: siteConfig.ogImage, alt: 'Pascal Editor' }],
|
||||
locale: 'en_US',
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: siteConfig.name,
|
||||
description: siteConfig.description,
|
||||
creator: siteConfig.twitterHandle,
|
||||
images: [siteConfig.ogImage],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
'max-image-preview': 'large',
|
||||
'max-snippet': -1,
|
||||
'max-video-preview': -1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
const shouldShowToolbar = process.env.NODE_ENV === 'development'
|
||||
|
||||
return (
|
||||
<html lang="en" className={`${geistSans.variable} ${geistMono.variable} ${barlow.variable}`}>
|
||||
<head>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<>
|
||||
<Script
|
||||
src="//unpkg.com/react-scan/dist/auto.global.js"
|
||||
crossOrigin="anonymous"
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
<Script
|
||||
src="//unpkg.com/react-grab/dist/index.global.js"
|
||||
crossOrigin="anonymous"
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</head>
|
||||
<body className="font-sans">
|
||||
<UsernameGate>{children}</UsernameGate>
|
||||
<Analytics />
|
||||
<SpeedInsights />
|
||||
{shouldShowToolbar && <VercelToolbar />}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Metadata } from 'next'
|
||||
import CommunityHub from '@/features/community/components/community-hub'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Community Projects',
|
||||
description:
|
||||
'Create and share 3D home projects with Pascal Editor, the open-source building editor.',
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return <CommunityHub />
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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
|
||||
@@ -0,0 +1,40 @@
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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 ?? []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
'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 '@pascal-app/editor'
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
'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}
|
||||
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
'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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type BuildingNode,
|
||||
type LevelNode,
|
||||
useScene,
|
||||
type ZoneNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Camera,
|
||||
ChevronRight,
|
||||
Diamond,
|
||||
Layers,
|
||||
Layers2,
|
||||
Moon,
|
||||
Sun,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { motion } from 'framer-motion'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ProjectOwner } from '@/features/community/lib/projects/types'
|
||||
import { ActionButton } from '@/components/ui/action-menu/action-button'
|
||||
import { TooltipProvider } from '@/components/ui/primitives/tooltip'
|
||||
import { emitter } from '@pascal-app/core'
|
||||
import { CollectionsPanel } from './collections-panel'
|
||||
|
||||
const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
|
||||
stacked: 'Stacked',
|
||||
exploded: 'Exploded',
|
||||
solo: 'Solo',
|
||||
}
|
||||
|
||||
const wallModeConfig = {
|
||||
up: {
|
||||
icon: (props: any) => (
|
||||
<img alt="Full Height" height={28} src="/icons/room.png" width={28} {...props} />
|
||||
),
|
||||
label: 'Full Height',
|
||||
},
|
||||
cutaway: {
|
||||
icon: (props: any) => (
|
||||
<img alt="Cutaway" height={28} src="/icons/wallcut.png" width={28} {...props} />
|
||||
),
|
||||
label: 'Cutaway',
|
||||
},
|
||||
down: {
|
||||
icon: (props: any) => <img alt="Low" height={28} src="/icons/walllow.png" width={28} {...props} />,
|
||||
label: 'Low',
|
||||
},
|
||||
}
|
||||
|
||||
const getNodeName = (node: AnyNode): string => {
|
||||
if ('name' in node && node.name) return node.name
|
||||
if (node.type === 'wall') return 'Wall'
|
||||
if (node.type === 'item') return (node as { asset: { name: string } }).asset?.name || 'Item'
|
||||
if (node.type === 'slab') return 'Slab'
|
||||
if (node.type === 'ceiling') return 'Ceiling'
|
||||
if (node.type === 'roof') return 'Roof'
|
||||
return node.type
|
||||
}
|
||||
|
||||
interface ViewerOverlayProps {
|
||||
projectName?: string | null
|
||||
owner?: ProjectOwner | null
|
||||
canShowScans?: boolean
|
||||
canShowGuides?: boolean
|
||||
onBack?: () => void
|
||||
hideCollections?: boolean
|
||||
}
|
||||
|
||||
export const ViewerOverlay = ({
|
||||
projectName,
|
||||
owner,
|
||||
canShowScans = true,
|
||||
canShowGuides = true,
|
||||
onBack,
|
||||
hideCollections,
|
||||
}: ViewerOverlayProps) => {
|
||||
const selection = useViewer((s) => s.selection)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const showScans = useViewer((s) => s.showScans)
|
||||
const showGuides = useViewer((s) => s.showGuides)
|
||||
const cameraMode = useViewer((s) => s.cameraMode)
|
||||
const levelMode = useViewer((s) => s.levelMode)
|
||||
const wallMode = useViewer((s) => s.wallMode)
|
||||
const theme = useViewer((s) => s.theme)
|
||||
|
||||
const building = selection.buildingId
|
||||
? (nodes[selection.buildingId] as BuildingNode | undefined)
|
||||
: null
|
||||
const level = selection.levelId ? (nodes[selection.levelId] as LevelNode | undefined) : null
|
||||
const zone = selection.zoneId ? (nodes[selection.zoneId] as ZoneNode | undefined) : null
|
||||
|
||||
// Get the first selected item (if any)
|
||||
const selectedNode =
|
||||
selection.selectedIds.length > 0
|
||||
? (nodes[selection.selectedIds[0] as AnyNodeId] as AnyNode | undefined)
|
||||
: null
|
||||
|
||||
// Get all levels for the selected building
|
||||
const levels =
|
||||
building?.children
|
||||
.map((id) => nodes[id as AnyNodeId] as LevelNode | undefined)
|
||||
.filter((n): n is LevelNode => n?.type === 'level')
|
||||
.sort((a, b) => a.level - b.level) ?? []
|
||||
|
||||
const handleLevelClick = (levelId: LevelNode['id']) => {
|
||||
// When switching levels, deselect zone and items
|
||||
useViewer.getState().setSelection({ levelId })
|
||||
}
|
||||
|
||||
const handleBreadcrumbClick = (depth: 'root' | 'building' | 'level' | 'zone') => {
|
||||
switch (depth) {
|
||||
case 'root':
|
||||
useViewer.getState().resetSelection()
|
||||
break
|
||||
case 'building':
|
||||
useViewer.getState().setSelection({ levelId: null })
|
||||
break
|
||||
case 'level':
|
||||
useViewer.getState().setSelection({ zoneId: null })
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Unified top-left card */}
|
||||
<div className="absolute top-4 left-4 z-20 flex flex-col gap-3 dark text-foreground">
|
||||
<div className="pointer-events-auto flex flex-col rounded-2xl border border-border/40 bg-background/95 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out overflow-hidden min-w-[200px]">
|
||||
{/* Project info + back */}
|
||||
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||
{onBack ? (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href="/"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground truncate">
|
||||
{projectName || 'Untitled'}
|
||||
</div>
|
||||
{owner?.username && (
|
||||
<Link
|
||||
href={`/u/${owner.username}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
@{owner.username}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb — only shown when navigated into a building */}
|
||||
{building && (
|
||||
<div className="border-t border-border/40 px-3 py-2">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<button
|
||||
onClick={() => handleBreadcrumbClick('root')}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Site
|
||||
</button>
|
||||
|
||||
{building && (
|
||||
<>
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
|
||||
<button
|
||||
onClick={() => handleBreadcrumbClick('building')}
|
||||
className={`transition-colors truncate ${level ? 'text-muted-foreground hover:text-foreground' : 'text-foreground font-medium'}`}
|
||||
>
|
||||
{building.name || 'Building'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{level && (
|
||||
<>
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
|
||||
<button
|
||||
onClick={() => handleBreadcrumbClick('level')}
|
||||
className={`transition-colors truncate ${zone ? 'text-muted-foreground hover:text-foreground' : 'text-foreground font-medium'}`}
|
||||
>
|
||||
{level.name || `Level ${level.level}`}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{zone && (
|
||||
<>
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
|
||||
<span
|
||||
className={`transition-colors truncate ${selectedNode ? 'text-muted-foreground' : 'text-foreground font-medium'}`}
|
||||
>
|
||||
{zone.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedNode && zone && (
|
||||
<>
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
|
||||
<span className="text-foreground font-medium truncate">
|
||||
{getNodeName(selectedNode)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Level List (only when building is selected) */}
|
||||
{building && levels.length > 0 && (
|
||||
<div className="pointer-events-auto flex flex-col rounded-2xl border border-border/40 bg-background/95 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out overflow-hidden w-48 py-1">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider px-3 py-2">Levels</span>
|
||||
<div className="flex flex-col">
|
||||
{levels.map((lvl) => {
|
||||
const isSelected = lvl.id === selection.levelId;
|
||||
return (
|
||||
<button
|
||||
key={lvl.id}
|
||||
onClick={() => handleLevelClick(lvl.id)}
|
||||
className={cn(
|
||||
"relative flex items-center h-8 w-full cursor-pointer group/row text-sm select-none border-b border-r border-border/50 border-r-transparent transition-all duration-200 px-3",
|
||||
isSelected
|
||||
? "bg-accent/50 text-foreground border-r-white border-r-3"
|
||||
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span className={cn(
|
||||
"w-4 h-4 flex items-center justify-center shrink-0 transition-all duration-200",
|
||||
!isSelected && "opacity-60 grayscale"
|
||||
)}>
|
||||
<Layers className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0 truncate text-left">
|
||||
{lvl.name || `Level ${lvl.level}`}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collections Panel - Top Right */}
|
||||
{!hideCollections && (
|
||||
<div className="absolute top-4 right-4 z-20 flex flex-col gap-3 dark text-foreground">
|
||||
<CollectionsPanel />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls Panel - Bottom Center */}
|
||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-20 dark text-foreground">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div className="pointer-events-auto flex flex-row items-center justify-center gap-1.5 rounded-2xl border border-border/40 bg-background/95 p-1.5 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out h-14">
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
className="shrink-0 flex items-center bg-accent/50 rounded-full p-1 border border-border/50 cursor-pointer h-[36px]"
|
||||
onClick={() => useViewer.getState().setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
type="button"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
<div className="relative flex">
|
||||
{/* Sliding Background */}
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-white shadow-sm rounded-full dark:bg-white/20"
|
||||
initial={false}
|
||||
animate={{
|
||||
x: theme === "light" ? "100%" : "0%",
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 500,
|
||||
damping: 35,
|
||||
}}
|
||||
style={{ width: "50%" }}
|
||||
/>
|
||||
|
||||
{/* Dark Mode Icon */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 flex h-7 w-9 items-center justify-center rounded-full transition-colors duration-200 pointer-events-none",
|
||||
theme === "dark"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<Moon className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
{/* Light Mode Icon */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 flex h-7 w-9 items-center justify-center rounded-full transition-colors duration-200 pointer-events-none",
|
||||
theme === "light"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<Sun className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="mx-1 h-5 w-px bg-border/40" />
|
||||
|
||||
{/* Scans and Guides Visibility */}
|
||||
{canShowScans && (
|
||||
<ActionButton
|
||||
label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`}
|
||||
tooltipSide="top"
|
||||
className={showScans ? 'bg-white/10' : 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5'}
|
||||
onClick={() => useViewer.getState().setShowScans(!showScans)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<img alt="Scans" className="h-[28px] w-[28px] object-contain" src="/icons/mesh.png" />
|
||||
</ActionButton>
|
||||
)}
|
||||
|
||||
{canShowGuides && (
|
||||
<ActionButton
|
||||
label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`}
|
||||
tooltipSide="top"
|
||||
className={showGuides ? 'bg-white/10' : 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5'}
|
||||
onClick={() => useViewer.getState().setShowGuides(!showGuides)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<img alt="Guides" className="h-[28px] w-[28px] object-contain" src="/icons/floorplan.png" />
|
||||
</ActionButton>
|
||||
)}
|
||||
|
||||
{(canShowScans || canShowGuides) && <div className="mx-1 h-5 w-px bg-border/40" />}
|
||||
|
||||
{/* Camera Mode */}
|
||||
<ActionButton
|
||||
label={`Camera: ${cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}`}
|
||||
tooltipSide="top"
|
||||
className={cameraMode === 'orthographic' ? 'bg-violet-500/20 text-violet-400' : 'hover:text-violet-400 hover:bg-white/5'}
|
||||
onClick={() => useViewer.getState().setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Camera className="h-6 w-6" />
|
||||
</ActionButton>
|
||||
|
||||
{/* Level Mode */}
|
||||
<ActionButton
|
||||
label={`Levels: ${levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode as keyof typeof levelModeLabels]}`}
|
||||
tooltipSide="top"
|
||||
className={levelMode !== 'stacked' ? 'bg-amber-500/20 text-amber-400' : 'hover:text-amber-400 hover:bg-white/5'}
|
||||
onClick={() => {
|
||||
if (levelMode === 'manual') return useViewer.getState().setLevelMode('stacked')
|
||||
const modes: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo']
|
||||
const nextIndex = (modes.indexOf(levelMode as any) + 1) % modes.length
|
||||
useViewer.getState().setLevelMode(modes[nextIndex] ?? 'stacked')
|
||||
}}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
{levelMode === 'solo' && <Diamond className="h-6 w-6" />}
|
||||
{levelMode === 'exploded' && <Layers2 className="h-6 w-6" />}
|
||||
{(levelMode === 'stacked' || levelMode === 'manual') && <Layers className="h-6 w-6" />}
|
||||
</ActionButton>
|
||||
|
||||
{/* Wall Mode */}
|
||||
<ActionButton
|
||||
label={`Walls: ${wallModeConfig[wallMode as keyof typeof wallModeConfig].label}`}
|
||||
tooltipSide="top"
|
||||
className={wallMode !== 'cutaway' ? 'bg-white/10' : 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5'}
|
||||
onClick={() => {
|
||||
const modes: ('cutaway' | 'up' | 'down')[] = ['cutaway', 'up', 'down']
|
||||
const nextIndex = (modes.indexOf(wallMode as any) + 1) % modes.length
|
||||
useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway')
|
||||
}}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
{(() => {
|
||||
const Icon = wallModeConfig[wallMode as keyof typeof wallModeConfig].icon
|
||||
return <Icon className="h-[28px] w-[28px]" />
|
||||
})()}
|
||||
</ActionButton>
|
||||
|
||||
<div className="mx-1 h-5 w-px bg-border/40" />
|
||||
|
||||
{/* Camera Actions */}
|
||||
<ActionButton
|
||||
label="Orbit Left"
|
||||
tooltipSide="top"
|
||||
className="group hover:bg-white/5 hidden sm:inline-flex"
|
||||
onClick={() => emitter.emit('camera-controls:orbit-ccw')}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<img alt="Orbit Left" className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100 -scale-x-100" src="/icons/rotate.png" />
|
||||
</ActionButton>
|
||||
|
||||
<ActionButton
|
||||
label="Orbit Right"
|
||||
tooltipSide="top"
|
||||
className="group hover:bg-white/5 hidden sm:inline-flex"
|
||||
onClick={() => emitter.emit('camera-controls:orbit-cw')}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<img alt="Orbit Right" className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100" src="/icons/rotate.png" />
|
||||
</ActionButton>
|
||||
|
||||
<ActionButton
|
||||
label="Top View"
|
||||
tooltipSide="top"
|
||||
className="group hover:bg-white/5"
|
||||
onClick={() => emitter.emit('camera-controls:top-view')}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<img alt="Top View" className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100" src="/icons/topview.png" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
|
||||
export const ViewerZoneSystem = () => {
|
||||
useFrame(() => {
|
||||
const { levelId, zoneId } = useViewer.getState().selection
|
||||
const nodes = useScene.getState().nodes
|
||||
|
||||
sceneRegistry.byType.zone.forEach((id) => {
|
||||
const obj = sceneRegistry.nodes.get(id)
|
||||
if (!obj) return
|
||||
|
||||
const zone = nodes[id as ZoneNode['id']] as ZoneNode | undefined
|
||||
if (!zone) return
|
||||
|
||||
// Hide zones if:
|
||||
// 1. No level is selected
|
||||
// 2. Zone is not on the selected level
|
||||
// 3. A zone is already selected (hide all zones to show zone contents)
|
||||
const isOnSelectedLevel = zone.parentId === levelId
|
||||
const shouldShow = !!levelId && isOnSelectedLevel && !zoneId
|
||||
|
||||
obj.visible = shouldShow
|
||||
|
||||
const targetOpacity = shouldShow ? '1' : '0'
|
||||
const labelEl = document.getElementById(`${id}-label`)
|
||||
if (labelEl && labelEl.style.opacity !== targetOpacity) {
|
||||
labelEl.style.opacity = targetOpacity
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user