remove 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 }
|
||||
@@ -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,110 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Editor, SceneLoader } from '@pascal-app/editor'
|
||||
import type { SceneGraph } from '@pascal-app/editor'
|
||||
import { createApiPresetsAdapter } from '@/lib/presets-adapter'
|
||||
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 apiPresetsAdapter = useMemo(
|
||||
() => createApiPresetsAdapter(isAuthenticated),
|
||||
[isAuthenticated],
|
||||
)
|
||||
|
||||
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}
|
||||
presetsAdapter={apiPresetsAdapter}
|
||||
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.
|
Before Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
Binary file not shown.
@@ -1,256 +0,0 @@
|
||||
@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%}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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 +0,0 @@
|
||||
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 />
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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 '@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>
|
||||
)
|
||||
}
|
||||
@@ -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,444 +0,0 @@
|
||||
'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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
'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
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/ui/primitives/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/primitives/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ActionButtonProps extends React.ComponentProps<typeof Button> {
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
isActive?: boolean;
|
||||
tooltipContent?: React.ReactNode;
|
||||
tooltipSide?: "top" | "right" | "bottom" | "left";
|
||||
}
|
||||
|
||||
export const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProps>(
|
||||
(
|
||||
{ className, children, label, shortcut, isActive, tooltipContent, tooltipSide, ...props },
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-11 w-11 transition-all",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center transition-transform",
|
||||
shortcut && "-translate-x-0.5 -translate-y-0.5"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{shortcut && (
|
||||
<div className="absolute bottom-1 right-1 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md">
|
||||
<span className="block font-mono text-[9px] font-medium leading-none text-muted-foreground/70">
|
||||
{shortcut}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={tooltipSide}>
|
||||
{tooltipContent || (
|
||||
<p>
|
||||
{label} {shortcut && `(${shortcut})`}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
);
|
||||
ActionButton.displayName = "ActionButton";
|
||||
@@ -1 +0,0 @@
|
||||
export { useCommandPalette } from '@pascal-app/editor'
|
||||
@@ -1,69 +0,0 @@
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md font-barlow font-medium text-sm outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40',
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
'icon-sm': 'size-8',
|
||||
'icon-lg': 'size-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
ref,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
if (asChild) {
|
||||
return (
|
||||
<Slot
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
data-slot="button"
|
||||
ref={ref as never}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
data-slot="button"
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -1,129 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { XIcon } from 'lucide-react'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
className,
|
||||
)}
|
||||
data-slot="dialog-overlay"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in sm:max-w-lg',
|
||||
className,
|
||||
)}
|
||||
data-slot="dialog-content"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
|
||||
data-slot="dialog-close"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||
data-slot="dialog-header"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||
data-slot="dialog-footer"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
className={cn('font-semibold text-lg leading-none', className)}
|
||||
data-slot="dialog-title"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
data-slot="dialog-description"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
className={cn(
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
className,
|
||||
)}
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: 'default' | 'destructive'
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm font-barlow outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-inset:pl-8 data-[variant=destructive]:text-destructive data-disabled:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||
className,
|
||||
)}
|
||||
data-inset={inset}
|
||||
data-slot="dropdown-menu-item"
|
||||
data-variant={variant}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
checked={checked}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm font-barlow outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm font-barlow outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
className={cn('px-2 py-1.5 font-medium font-barlow text-sm data-inset:pl-8', className)}
|
||||
data-inset={inset}
|
||||
data-slot="dropdown-menu-label"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||
data-slot="dropdown-menu-separator"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
className={cn('ml-auto text-muted-foreground text-xs tracking-widest', className)}
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm font-barlow outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-inset:pl-8 data-[state=open]:text-accent-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
data-inset={inset}
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
className={cn(
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
className,
|
||||
)}
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children?: ReactNode
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
public state: State = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('Uncaught error:', error, errorInfo)
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center bg-[#1b1c1f] p-4 text-white">
|
||||
<h2 className="mb-4 text-xl font-bold text-red-400">Something went wrong</h2>
|
||||
<pre className="max-w-full overflow-auto rounded bg-black/30 p-4 text-sm text-gray-300">
|
||||
{this.state.error?.message}
|
||||
</pre>
|
||||
<button
|
||||
className="mt-4 rounded bg-blue-600 px-4 py-2 hover:bg-blue-700"
|
||||
onClick={() => this.setState({ hasError: false, error: null })}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import type * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
'h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs outline-none transition-[color,box-shadow] selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:font-medium file:text-foreground file:text-sm placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30',
|
||||
'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
||||
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
|
||||
className,
|
||||
)}
|
||||
data-slot="input"
|
||||
type={type}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -1,42 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = 'center',
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
align={align}
|
||||
className={cn(
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
className,
|
||||
)}
|
||||
data-slot="popover-content"
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
@@ -1,28 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = 'horizontal',
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
className={cn(
|
||||
'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px',
|
||||
className,
|
||||
)}
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -1,13 +0,0 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('animate-pulse rounded-md bg-accent', className)}
|
||||
data-slot="skeleton"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -1,30 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-xs transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
className={cn(
|
||||
'fade-in-0 zoom-in-95 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in text-balance rounded-md bg-foreground px-3 py-1.5 text-background font-barlow text-xs data-[state=closed]:animate-out',
|
||||
className,
|
||||
)}
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* Environment variable validation for the editor app.
|
||||
*
|
||||
* This file validates that required environment variables are set at runtime.
|
||||
* Variables are defined in the root .env file.
|
||||
*
|
||||
* @see https://env.t3.gg/docs/nextjs
|
||||
*/
|
||||
import { createEnv } from '@t3-oss/env-nextjs'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const env = createEnv({
|
||||
/**
|
||||
* Server-side environment variables (not exposed to client)
|
||||
*/
|
||||
server: {
|
||||
// Database
|
||||
POSTGRES_URL: z.string().min(1),
|
||||
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
|
||||
|
||||
// Auth
|
||||
BETTER_AUTH_SECRET: z.string().min(1),
|
||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
|
||||
// Email
|
||||
RESEND_API_KEY: z.string().optional(),
|
||||
},
|
||||
|
||||
/**
|
||||
* Client-side environment variables (exposed to browser via NEXT_PUBLIC_)
|
||||
*/
|
||||
client: {
|
||||
NEXT_PUBLIC_SUPABASE_URL: z.string().min(1),
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().optional(),
|
||||
},
|
||||
|
||||
/**
|
||||
* Runtime values - pulls from process.env
|
||||
*/
|
||||
runtimeEnv: {
|
||||
// Server
|
||||
POSTGRES_URL: process.env.POSTGRES_URL,
|
||||
SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY,
|
||||
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET,
|
||||
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
|
||||
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
|
||||
RESEND_API_KEY: process.env.RESEND_API_KEY,
|
||||
// Client
|
||||
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
||||
},
|
||||
|
||||
/**
|
||||
* Skip validation during build (env vars come from Vercel at runtime)
|
||||
*/
|
||||
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
|
||||
})
|
||||
@@ -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,157 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, Command, FolderOpen, Search } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/primitives/dialog";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/primitives/popover";
|
||||
import { useCommandPalette } from "@/components/ui/command-palette";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useProjectStore } from "../lib/projects/store";
|
||||
|
||||
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 function CommunityAppMenu() {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [isOpenProjectOpen, setIsOpenProjectOpen] = useState(false);
|
||||
|
||||
const handleOpenProject = () => {
|
||||
setIsMenuOpen(false);
|
||||
setIsOpenProjectOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
|
||||
<OpenProjectModal open={isOpenProjectOpen} onOpenChange={setIsOpenProjectOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,481 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useScene } from "@pascal-app/core";
|
||||
import {
|
||||
ArrowUpCircle,
|
||||
ChevronDown,
|
||||
Clock3,
|
||||
RotateCcw,
|
||||
Save,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/primitives/popover";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/primitives/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { applySceneGraphToEditor } from "@pascal-app/editor";
|
||||
import {
|
||||
getProjectModel,
|
||||
getProjectVersionById,
|
||||
getProjectVersionList,
|
||||
getProjectVersionStatus,
|
||||
publishProjectModel,
|
||||
saveProjectModel,
|
||||
saveProjectVersion,
|
||||
type ProjectVersionListItem,
|
||||
type ProjectVersionStatus,
|
||||
type SceneGraph,
|
||||
} from "../lib/models/actions";
|
||||
import { updateProjectName } from "../lib/projects/actions";
|
||||
import { useProjectStore } from "../lib/projects/store";
|
||||
|
||||
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 ProjectHeader() {
|
||||
type VersionAction = "save" | "savePublish" | "publish";
|
||||
type VersionItemAction = "restore" | "publish";
|
||||
|
||||
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(() => {
|
||||
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) {
|
||||
useProjectStore.setState((state) => ({
|
||||
activeProject: state.activeProject ? { ...state.activeProject, name: trimmed } : null,
|
||||
projects: state.projects.map((p) => p.id === activeProject.id ? { ...p, name: trimmed } : p),
|
||||
}));
|
||||
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);
|
||||
setVersionList(result.success && result.data ? 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();
|
||||
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 };
|
||||
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); }
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,999 +0,0 @@
|
||||
/**
|
||||
* Project model actions - Server actions for scene loading/saving
|
||||
* Manages 3D models (scene graphs) stored in projects_models table
|
||||
*/
|
||||
|
||||
'use server'
|
||||
|
||||
import { createServerSupabaseClient } from '../database/server'
|
||||
import { getSession } from '../auth/server'
|
||||
import { createId } from '../utils/id-generator'
|
||||
import type { ActionResult } from '../projects/actions'
|
||||
import { isSceneGraphEmpty } from './scene-graph-utils'
|
||||
|
||||
export interface SceneGraph {
|
||||
nodes: Record<string, unknown>
|
||||
rootNodeIds: string[]
|
||||
}
|
||||
|
||||
export interface ProjectModel {
|
||||
id: string
|
||||
name: string
|
||||
version: number
|
||||
draft: boolean
|
||||
project_id: string
|
||||
scene_graph: SceneGraph | null
|
||||
metadata: Record<string, unknown> | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ProjectVersionStatus {
|
||||
publishedVersion: number | null
|
||||
draftVersion: number | null
|
||||
latestSavedVersion: number | null
|
||||
hasUnsavedDraftChanges: boolean
|
||||
hasPublishableVersion: boolean
|
||||
}
|
||||
|
||||
export interface ProjectModelState extends ProjectVersionStatus {
|
||||
model: ProjectModel | null
|
||||
}
|
||||
|
||||
export interface ProjectVersionListItem {
|
||||
id: string
|
||||
version: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
isPublished: boolean
|
||||
isDraft: boolean
|
||||
restoredFromVersion: number | null
|
||||
}
|
||||
|
||||
type ProjectVersionListRow = {
|
||||
id: string
|
||||
version: number
|
||||
draft: boolean
|
||||
metadata: unknown
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface ProjectOwnershipRow {
|
||||
id: string
|
||||
owner_id: string
|
||||
name: string
|
||||
published_model_version: number | null
|
||||
}
|
||||
|
||||
type AuthenticatedProjectContext = {
|
||||
supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>
|
||||
project: ProjectOwnershipRow
|
||||
}
|
||||
|
||||
function sceneGraphsEqual(
|
||||
left: SceneGraph | null | undefined,
|
||||
right: SceneGraph | null | undefined,
|
||||
): boolean {
|
||||
return JSON.stringify(left ?? null) === JSON.stringify(right ?? null)
|
||||
}
|
||||
|
||||
function parseModelMetadata(input: unknown): Record<string, unknown> {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return { ...(input as Record<string, unknown>) }
|
||||
}
|
||||
|
||||
function readRestoredFromVersion(input: unknown): number | null {
|
||||
const metadata = parseModelMetadata(input)
|
||||
const restoredFromVersion = metadata.restoredFromVersion
|
||||
if (typeof restoredFromVersion !== 'number' || !Number.isFinite(restoredFromVersion)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return restoredFromVersion
|
||||
}
|
||||
|
||||
function buildVersionStatus(params: {
|
||||
publishedVersion: number | null
|
||||
draftModel: ProjectModel | null
|
||||
latestSavedModel: ProjectModel | null
|
||||
}): ProjectVersionStatus {
|
||||
const publishedVersion = params.publishedVersion
|
||||
const draftVersion = params.draftModel?.version ?? null
|
||||
const latestSavedVersion = params.latestSavedModel?.version ?? null
|
||||
|
||||
const hasUnsavedDraftChanges = params.draftModel
|
||||
? params.latestSavedModel
|
||||
? !sceneGraphsEqual(params.draftModel.scene_graph, params.latestSavedModel.scene_graph)
|
||||
: true
|
||||
: false
|
||||
|
||||
const hasPublishableVersion =
|
||||
latestSavedVersion !== null && latestSavedVersion !== publishedVersion
|
||||
|
||||
return {
|
||||
publishedVersion,
|
||||
draftVersion: draftVersion,
|
||||
latestSavedVersion,
|
||||
hasUnsavedDraftChanges,
|
||||
hasPublishableVersion,
|
||||
}
|
||||
}
|
||||
|
||||
async function getProjectVersionModels(
|
||||
supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>,
|
||||
projectId: string,
|
||||
): Promise<
|
||||
ActionResult<{
|
||||
draftModel: ProjectModel | null
|
||||
latestSavedModel: ProjectModel | null
|
||||
}>
|
||||
> {
|
||||
const { data: draftModel, error: draftModelError } = await supabase
|
||||
.from('projects_models')
|
||||
.select('*')
|
||||
.eq('project_id', projectId)
|
||||
.eq('draft', true)
|
||||
.is('deleted_at', null)
|
||||
.order('version', { ascending: false })
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle<ProjectModel>()
|
||||
|
||||
if (draftModelError) {
|
||||
return {
|
||||
success: false,
|
||||
error: draftModelError.message,
|
||||
}
|
||||
}
|
||||
|
||||
const { data: latestSavedModel, error: latestSavedModelError } = await supabase
|
||||
.from('projects_models')
|
||||
.select('*')
|
||||
.eq('project_id', projectId)
|
||||
.eq('draft', false)
|
||||
.is('deleted_at', null)
|
||||
.order('version', { ascending: false })
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle<ProjectModel>()
|
||||
|
||||
if (latestSavedModelError) {
|
||||
return {
|
||||
success: false,
|
||||
error: latestSavedModelError.message,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
draftModel: draftModel ?? null,
|
||||
latestSavedModel: latestSavedModel ?? null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function getAuthenticatedProjectContext(
|
||||
projectId: string,
|
||||
): Promise<ActionResult<AuthenticatedProjectContext>> {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Not authenticated',
|
||||
}
|
||||
}
|
||||
|
||||
const supabase = await createServerSupabaseClient()
|
||||
const { data: project, error: projectError } = await supabase
|
||||
.from('projects')
|
||||
.select('id, owner_id, name, published_model_version')
|
||||
.eq('id', projectId)
|
||||
.single<ProjectOwnershipRow>()
|
||||
|
||||
if (projectError || !project) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Project not found',
|
||||
}
|
||||
}
|
||||
|
||||
if (project.owner_id !== session.user.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
supabase,
|
||||
project,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns publish/draft status for the current project.
|
||||
*/
|
||||
export async function getProjectVersionStatus(
|
||||
projectId: string,
|
||||
): Promise<ActionResult<ProjectVersionStatus>> {
|
||||
try {
|
||||
const contextResult = await getAuthenticatedProjectContext(projectId)
|
||||
if (!contextResult.success || !contextResult.data) {
|
||||
return {
|
||||
success: false,
|
||||
error: contextResult.error,
|
||||
}
|
||||
}
|
||||
|
||||
const { supabase, project } = contextResult.data
|
||||
const versionModelsResult = await getProjectVersionModels(supabase, projectId)
|
||||
if (!versionModelsResult.success || !versionModelsResult.data) {
|
||||
return {
|
||||
success: false,
|
||||
error: versionModelsResult.error,
|
||||
}
|
||||
}
|
||||
|
||||
const { draftModel, latestSavedModel } = versionModelsResult.data
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: buildVersionStatus({
|
||||
publishedVersion: project.published_model_version ?? null,
|
||||
draftModel,
|
||||
latestSavedModel,
|
||||
}),
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch project version status',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all project versions (including current draft), newest first.
|
||||
*/
|
||||
export async function getProjectVersionList(
|
||||
projectId: string,
|
||||
): Promise<ActionResult<ProjectVersionListItem[]>> {
|
||||
try {
|
||||
const contextResult = await getAuthenticatedProjectContext(projectId)
|
||||
if (!contextResult.success || !contextResult.data) {
|
||||
return {
|
||||
success: false,
|
||||
error: contextResult.error,
|
||||
data: [],
|
||||
}
|
||||
}
|
||||
|
||||
const { supabase, project } = contextResult.data
|
||||
const { data: versions, error: versionsError } = await supabase
|
||||
.from('projects_models')
|
||||
.select('id, version, draft, metadata, created_at, updated_at')
|
||||
.eq('project_id', projectId)
|
||||
.is('deleted_at', null)
|
||||
.order('version', { ascending: false })
|
||||
.order('created_at', { ascending: false })
|
||||
.returns<ProjectVersionListRow[]>()
|
||||
|
||||
if (versionsError) {
|
||||
return {
|
||||
success: false,
|
||||
error: versionsError.message,
|
||||
data: [],
|
||||
}
|
||||
}
|
||||
|
||||
const publishedVersion = project.published_model_version ?? null
|
||||
return {
|
||||
success: true,
|
||||
data: (versions ?? []).map((item) => ({
|
||||
id: item.id,
|
||||
version: item.version,
|
||||
createdAt: item.created_at,
|
||||
updatedAt: item.updated_at,
|
||||
isPublished: publishedVersion !== null && item.version === publishedVersion,
|
||||
isDraft: item.draft,
|
||||
restoredFromVersion: readRestoredFromVersion(item.metadata),
|
||||
})),
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch project version list',
|
||||
data: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single version by version number (saved or draft).
|
||||
*/
|
||||
export async function getProjectVersionByNumber(
|
||||
projectId: string,
|
||||
version: number,
|
||||
): Promise<ActionResult<ProjectModel | null>> {
|
||||
try {
|
||||
const contextResult = await getAuthenticatedProjectContext(projectId)
|
||||
if (!contextResult.success || !contextResult.data) {
|
||||
return {
|
||||
success: false,
|
||||
error: contextResult.error,
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
|
||||
const { supabase } = contextResult.data
|
||||
const { data: model, error: modelError } = await supabase
|
||||
.from('projects_models')
|
||||
.select('*')
|
||||
.eq('project_id', projectId)
|
||||
.eq('version', version)
|
||||
.is('deleted_at', null)
|
||||
.limit(1)
|
||||
.maybeSingle<ProjectModel>()
|
||||
|
||||
if (modelError) {
|
||||
return {
|
||||
success: false,
|
||||
error: modelError.message,
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: model ?? null,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch project version',
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single version by model id.
|
||||
*/
|
||||
export async function getProjectVersionById(
|
||||
projectId: string,
|
||||
modelId: string,
|
||||
): Promise<ActionResult<ProjectModel | null>> {
|
||||
try {
|
||||
const contextResult = await getAuthenticatedProjectContext(projectId)
|
||||
if (!contextResult.success || !contextResult.data) {
|
||||
return {
|
||||
success: false,
|
||||
error: contextResult.error,
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
|
||||
const { supabase } = contextResult.data
|
||||
const { data: model, error: modelError } = await supabase
|
||||
.from('projects_models')
|
||||
.select('*')
|
||||
.eq('project_id', projectId)
|
||||
.eq('id', modelId)
|
||||
.is('deleted_at', null)
|
||||
.limit(1)
|
||||
.maybeSingle<ProjectModel>()
|
||||
|
||||
if (modelError) {
|
||||
return {
|
||||
success: false,
|
||||
error: modelError.message,
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: model ?? null,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch project version',
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the editor model for a project:
|
||||
* - Draft if one exists
|
||||
* - Otherwise the published version
|
||||
* - Otherwise latest available model (legacy fallback)
|
||||
*/
|
||||
export async function getProjectModel(projectId: string): Promise<ActionResult<ProjectModelState>> {
|
||||
try {
|
||||
const contextResult = await getAuthenticatedProjectContext(projectId)
|
||||
if (!contextResult.success || !contextResult.data) {
|
||||
return {
|
||||
success: false,
|
||||
error: contextResult.error,
|
||||
}
|
||||
}
|
||||
|
||||
const { supabase, project } = contextResult.data
|
||||
const publishedVersion = project.published_model_version ?? null
|
||||
|
||||
const versionModelsResult = await getProjectVersionModels(supabase, projectId)
|
||||
if (!versionModelsResult.success || !versionModelsResult.data) {
|
||||
return {
|
||||
success: false,
|
||||
error: versionModelsResult.error,
|
||||
}
|
||||
}
|
||||
|
||||
const { draftModel, latestSavedModel } = versionModelsResult.data
|
||||
let modelToLoad = draftModel ?? null
|
||||
|
||||
if (!modelToLoad && publishedVersion !== null) {
|
||||
const { data: publishedModel, error: publishedModelError } = await supabase
|
||||
.from('projects_models')
|
||||
.select('*')
|
||||
.eq('project_id', projectId)
|
||||
.eq('version', publishedVersion)
|
||||
.eq('draft', false)
|
||||
.is('deleted_at', null)
|
||||
.limit(1)
|
||||
.maybeSingle<ProjectModel>()
|
||||
|
||||
if (publishedModelError) {
|
||||
return {
|
||||
success: false,
|
||||
error: publishedModelError.message,
|
||||
}
|
||||
}
|
||||
|
||||
modelToLoad = publishedModel ?? null
|
||||
}
|
||||
|
||||
if (!modelToLoad && latestSavedModel) {
|
||||
modelToLoad = latestSavedModel
|
||||
}
|
||||
|
||||
if (!modelToLoad) {
|
||||
const { data: latestModel, error: latestModelError } = await supabase
|
||||
.from('projects_models')
|
||||
.select('*')
|
||||
.eq('project_id', projectId)
|
||||
.is('deleted_at', null)
|
||||
.order('version', { ascending: false })
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle<ProjectModel>()
|
||||
|
||||
if (latestModelError) {
|
||||
return {
|
||||
success: false,
|
||||
error: latestModelError.message,
|
||||
}
|
||||
}
|
||||
|
||||
modelToLoad = latestModel ?? null
|
||||
}
|
||||
|
||||
const status = buildVersionStatus({
|
||||
publishedVersion,
|
||||
draftModel,
|
||||
latestSavedModel,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
model: modelToLoad,
|
||||
...status,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch project model',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save or update the project's draft model scene graph.
|
||||
*/
|
||||
export interface SaveProjectModelOptions {
|
||||
restoredFromVersion?: number | null
|
||||
}
|
||||
|
||||
export async function saveProjectModel(
|
||||
projectId: string,
|
||||
sceneGraph: SceneGraph,
|
||||
options?: SaveProjectModelOptions,
|
||||
): Promise<ActionResult<ProjectModelState>> {
|
||||
try {
|
||||
const contextResult = await getAuthenticatedProjectContext(projectId)
|
||||
if (!contextResult.success || !contextResult.data) {
|
||||
return {
|
||||
success: false,
|
||||
error: contextResult.error,
|
||||
}
|
||||
}
|
||||
|
||||
const { supabase, project } = contextResult.data
|
||||
|
||||
// Determine if scene graph is empty
|
||||
const isEmpty = isSceneGraphEmpty(sceneGraph)
|
||||
|
||||
// Update the project's is_empty flag
|
||||
await (supabase.from('projects') as any)
|
||||
.update({ is_empty: isEmpty })
|
||||
.eq('id', projectId)
|
||||
|
||||
const versionModelsResult = await getProjectVersionModels(supabase, projectId)
|
||||
if (!versionModelsResult.success || !versionModelsResult.data) {
|
||||
return {
|
||||
success: false,
|
||||
error: versionModelsResult.error,
|
||||
}
|
||||
}
|
||||
|
||||
const { draftModel: existingDraftModel, latestSavedModel } = versionModelsResult.data
|
||||
let savedModel: ProjectModel | null = null
|
||||
const restoredFromVersionOption = options?.restoredFromVersion
|
||||
const metadataOverride =
|
||||
restoredFromVersionOption === undefined
|
||||
? undefined
|
||||
: (() => {
|
||||
const metadata = parseModelMetadata(existingDraftModel?.metadata ?? null)
|
||||
|
||||
if (typeof restoredFromVersionOption === 'number') {
|
||||
metadata.restoredFromVersion = restoredFromVersionOption
|
||||
} else {
|
||||
delete metadata.restoredFromVersion
|
||||
}
|
||||
|
||||
return Object.keys(metadata).length > 0 ? metadata : null
|
||||
})()
|
||||
|
||||
if (existingDraftModel) {
|
||||
const updateData: Record<string, unknown> = {
|
||||
scene_graph: sceneGraph,
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
if (metadataOverride !== undefined) {
|
||||
updateData.metadata = metadataOverride
|
||||
}
|
||||
const { data: updatedModel, error: updateError } = (await (supabase
|
||||
.from('projects_models') as any)
|
||||
.update(updateData)
|
||||
.eq('id', existingDraftModel.id)
|
||||
.select()
|
||||
.single()) as { data: ProjectModel | null; error: any }
|
||||
|
||||
if (updateError) {
|
||||
return {
|
||||
success: false,
|
||||
error: updateError.message,
|
||||
}
|
||||
}
|
||||
|
||||
savedModel = updatedModel as ProjectModel
|
||||
} else {
|
||||
const baselineModel = latestSavedModel
|
||||
|
||||
if (baselineModel && sceneGraphsEqual(baselineModel.scene_graph, sceneGraph)) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
model: baselineModel,
|
||||
...buildVersionStatus({
|
||||
publishedVersion: project.published_model_version ?? null,
|
||||
draftModel: null,
|
||||
latestSavedModel: baselineModel,
|
||||
}),
|
||||
},
|
||||
message: 'No draft changes to save',
|
||||
}
|
||||
}
|
||||
|
||||
const { data: latestModel, error: latestModelError } = await supabase
|
||||
.from('projects_models')
|
||||
.select('version')
|
||||
.eq('project_id', projectId)
|
||||
.is('deleted_at', null)
|
||||
.order('version', { ascending: false })
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle<{ version: number }>()
|
||||
|
||||
if (latestModelError) {
|
||||
return {
|
||||
success: false,
|
||||
error: latestModelError.message,
|
||||
}
|
||||
}
|
||||
|
||||
const nextVersion = (latestModel?.version ?? 0) + 1
|
||||
const modelId = createId('model')
|
||||
|
||||
const insertData = {
|
||||
id: modelId,
|
||||
project_id: projectId,
|
||||
name: `${project.name} - Draft v${nextVersion}`,
|
||||
version: nextVersion,
|
||||
draft: true,
|
||||
scene_graph: sceneGraph,
|
||||
...(metadataOverride !== undefined ? { metadata: metadataOverride } : {}),
|
||||
}
|
||||
const { data: newModel, error: createError } = (await (supabase
|
||||
.from('projects_models') as any)
|
||||
.insert(insertData)
|
||||
.select()
|
||||
.single()) as { data: ProjectModel | null; error: any }
|
||||
|
||||
if (createError) {
|
||||
return {
|
||||
success: false,
|
||||
error: createError.message,
|
||||
}
|
||||
}
|
||||
|
||||
savedModel = newModel as ProjectModel
|
||||
}
|
||||
|
||||
if (!savedModel) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to save project model',
|
||||
}
|
||||
}
|
||||
|
||||
const status = buildVersionStatus({
|
||||
publishedVersion: project.published_model_version ?? null,
|
||||
draftModel: savedModel,
|
||||
latestSavedModel,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
model: savedModel,
|
||||
...status,
|
||||
},
|
||||
message: existingDraftModel
|
||||
? 'Draft model updated successfully'
|
||||
: 'Draft model created successfully',
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to save project model',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createNextDraftVersion(
|
||||
supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>,
|
||||
params: {
|
||||
projectId: string
|
||||
projectName: string
|
||||
sceneGraph: SceneGraph | null
|
||||
},
|
||||
): Promise<ActionResult<ProjectModel>> {
|
||||
const { data: latestModel, error: latestModelError } = await supabase
|
||||
.from('projects_models')
|
||||
.select('version')
|
||||
.eq('project_id', params.projectId)
|
||||
.is('deleted_at', null)
|
||||
.order('version', { ascending: false })
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle<{ version: number }>()
|
||||
|
||||
if (latestModelError) {
|
||||
return {
|
||||
success: false,
|
||||
error: latestModelError.message,
|
||||
}
|
||||
}
|
||||
|
||||
const nextVersion = (latestModel?.version ?? 0) + 1
|
||||
const modelId = createId('model')
|
||||
const insertData = {
|
||||
id: modelId,
|
||||
project_id: params.projectId,
|
||||
name: `${params.projectName} - Draft v${nextVersion}`,
|
||||
version: nextVersion,
|
||||
draft: true,
|
||||
scene_graph: params.sceneGraph,
|
||||
}
|
||||
|
||||
const { data: newDraftModel, error: createError } = (await (supabase
|
||||
.from('projects_models') as any)
|
||||
.insert(insertData)
|
||||
.select()
|
||||
.single()) as { data: ProjectModel | null; error: any }
|
||||
|
||||
if (createError || !newDraftModel) {
|
||||
return {
|
||||
success: false,
|
||||
error: createError?.message ?? 'Failed to create next draft version',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: newDraftModel as ProjectModel,
|
||||
}
|
||||
}
|
||||
|
||||
export interface SaveProjectVersionOptions {
|
||||
publish?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current draft into a locked version, and optionally publish it.
|
||||
*
|
||||
* Behavior:
|
||||
* - Save only: lock draft as a saved version, then create the next draft.
|
||||
* - Save + publish: lock draft, publish it, then create the next draft.
|
||||
* - Publish when already saved: publish latest saved version directly.
|
||||
*/
|
||||
export async function saveProjectVersion(
|
||||
projectId: string,
|
||||
options?: SaveProjectVersionOptions,
|
||||
): Promise<ActionResult<ProjectVersionStatus>> {
|
||||
try {
|
||||
const contextResult = await getAuthenticatedProjectContext(projectId)
|
||||
if (!contextResult.success || !contextResult.data) {
|
||||
return {
|
||||
success: false,
|
||||
error: contextResult.error,
|
||||
}
|
||||
}
|
||||
|
||||
const { supabase, project } = contextResult.data
|
||||
const shouldPublish = options?.publish ?? false
|
||||
let publishedVersion = project.published_model_version ?? null
|
||||
|
||||
const versionModelsResult = await getProjectVersionModels(supabase, projectId)
|
||||
if (!versionModelsResult.success || !versionModelsResult.data) {
|
||||
return {
|
||||
success: false,
|
||||
error: versionModelsResult.error,
|
||||
}
|
||||
}
|
||||
|
||||
let { draftModel, latestSavedModel } = versionModelsResult.data
|
||||
let didSaveVersion = false
|
||||
let didPublishVersion = false
|
||||
|
||||
if (draftModel) {
|
||||
const draftDiffersFromSaved = latestSavedModel
|
||||
? !sceneGraphsEqual(draftModel.scene_graph, latestSavedModel.scene_graph)
|
||||
: true
|
||||
|
||||
if (draftDiffersFromSaved) {
|
||||
const { data: lockedModel, error: lockDraftError } = (await (supabase
|
||||
.from('projects_models') as any)
|
||||
.update({
|
||||
draft: false,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', draftModel.id)
|
||||
.select()
|
||||
.single()) as { data: ProjectModel | null; error: any }
|
||||
|
||||
if (lockDraftError || !lockedModel) {
|
||||
return {
|
||||
success: false,
|
||||
error: lockDraftError?.message ?? 'Failed to lock draft version',
|
||||
}
|
||||
}
|
||||
|
||||
latestSavedModel = lockedModel as ProjectModel
|
||||
draftModel = null
|
||||
didSaveVersion = true
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldPublish) {
|
||||
if (!latestSavedModel) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No saved version available to publish',
|
||||
}
|
||||
}
|
||||
|
||||
if (publishedVersion !== latestSavedModel.version) {
|
||||
const { error: updateProjectError } = await (supabase
|
||||
.from('projects') as any)
|
||||
.update({
|
||||
published_model_version: latestSavedModel.version,
|
||||
})
|
||||
.eq('id', projectId)
|
||||
|
||||
if (updateProjectError) {
|
||||
return {
|
||||
success: false,
|
||||
error: updateProjectError.message,
|
||||
}
|
||||
}
|
||||
|
||||
publishedVersion = latestSavedModel.version
|
||||
didPublishVersion = true
|
||||
}
|
||||
}
|
||||
|
||||
// Keep autosave flowing onto a fresh draft whenever we lock/publish a version.
|
||||
if ((didSaveVersion || didPublishVersion) && !draftModel && latestSavedModel) {
|
||||
const nextDraftResult = await createNextDraftVersion(supabase, {
|
||||
projectId,
|
||||
projectName: project.name,
|
||||
sceneGraph: latestSavedModel.scene_graph,
|
||||
})
|
||||
|
||||
if (!nextDraftResult.success || !nextDraftResult.data) {
|
||||
return {
|
||||
success: false,
|
||||
error: nextDraftResult.error,
|
||||
}
|
||||
}
|
||||
|
||||
draftModel = nextDraftResult.data
|
||||
}
|
||||
|
||||
const status = buildVersionStatus({
|
||||
publishedVersion,
|
||||
draftModel,
|
||||
latestSavedModel,
|
||||
})
|
||||
|
||||
let message = 'No version changes'
|
||||
if (didSaveVersion && didPublishVersion && latestSavedModel) {
|
||||
message = `Saved and published v${latestSavedModel.version}`
|
||||
} else if (didSaveVersion && latestSavedModel) {
|
||||
message = `Saved version v${latestSavedModel.version}`
|
||||
} else if (didPublishVersion && latestSavedModel) {
|
||||
message = `Published version v${latestSavedModel.version}`
|
||||
} else if (shouldPublish && latestSavedModel && publishedVersion === latestSavedModel.version) {
|
||||
message = `Version v${latestSavedModel.version} is already published`
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: status,
|
||||
message,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to save project version',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface PublishProjectModelOptions {
|
||||
version?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a project model version to the community.
|
||||
*
|
||||
* - If `options.version` is provided, republish that saved version.
|
||||
* - Otherwise publish using saveProjectVersion(publish=true) behavior.
|
||||
*/
|
||||
export async function publishProjectModel(
|
||||
projectId: string,
|
||||
options?: PublishProjectModelOptions,
|
||||
): Promise<ActionResult<ProjectVersionStatus>> {
|
||||
try {
|
||||
if (typeof options?.version !== 'number') {
|
||||
return await saveProjectVersion(projectId, { publish: true })
|
||||
}
|
||||
|
||||
const contextResult = await getAuthenticatedProjectContext(projectId)
|
||||
if (!contextResult.success || !contextResult.data) {
|
||||
return {
|
||||
success: false,
|
||||
error: contextResult.error,
|
||||
}
|
||||
}
|
||||
|
||||
const { supabase, project } = contextResult.data
|
||||
const { data: targetVersionModel, error: targetVersionError } = await supabase
|
||||
.from('projects_models')
|
||||
.select('*')
|
||||
.eq('project_id', projectId)
|
||||
.eq('version', options.version)
|
||||
.eq('draft', false)
|
||||
.is('deleted_at', null)
|
||||
.limit(1)
|
||||
.maybeSingle<ProjectModel>()
|
||||
|
||||
if (targetVersionError) {
|
||||
return {
|
||||
success: false,
|
||||
error: targetVersionError.message,
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetVersionModel) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Version ${options.version} is not a saved version`,
|
||||
}
|
||||
}
|
||||
|
||||
const { error: updateProjectError } = await (supabase
|
||||
.from('projects') as any)
|
||||
.update({
|
||||
published_model_version: targetVersionModel.version,
|
||||
})
|
||||
.eq('id', projectId)
|
||||
|
||||
if (updateProjectError) {
|
||||
return {
|
||||
success: false,
|
||||
error: updateProjectError.message,
|
||||
}
|
||||
}
|
||||
|
||||
const versionModelsResult = await getProjectVersionModels(supabase, projectId)
|
||||
if (!versionModelsResult.success || !versionModelsResult.data) {
|
||||
return {
|
||||
success: false,
|
||||
error: versionModelsResult.error,
|
||||
}
|
||||
}
|
||||
|
||||
let { draftModel, latestSavedModel } = versionModelsResult.data
|
||||
if (!latestSavedModel || latestSavedModel.version < targetVersionModel.version) {
|
||||
latestSavedModel = targetVersionModel
|
||||
}
|
||||
|
||||
if (!draftModel) {
|
||||
const nextDraftResult = await createNextDraftVersion(supabase, {
|
||||
projectId,
|
||||
projectName: project.name,
|
||||
sceneGraph: targetVersionModel.scene_graph,
|
||||
})
|
||||
|
||||
if (!nextDraftResult.success || !nextDraftResult.data) {
|
||||
return {
|
||||
success: false,
|
||||
error: nextDraftResult.error,
|
||||
}
|
||||
}
|
||||
|
||||
draftModel = nextDraftResult.data
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: buildVersionStatus({
|
||||
publishedVersion: targetVersionModel.version,
|
||||
draftModel,
|
||||
latestSavedModel,
|
||||
}),
|
||||
message: `Published version v${targetVersionModel.version}`,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to publish project model',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
/**
|
||||
* Hooks for project model (scene) loading and auto-saving
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { applySceneGraphToEditor } from '@pascal-app/editor'
|
||||
import { useProjectStore } from '../projects/store'
|
||||
import { getProjectModel, saveProjectModel } from './actions'
|
||||
|
||||
/** Debounce interval for cloud auto-save (ms). */
|
||||
const AUTOSAVE_DEBOUNCE_MS = 1_000
|
||||
|
||||
export { applySceneGraphToEditor }
|
||||
|
||||
/**
|
||||
* 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' | 'error'
|
||||
|
||||
// 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,58 +0,0 @@
|
||||
import type { PresetsAdapter } from '@pascal-app/editor'
|
||||
|
||||
export function createApiPresetsAdapter(isAuthenticated: boolean): PresetsAdapter {
|
||||
return {
|
||||
tabs: ['community', 'mine'],
|
||||
isAuthenticated,
|
||||
fetchPresets: async (type, tab) => {
|
||||
const res = await fetch(`/api/presets?type=${type}&tab=${tab}`)
|
||||
if (!res.ok) return []
|
||||
const json = await res.json()
|
||||
return json.presets ?? []
|
||||
},
|
||||
savePreset: async (type, name, data) => {
|
||||
const res = await fetch('/api/presets', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type, name, data }),
|
||||
})
|
||||
if (!res.ok) return null
|
||||
const json = await res.json()
|
||||
return json.preset?.id ?? null
|
||||
},
|
||||
overwritePreset: async (_type, id, data) => {
|
||||
await fetch(`/api/presets/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data }),
|
||||
})
|
||||
},
|
||||
renamePreset: async (id, name) => {
|
||||
await fetch(`/api/presets/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
},
|
||||
deletePreset: async (id) => {
|
||||
await fetch(`/api/presets/${id}`, { method: 'DELETE' })
|
||||
},
|
||||
togglePresetCommunity: async (id, current) => {
|
||||
await fetch(`/api/presets/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ is_community: !current }),
|
||||
})
|
||||
},
|
||||
uploadPresetThumbnail: async (presetId, blob) => {
|
||||
const res = await fetch(`/api/presets/${presetId}/thumbnail`, {
|
||||
method: 'POST',
|
||||
body: blob,
|
||||
headers: { 'Content-Type': 'image/png' },
|
||||
})
|
||||
if (!res.ok) return null
|
||||
const json = await res.json()
|
||||
return json.thumbnail_url ?? null
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 '@pascal-app/editor'
|
||||
import { useEditor } from '@pascal-app/editor'
|
||||
|
||||
/**
|
||||
* Upload a file directly to Supabase Storage via signed URL with progress tracking.
|
||||
* Runs entirely outside React — survives component unmounts.
|
||||
*/
|
||||
export function uploadAssetWithProgress(
|
||||
projectId: string,
|
||||
levelId: string,
|
||||
file: File,
|
||||
assetType: AssetType,
|
||||
) {
|
||||
const store = useUploadStore.getState()
|
||||
store.startUpload(levelId, assetType, file.name)
|
||||
|
||||
// Run async work without blocking the caller
|
||||
doUpload(projectId, levelId, file, assetType).catch(() => {
|
||||
// errors are already recorded in the store by doUpload
|
||||
})
|
||||
}
|
||||
|
||||
async function doUpload(
|
||||
projectId: string,
|
||||
levelId: string,
|
||||
file: File,
|
||||
assetType: AssetType,
|
||||
) {
|
||||
const store = () => useUploadStore.getState()
|
||||
|
||||
// Phase 1: Get signed URL
|
||||
const urlResult = await createAssetUploadUrl(
|
||||
projectId,
|
||||
file.name,
|
||||
file.type || 'application/octet-stream',
|
||||
assetType,
|
||||
)
|
||||
|
||||
if (!urlResult.success) {
|
||||
store().setError(levelId, urlResult.error)
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 2: Upload directly to Supabase via XHR (for progress)
|
||||
store().setStatus(levelId, 'uploading')
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const pct = Math.round((e.loaded / e.total) * 100)
|
||||
useUploadStore.getState().setProgress(levelId, pct)
|
||||
}
|
||||
})
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(new Error(`Upload failed: HTTP ${xhr.status}`))
|
||||
}
|
||||
})
|
||||
|
||||
xhr.addEventListener('error', () => reject(new Error('Network error during upload')))
|
||||
xhr.addEventListener('abort', () => reject(new Error('Upload aborted')))
|
||||
|
||||
xhr.open('PUT', urlResult.signedUrl)
|
||||
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')
|
||||
xhr.send(file)
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Upload failed'
|
||||
store().setError(levelId, msg)
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 3: Confirm upload and record in DB
|
||||
store().setStatus(levelId, 'confirming')
|
||||
|
||||
const confirmResult = await confirmAssetUpload(
|
||||
projectId,
|
||||
urlResult.assetId,
|
||||
urlResult.storageKey,
|
||||
file.name,
|
||||
file.type || null,
|
||||
assetType,
|
||||
)
|
||||
|
||||
if (!confirmResult.success) {
|
||||
store().setError(levelId, confirmResult.error)
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 4: Create scene node (works even if component is unmounted)
|
||||
const Schema = assetType === 'scan' ? ScanNodeSchema : GuideNodeSchema
|
||||
const node = Schema.parse({
|
||||
url: confirmResult.url,
|
||||
name: file.name,
|
||||
parentId: levelId,
|
||||
})
|
||||
useScene.getState().createNode(node, levelId as AnyNodeId)
|
||||
useEditor.getState().setSelectedReferenceId(node.id)
|
||||
|
||||
store().setResult(levelId, confirmResult.url)
|
||||
|
||||
// Auto-clear after a short delay so the UI shows "done" briefly
|
||||
setTimeout(() => {
|
||||
useUploadStore.getState().clearUpload(levelId)
|
||||
}, 1500)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export const isDevelopment =
|
||||
process.env.NODE_ENV === 'development' ||
|
||||
process.env.NEXT_PUBLIC_VERCEL_ENV === 'development'
|
||||
|
||||
export const isProduction =
|
||||
process.env.NODE_ENV === 'production' || process.env.NEXT_PUBLIC_VERCEL_ENV === 'production'
|
||||
|
||||
export const isPreview = process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview'
|
||||
|
||||
/**
|
||||
* Base URL for the application
|
||||
* Uses NEXT_PUBLIC_* variables which are available at build time
|
||||
*/
|
||||
export const BASE_URL = (() => {
|
||||
// Development: localhost
|
||||
if (isDevelopment) {
|
||||
return process.env.NEXT_PUBLIC_APP_URL || `http://localhost:${process.env.PORT || 3000}`
|
||||
}
|
||||
|
||||
// Preview deployments: use Vercel branch URL
|
||||
if (isPreview && process.env.NEXT_PUBLIC_VERCEL_URL) {
|
||||
return `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
|
||||
}
|
||||
|
||||
// Production: use custom domain or Vercel production 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')
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback (should never reach here in normal operation)
|
||||
return process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
||||
})()
|
||||
Vendored
-6
@@ -1,6 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: ['three', '@pascal-app/viewer', '@pascal-app/core', '@pascal-app/editor'],
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: '100mb',
|
||||
},
|
||||
},
|
||||
images: {
|
||||
unoptimized: process.env.NEXT_PUBLIC_ASSETS_CDN_URL?.startsWith('http://localhost') ?? false,
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '**',
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: '**',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"name": "community",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "set -a && . ../../.env 2>/dev/null; set +a; next dev --port 3001",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "biome lint",
|
||||
"check-types": "next typegen && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pascal-app/auth": "*",
|
||||
"@pascal-app/core": "*",
|
||||
"@pascal-app/db": "*",
|
||||
"@pascal-app/editor": "*",
|
||||
"@pascal-app/viewer": "*",
|
||||
"@react-google-maps/api": "^2.20.8",
|
||||
"@supabase/supabase-js": "^2.98.0",
|
||||
"@t3-oss/env-nextjs": "^0.13.10",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"@vercel/speed-insights": "^1.3.1",
|
||||
"@vercel/toolbar": "^0.2.2",
|
||||
"better-auth": "^1.5.2",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"motion": "^12.34.3",
|
||||
"next": "16.1.6",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"three": "^0.183.1",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/typescript-config": "*",
|
||||
"@types/node": "^22.19.12",
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "19.2.2",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user