remove community

This commit is contained in:
wass08
2026-03-13 09:07:46 +01:00
parent 3eeba322a8
commit f2501be2bf
693 changed files with 8264 additions and 35562 deletions
@@ -1,11 +0,0 @@
/**
* Better Auth API route handler
* Handles all /api/auth/* routes for authentication
*/
import { toNextJsHandler } from 'better-auth/next-js'
import { auth } from '@/lib/auth'
const { GET, POST } = toNextJsHandler(auth)
export { GET, POST }
@@ -1,89 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { headers } from 'next/headers'
import { auth } from '@/lib/auth'
import { supabaseAdmin } from '@/lib/supabase/server'
// PUT /api/presets/[id]
// Accepts any subset of: name, data, is_community
export async function PUT(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth.api.getSession({ headers: await headers() })
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const body = await req.json()
const { name, data, is_community } = body
if (name === undefined && data === undefined && is_community === undefined) {
return NextResponse.json({ error: 'Nothing to update' }, { status: 400 })
}
const existingResult = await supabaseAdmin
.from('presets')
.select('user_id')
.eq('id', id)
.single()
const existing = existingResult.data as { user_id: string | null } | null
if (!existing || existing.user_id !== session.user.id) {
return NextResponse.json({ error: 'Not found or forbidden' }, { status: 403 })
}
const updates: Record<string, unknown> = {}
if (name !== undefined) updates.name = name
if (data !== undefined) updates.data = data
if (is_community !== undefined) updates.is_community = is_community
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: preset, error } = await (supabaseAdmin as any)
.from('presets')
.update(updates)
.eq('id', id)
.select()
.single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ preset })
}
// DELETE /api/presets/[id]
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth.api.getSession({ headers: await headers() })
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const existingResult = await supabaseAdmin
.from('presets')
.select('user_id, thumbnail_url')
.eq('id', id)
.single()
const existing = existingResult.data as { user_id: string | null; thumbnail_url: string | null } | null
if (!existing || existing.user_id !== session.user.id) {
return NextResponse.json({ error: 'Not found or forbidden' }, { status: 403 })
}
// Delete thumbnail from storage if present
if (existing.thumbnail_url) {
const url = existing.thumbnail_url as string
const match = url.match(/preset-thumbnails\/(.+)$/)
if (match?.[1]) {
await supabaseAdmin.storage.from('preset-thumbnails').remove([match[1]])
}
}
const { error } = await supabaseAdmin.from('presets').delete().eq('id', id)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ success: true })
}
@@ -1,61 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { headers } from 'next/headers'
import { auth } from '@/lib/auth'
import { supabaseAdmin } from '@/lib/supabase/server'
// POST /api/presets/[id]/thumbnail
// Accepts a raw PNG blob, uploads to preset-thumbnails bucket, updates thumbnail_url
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth.api.getSession({ headers: await headers() })
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const existingResult = await supabaseAdmin
.from('presets')
.select('user_id')
.eq('id', id)
.single()
const existing = existingResult.data as { user_id: string | null } | null
if (!existing || existing.user_id !== session.user.id) {
return NextResponse.json({ error: 'Not found or forbidden' }, { status: 403 })
}
const blob = await req.blob()
const filename = `${id}/thumbnail.png`
const { data: uploadData, error: uploadError } = await supabaseAdmin.storage
.from('preset-thumbnails')
.upload(filename, blob, {
contentType: 'image/png',
upsert: true,
})
if (uploadError) {
return NextResponse.json({ error: uploadError.message }, { status: 500 })
}
const { data: urlData } = supabaseAdmin.storage
.from('preset-thumbnails')
.getPublicUrl(uploadData.path)
const thumbnailUrl = `${urlData.publicUrl}?t=${Date.now()}`
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { error: updateError } = await (supabaseAdmin as any)
.from('presets')
.update({ thumbnail_url: thumbnailUrl })
.eq('id', id)
if (updateError) {
return NextResponse.json({ error: updateError.message }, { status: 500 })
}
return NextResponse.json({ thumbnail_url: thumbnailUrl })
}
-81
View File
@@ -1,81 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { headers } from 'next/headers'
import { auth } from '@/lib/auth'
import { supabaseAdmin } from '@/lib/supabase/server'
import { createId } from '@pascal-app/db'
// GET /api/presets?type=door|window&tab=community|mine
export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl
const type = searchParams.get('type')
const tab = searchParams.get('tab') ?? 'community'
if (!type || (type !== 'door' && type !== 'window')) {
return NextResponse.json({ error: 'Invalid type' }, { status: 400 })
}
if (tab === 'mine') {
const session = await auth.api.getSession({ headers: await headers() })
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabaseAdmin
.from('presets')
.select('*')
.eq('type', type)
.eq('user_id', session.user.id)
.order('created_at', { ascending: false })
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ presets: data })
}
// community tab
const { data, error } = await supabaseAdmin
.from('presets')
.select('*')
.eq('type', type)
.eq('is_community', true)
.order('created_at', { ascending: false })
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ presets: data })
}
// POST /api/presets
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() })
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await req.json()
const { type, name, data, thumbnailUrl } = body
if (!type || !name || !data) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
}
if (type !== 'door' && type !== 'window') {
return NextResponse.json({ error: 'Invalid type' }, { status: 400 })
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: preset, error } = await (supabaseAdmin as any)
.from('presets')
.insert({
id: createId('preset'),
type,
name,
data,
thumbnail_url: thumbnailUrl ?? null,
user_id: session.user.id,
is_community: false,
})
.select()
.single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ preset }, { status: 201 })
}
@@ -1,42 +0,0 @@
'use client'
import Link from 'next/link'
import { useEffect } from 'react'
export default function EditorRouteError({
error,
reset,
}: Readonly<{
error: Error & { digest?: string }
reset: () => void
}>) {
useEffect(() => {
console.error('[editor-route] Unhandled editor error:', error)
}, [error])
return (
<div className="flex min-h-screen w-full items-center justify-center bg-background p-4 text-foreground">
<div className="w-full max-w-md rounded-2xl border border-border/60 bg-background p-6 shadow-xl">
<h1 className="text-lg font-semibold">Editor error</h1>
<p className="mt-2 text-sm text-muted-foreground">
We couldn&apos;t load this editor route. You can retry or return home.
</p>
<div className="mt-4 flex items-center gap-2">
<button
className="rounded-md border border-border bg-accent px-3 py-2 text-sm font-medium hover:bg-accent/80"
onClick={reset}
type="button"
>
Try again
</button>
<Link
className="rounded-md border border-border bg-background px-3 py-2 text-sm font-medium hover:bg-accent/40"
href="/"
>
Back to home
</Link>
</div>
</div>
</div>
)
}
@@ -1,22 +0,0 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Editor Workspace',
description: 'Edit your Pascal projects in a full 3D workspace.',
robots: {
index: false,
follow: false,
},
}
export default function EditorProjectLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<div style={{ cursor: "url('/cursor.svg') 4 2, default" }}>
{children}
</div>
)
}
@@ -1,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.
-256
View File
@@ -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%}
}
-106
View File
@@ -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>
)
}
-163
View File
@@ -1,163 +0,0 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { ImageResponse } from 'next/og'
import * as z from 'zod'
import { siteConfig } from '@/app/seo'
const ogImageSchema = z.object({
title: z.string().default(''),
description: z.string().default(''),
theme: z.enum(['light', 'dark']).default('dark'),
})
let sansFont: ArrayBuffer | null = null
let jakartaFont: ArrayBuffer | null = null
async function loadFonts() {
if (!(sansFont && jakartaFont)) {
const fontDir = join(process.cwd(), 'public', 'fonts')
const [sans, jakarta] = await Promise.all([
readFile(join(fontDir, 'geist-regular.ttf')),
readFile(join(fontDir, 'PlusJakartaSans-SemiBold.ttf')),
])
sansFont = sans.buffer.slice(sans.byteOffset, sans.byteOffset + sans.byteLength)
jakartaFont = jakarta.buffer.slice(jakarta.byteOffset, jakarta.byteOffset + jakarta.byteLength)
}
return { sans: sansFont, jakarta: jakartaFont }
}
function PascalLogo({ color = '#fff', size = 100 }: { color?: string; size?: number }) {
return (
<svg fill="none" height={size} style={{ display: 'flex' }} viewBox="0 0 100 100" width={size}>
<rect fill={color} height="40" width="20" y="60" />
<rect fill={color} height="40" width="20" x="40" y="30" />
<rect fill={color} height="40" width="20" x="80" />
</svg>
)
}
export async function GET(req: Request) {
try {
const url = new URL(req.url)
const values = ogImageSchema.parse(Object.fromEntries(url.searchParams))
const heading =
values.title.length > 140 ? `${values.title.substring(0, 140)}...` : values.title
const { theme } = values
const paint = theme === 'dark' ? '#fff' : '#000'
const logoColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.9)'
const fontSize = heading.length > 100 ? '70px' : '100px'
const showLargeLogo = !(values.title || values.description)
return new ImageResponse(
<div
style={{
color: paint,
background:
theme === 'dark'
? 'linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 50%, #0f0f0f 100%)'
: 'white',
}}
tw="flex relative flex-col w-full h-full items-start bg-cover"
>
{showLargeLogo ? (
<div tw="flex flex-col flex-1 py-10 px-12 h-full justify-center">
<div style={{ display: 'flex', marginBottom: 24 }}>
<PascalLogo color={logoColor} size={80} />
</div>
<div
style={{
fontFamily: 'jakarta-semibold',
fontWeight: 'bolder',
fontSize: '72px',
letterSpacing: '-0.02em',
color: paint,
display: 'flex',
}}
>
Pascal Editor
</div>
<div
style={{
fontFamily: 'sans-regular',
fontWeight: 'normal',
fontSize: '28px',
letterSpacing: '0.05em',
color: 'rgba(255, 255, 255, 0.6)',
marginTop: 16,
display: 'flex',
}}
>
{siteConfig.description}
</div>
</div>
) : (
<div tw="flex flex-col flex-1 py-10 px-12 h-full">
<div style={{ display: 'flex', marginBottom: 24 }}>
<PascalLogo color={logoColor} size={40} />
</div>
<div
style={{
fontFamily: 'jakarta-semibold',
fontWeight: 'bolder',
marginLeft: '-3px',
fontSize,
letterSpacing: '.05rem',
}}
tw="flex leading-[1.1] text-[80px] tracking-tighter font-sans mb-4"
>
{heading}
</div>
<div
style={{
fontFamily: 'sans-regular',
fontWeight: 'normal',
letterSpacing: '.1rem',
}}
tw="flex flex-1 text-[50px] tracking-tight font-sans"
>
{values.description}
</div>
</div>
)}
{!showLargeLogo && (
<div
style={{
fontFamily: 'sans-regular',
fontWeight: 'normal',
letterSpacing: '.1rem',
}}
tw="w-full pt-24 p-10 text-[30px] tracking-tight font-sans text-right w-full"
>
editor.pascal.app
</div>
)}
</div>,
{
width: 1200,
height: 630,
fonts: await loadFonts().then(({ sans, jakarta }) => [
{
name: 'sans-regular',
data: sans,
style: 'normal' as const,
weight: 400 as const,
},
{
name: 'jakarta-semibold',
data: jakarta,
style: 'normal' as const,
weight: 600 as const,
},
]),
},
)
} catch (error) {
console.log('error', error)
return new Response('Failed to generate image', {
status: 500,
})
}
}
-12
View File
@@ -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 />
}
-16
View File
@@ -1,16 +0,0 @@
import type { MetadataRoute } from 'next'
import { siteConfig } from './seo'
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: ['/', '/viewer/', '/u/'],
disallow: ['/api/', '/editor/', '/settings', '/_next/'],
},
],
sitemap: `${siteConfig.url}/sitemap.xml`,
host: siteConfig.url,
}
}
-22
View File
@@ -1,22 +0,0 @@
import { BASE_URL } from '@/lib/utils'
export const siteConfig = {
name: 'Pascal Editor',
description:
'Pascal Editor is an open-source 3D building editor for designing, editing, and sharing home projects.',
url: BASE_URL,
website: 'editor.pascal.app',
ogImage: '/og',
keywords: [
'Pascal Editor',
'open-source 3D editor',
'3D building editor',
'home design software',
'architecture editor',
'collaborative design',
],
twitterHandle: '@pascal_app',
links: {
github: 'https://github.com/pascalorg/editor',
},
} as const
-40
View File
@@ -1,40 +0,0 @@
export const dynamic = 'force-dynamic'
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/features/community/lib/auth/server'
import { getUserProfile, getConnectedAccounts } from '@/features/community/lib/auth/actions'
import { SettingsPage } from '@/features/community/components/settings-page'
export const metadata: Metadata = {
title: 'Account Settings',
description: 'Manage your Pascal Editor account profile and connected providers.',
robots: {
index: false,
follow: false,
},
}
export default async function Settings() {
const session = await getSession()
if (!session?.user) {
redirect('/')
}
const [profile, connectedAccounts] = await Promise.all([
getUserProfile(),
getConnectedAccounts(),
])
return (
<SettingsPage
user={session.user}
currentUsername={profile?.username ?? null}
currentGithubUrl={profile?.githubUrl ?? null}
currentXUrl={profile?.xUrl ?? null}
currentYoutubeUrl={profile?.youtubeUrl ?? null}
currentEmailNotifications={profile?.emailNotifications ?? true}
connectedAccounts={connectedAccounts}
/>
)
}
-22
View File
@@ -1,22 +0,0 @@
import type { MetadataRoute } from 'next'
import { siteConfig } from './seo'
export default function sitemap(): MetadataRoute.Sitemap {
const currentDate = new Date()
const baseUrl = siteConfig.url
return [
{
url: baseUrl,
lastModified: currentDate,
changeFrequency: 'daily',
priority: 1,
},
{
url: `${baseUrl}/viewer/demo_1`,
lastModified: currentDate,
changeFrequency: 'weekly',
priority: 0.7,
},
]
}
-45
View File
@@ -1,45 +0,0 @@
export const dynamic = 'force-dynamic'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getPublicProfile } from '@/features/community/lib/auth/actions'
import { getPublicProjectsByUserId } from '@/features/community/lib/projects/actions'
import { PublicProfilePage } from '@/features/community/components/public-profile-page'
export async function generateMetadata({
params,
}: {
params: Promise<{ username: string }>
}): Promise<Metadata> {
const { username } = await params
return {
title: `${username}'s Projects`,
description: `Public projects shared by @${username} on Pascal Editor.`,
alternates: {
canonical: `/u/${encodeURIComponent(username)}`,
},
}
}
export default async function ProfilePage({
params,
}: {
params: Promise<{ username: string }>
}) {
const { username } = await params
const profileResult = await getPublicProfile(username)
if (!profileResult.success || !profileResult.data) {
notFound()
}
const projectsResult = await getPublicProjectsByUserId(profileResult.data.id)
return (
<PublicProfilePage
profile={profileResult.data}
projects={projectsResult.data ?? []}
/>
)
}
@@ -1,300 +0,0 @@
'use client'
import {
type AnyNodeId,
type CollectionId,
type Control,
type ControlValue,
type ItemNode,
useInteractive,
useScene,
} from '@pascal-app/core'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { cn } from '@/lib/utils'
// ─── Shared control derivation ───────────────────────────────────────────────
type ItemControlRef = { itemId: AnyNodeId; controlIndex: number }
type SharedControlDef = {
kind: 'toggle' | 'slider' | 'temperature'
label?: string
min?: number
max?: number
step?: number
unit?: string
refs: ItemControlRef[]
}
function deriveSharedControls(items: ItemNode[]): SharedControlDef[] {
if (items.length === 0) return []
const result: SharedControlDef[] = []
for (const kind of ['toggle', 'slider', 'temperature'] as const) {
const refs: ItemControlRef[] = []
let ref: Control | null = null
let allHave = true
for (const item of items) {
const idx = item.asset.interactive!.controls.findIndex((c) => c.kind === kind)
if (idx === -1) { allHave = false; break }
refs.push({ itemId: item.id, controlIndex: idx })
if (!ref) ref = item.asset.interactive!.controls[idx]!
}
if (!allHave || !ref) continue
const def: SharedControlDef = { kind, label: ref.label, refs }
if ('min' in ref) { def.min = ref.min; def.max = ref.max }
if ('step' in ref) def.step = (ref as { step?: number }).step
if ('unit' in ref) def.unit = (ref as { unit?: string }).unit
result.push(def)
}
return result
}
// ─── Shared control widget ───────────────────────────────────────────────────
function SharedWidget({ def, value, onChange }: { def: SharedControlDef; value: ControlValue; onChange: (v: ControlValue) => void }) {
if (def.kind === 'toggle') {
return (
<button
type="button"
onClick={() => onChange(!value)}
className={cn(
'flex h-7 w-full items-center justify-center rounded-md px-3 text-xs font-medium transition-colors',
value ? 'bg-green-500/20 text-green-400' : 'bg-white/10 text-muted-foreground hover:text-foreground',
)}
>
{def.label ?? (value ? 'On' : 'Off')}
</button>
)
}
return (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>{def.label ?? def.kind}</span>
<span>{value}{def.kind === 'temperature' ? '°' : ''}{def.unit ? ` ${def.unit}` : ''}</span>
</div>
<input
type="range"
min={def.min}
max={def.max}
step={def.step ?? 1}
value={value as number}
onChange={(e) => onChange(Number(e.target.value))}
onPointerDown={(e) => e.stopPropagation()}
className="w-full accent-white"
/>
</div>
)
}
// ─── Individual item control widget ──────────────────────────────────────────
function ItemWidget({ control, value, onChange }: { control: Control; value: ControlValue; onChange: (v: ControlValue) => void }) {
if (control.kind === 'toggle') {
return (
<button
type="button"
onClick={() => onChange(!value)}
className={cn(
'flex h-6 w-full items-center justify-center rounded px-2 text-[10px] font-medium transition-colors',
value ? 'bg-green-500/20 text-green-400' : 'bg-white/5 text-muted-foreground hover:text-foreground',
)}
>
{control.label ?? (value ? 'On' : 'Off')}
</button>
)
}
return (
<div className="flex flex-col gap-0.5">
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>{control.label ?? control.kind}</span>
<span>{value}{control.kind === 'temperature' ? '°' : ''}{'unit' in control && control.unit ? ` ${control.unit}` : ''}</span>
</div>
<input
type="range"
min={'min' in control ? control.min : 0}
max={'max' in control ? control.max : 100}
step={'step' in control ? (control as { step?: number }).step ?? 1 : 1}
value={value as number}
onChange={(e) => onChange(Number(e.target.value))}
onPointerDown={(e) => e.stopPropagation()}
className="w-full accent-white"
/>
</div>
)
}
// ─── Collection row ───────────────────────────────────────────────────────────
function CollectionRow({ collectionId }: { collectionId: CollectionId }) {
const collection = useScene((s) => s.collections[collectionId])
const interactiveItems = useScene(
useShallow((s) =>
(collection?.nodeIds ?? [])
.map((id) => s.nodes[id])
.filter((n): n is ItemNode => n?.type === 'item' && !!n.asset.interactive)
),
)
const allItems = useInteractive((s) => s.items)
const controlValuesByItem = useMemo(
() => Object.fromEntries(interactiveItems.map((n) => [n.id, allItems[n.id]?.controlValues ?? []])),
[allItems, interactiveItems],
)
const setControlValue = useInteractive((s) => s.setControlValue)
const [expanded, setExpanded] = useState(false)
const [expandedItemIds, setExpandedItemIds] = useState<Set<AnyNodeId>>(new Set())
if (!collection) return null
const sharedControls = deriveSharedControls(interactiveItems)
const getSharedValue = (def: SharedControlDef): ControlValue => {
if (def.kind === 'toggle') {
return def.refs.every(({ itemId, controlIndex }) => Boolean(controlValuesByItem[itemId]?.[controlIndex]))
}
const first = def.refs[0]!
return controlValuesByItem[first.itemId]?.[first.controlIndex] ?? 0
}
const setSharedValue = (def: SharedControlDef, value: ControlValue) => {
for (const { itemId, controlIndex } of def.refs) {
setControlValue(itemId, controlIndex, value)
}
}
const toggleItemExpand = (id: AnyNodeId) => {
setExpandedItemIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
return (
<div>
{/* Header */}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex w-full items-center gap-2 px-3 py-2.5 hover:bg-white/5 transition-colors"
>
<span
className="h-2.5 w-2.5 shrink-0 rounded-sm border border-white/20"
style={{ backgroundColor: collection.color ?? '#6366f1' }}
/>
<span className="flex-1 min-w-0 text-xs font-medium text-foreground truncate text-left">
{collection.name}
</span>
{interactiveItems.length > 0 && (
<span className="text-[10px] text-muted-foreground shrink-0">
{interactiveItems.length}
</span>
)}
{expanded
? <ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
: <ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />}
</button>
{/* Expanded */}
{expanded && (
<div className="pb-1">
{interactiveItems.length === 0 ? (
<p className="px-3 pb-2 text-[11px] text-muted-foreground">No interactive items.</p>
) : (
<>
{/* Shared controls */}
{sharedControls.length > 0 && (
<div className="px-3 pt-0.5 pb-2.5 border-b border-border/30">
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mb-2">All</p>
<div className="flex flex-col gap-2">
{sharedControls.map((def, i) => (
<SharedWidget
key={i}
def={def}
value={getSharedValue(def)}
onChange={(v) => setSharedValue(def, v)}
/>
))}
</div>
</div>
)}
{/* Individual items */}
{interactiveItems.map((item) => {
const isItemExpanded = expandedItemIds.has(item.id)
const controls = item.asset.interactive!.controls
const values = controlValuesByItem[item.id] ?? []
return (
<div key={item.id}>
<button
type="button"
onClick={() => toggleItemExpand(item.id)}
className="flex w-full items-center gap-1.5 px-3 py-1.5 hover:bg-white/5 transition-colors"
>
{isItemExpanded
? <ChevronDown className="h-2.5 w-2.5 shrink-0 text-muted-foreground/50" />
: <ChevronRight className="h-2.5 w-2.5 shrink-0 text-muted-foreground/50" />}
<span className="flex-1 min-w-0 text-[11px] text-muted-foreground truncate text-left">
{item.name || item.asset.name}
</span>
</button>
{isItemExpanded && (
<div className="px-3 pb-2 flex flex-col gap-1.5">
{controls.map((control, i) => (
<ItemWidget
key={i}
control={control}
value={values[i] ?? false}
onChange={(v) => setControlValue(item.id, i, v)}
/>
))}
</div>
)}
</div>
)
})}
</>
)}
</div>
)}
</div>
)
}
// ─── Main panel ───────────────────────────────────────────────────────────────
export function CollectionsPanel() {
const collectionIds = useScene(
useShallow((s) => Object.keys(s.collections) as CollectionId[]),
)
if (collectionIds.length === 0) return null
return (
<div className="pointer-events-auto flex flex-col rounded-2xl border border-border/40 bg-background/95 shadow-lg backdrop-blur-xl overflow-hidden w-56">
<div className="px-3 py-2 border-b border-border/40 shrink-0">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Collections</span>
</div>
<div className="overflow-y-auto max-h-[70vh] no-scrollbar divide-y divide-border/30">
{collectionIds.map((id) => (
<CollectionRow key={id} collectionId={id} />
))}
</div>
</div>
)
}
-42
View File
@@ -1,42 +0,0 @@
'use client'
import Link from 'next/link'
import { useEffect } from 'react'
export default function ViewerRouteError({
error,
reset,
}: Readonly<{
error: Error & { digest?: string }
reset: () => void
}>) {
useEffect(() => {
console.error('[viewer-route] Unhandled viewer error:', error)
}, [error])
return (
<div className="flex min-h-screen w-full items-center justify-center bg-background p-4 text-foreground">
<div className="w-full max-w-md rounded-2xl border border-border/60 bg-background p-6 shadow-xl">
<h1 className="text-lg font-semibold">Viewer error</h1>
<p className="mt-2 text-sm text-muted-foreground">
We couldn&apos;t load this project view. You can retry without leaving the app.
</p>
<div className="mt-4 flex items-center gap-2">
<button
className="rounded-md border border-border bg-accent px-3 py-2 text-sm font-medium hover:bg-accent/80"
onClick={reset}
type="button"
>
Try again
</button>
<Link
className="rounded-md border border-border bg-background px-3 py-2 text-sm font-medium hover:bg-accent/40"
href="/"
>
Back to home
</Link>
</div>
</div>
</div>
)
}
-14
View File
@@ -1,14 +0,0 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Project Viewer',
description: 'View and share 3D projects built with Pascal Editor.',
}
export default function ViewerLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return children
}
-190
View File
@@ -1,190 +0,0 @@
'use client'
import { initSpatialGridSync, useScene } from '@pascal-app/core'
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
import Link from 'next/link'
import { useParams } from 'next/navigation'
import { useEffect, useState } from 'react'
import { ErrorBoundary } from '@/components/ui/primitives/error-boundary'
import { SceneLoader } from '@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 }
-58
View File
@@ -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,
})
-198
View File
@@ -1,198 +0,0 @@
# Community Feature
This directory contains the **optional** community features (cloud synchronization and authentication) for the Pascal Editor. This feature is specific to the Pascal platform and can be safely removed if you're using the editor standalone.
## What This Does
The community feature provides:
- **Authentication** - Sign in with magic link via Better Auth
- **Property Management** - Create and manage properties with Google Maps address search
- **Scene Loading** - Automatically load property scenes from the database when a property is selected
- **Auto-Save** - Automatically save scene changes to the database (2-second debounce)
- **Database Sync** - Save and load editor state from a PostgreSQL database via Supabase
## Architecture
```
features/community/
├── lib/
│ ├── auth/
│ │ ├── client.ts # Re-exports from @pascal-app/auth
│ │ ├── server.ts # Server-side session handling
│ │ └── hooks.ts # useAuth React hook
│ ├── properties/
│ │ ├── actions.ts # Server actions for CRUD operations
│ │ ├── types.ts # TypeScript types for properties
│ │ ├── hooks.ts # Property React hooks
│ │ └── store.ts # Zustand store for property state
│ ├── models/
│ │ ├── actions.ts # Scene model CRUD operations
│ │ └── hooks.ts # Scene loading and auto-save hooks
│ ├── database/
│ │ └── server.ts # Re-exports from @pascal-app/db
│ └── utils/
│ └── id-generator.ts # nanoid-based ID generation
├── components/
│ ├── cloud-save-button.tsx # Main UI entry point (top-right button)
│ ├── sign-in-dialog.tsx # Magic link sign-in dialog
│ ├── profile-dropdown.tsx # User profile menu
│ ├── property-dropdown.tsx # Property selector dropdown
│ ├── new-property-dialog.tsx # Create new property dialog
│ └── google-address-search.tsx # Google Maps autocomplete
└── README.md # This file
```
## How It Works
### Authentication Flow
1. User clicks "Save to cloud" button
2. Signs in with magic link (email-based, no password)
3. Better Auth session is stored in cookies
4. Server actions validate session using Better Auth API
### Property Management
1. User creates a property with a real-world address (Google Maps)
2. Address and property are saved to PostgreSQL via Supabase
3. Properties are associated with the authenticated user
4. User can switch between properties
### Scene Management
1. When a property is selected, its scene is loaded from `properties_models` table
2. If no scene exists, loads default empty scene
3. Scene changes are auto-saved every 2 seconds (debounced)
4. Updates existing model (highest version) instead of creating new ones
5. Scene graph includes all nodes and hierarchy
### Database Integration
- Uses Supabase (PostgreSQL) for database access
- Better Auth manages authentication tables directly
- Server actions use service role key to bypass RLS
- Permissions enforced by filtering on `owner_id`
- Tables: `users`, `sessions`, `properties`, `properties_addresses`, `properties_models`
## Required Environment Variables
```bash
# Database Connection
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
# Supabase Configuration
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key_here
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key_here
# Better Auth
BETTER_AUTH_SECRET=<generate_with_openssl_rand_base64_32>
BETTER_AUTH_URL=http://localhost:3000
# Google Maps API Key (for address search)
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your_google_maps_key_here
```
Generate `BETTER_AUTH_SECRET`:
```bash
openssl rand -base64 32
```
## Dependencies
The community feature requires these packages:
```json
{
"better-auth": "^1.4.18",
"@supabase/supabase-js": "^2.95.3",
"@react-google-maps/api": "^2.20.8",
"nanoid": "^5.1.6"
}
```
## How to Remove (For Open Source Users)
If you want to use the editor without community features:
### 1. Delete this directory
```bash
rm -rf features/community
```
### 2. Remove the CloudSaveButton from the editor
Edit `components/editor/index.tsx`:
```diff
- import { CloudSaveButton } from '@/features/community/components/cloud-save-button'
export default function Editor() {
return (
<div className="w-full h-full">
<ActionMenu />
<PanelManager />
- <CloudSaveButton />
```
### 3. Remove dependencies (optional)
Edit `package.json`:
```diff
- "better-auth": "^1.4.18",
- "@supabase/supabase-js": "^2.95.3",
- "@react-google-maps/api": "^2.20.8",
- "nanoid": "^5.1.6"
```
### 4. Remove environment variables
Delete from `.env.local` and `.env.example`:
```diff
- NEXT_PUBLIC_API_URL=...
- NEXT_PUBLIC_SUPABASE_URL=...
- NEXT_PUBLIC_SUPABASE_ANON_KEY=...
- SUPABASE_SERVICE_ROLE_KEY=...
- NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=...
```
That's it! The editor will work as a standalone application without any cloud features.
## Backend Requirements
This feature requires:
- Supabase local development instance
- PostgreSQL database with the following tables:
- `users` - User accounts (Better Auth)
- `sessions` - Authentication sessions (Better Auth)
- `verification_tokens` - Magic link tokens (Better Auth)
- `properties` - Property records
- `properties_addresses` - Property addresses
- `properties_models` - Scene graph models
- Database migrations are managed in `supabase/migrations/`
## Development
To work on this feature:
1. Install dependencies:
```bash
bun install
```
2. Start Supabase local development:
```bash
bun db:start
```
3. Run database migrations:
```bash
bun db:reset
```
4. Configure all environment variables in `apps/editor/.env.local`
5. Run the editor: `bun dev`
The editor will be available at `http://localhost:3000`.
For detailed setup instructions, see [SETUP.md](../../../SETUP.md) in the root directory.
## Notes
- This feature uses **server actions** (Next.js App Router) for all database operations
- Authentication is handled by **Better Auth** with magic link support
- Better Auth server is configured in `packages/auth` and mounted at `/api/auth/*`
- The editor queries the database directly using Supabase with service role key
- IDs are generated using nanoid with custom alphabet
- Scene state is managed with a **Zustand store** for reliable property switching
- Scene changes are auto-saved with 2-second debouncing to the currently selected property
@@ -1,57 +0,0 @@
'use client'
import { Home } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
import { useAuth } from '../lib/auth/hooks'
import { useProjectStore } from '../lib/projects/store'
import { ProfileDropdown } from './profile-dropdown'
/**
* CloudSaveButton - Shows authentication state and project management
*
* Guest: Shows "Home" button
* Authenticated: Shows ProfileDropdown
*/
export function CloudSaveButton() {
const { isAuthenticated, isLoading } = useAuth()
const initialize = useProjectStore(state => state.initialize)
const router = useRouter()
// Initialize project store when authenticated
useEffect(() => {
if (isAuthenticated) {
initialize()
}
}, [isAuthenticated, initialize])
if (isLoading) {
return (
<div className="pointer-events-auto">
<div className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 shadow-lg backdrop-blur-md">
<div className="h-4 w-4 animate-pulse rounded-full bg-muted" />
</div>
</div>
)
}
if (!isAuthenticated) {
return (
<div className="pointer-events-auto">
<button
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 text-sm font-medium shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground"
onClick={() => router.push('/')}
>
<Home className="h-4 w-4" />
Home
</button>
</div>
)
}
return (
<div className="pointer-events-auto">
<ProfileDropdown />
</div>
)
}
@@ -1,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&apos;t have any projects yet.</p>
</div>
) : (
<ProjectGrid
projects={userProjects}
onProjectClick={handleProjectClick}
onViewClick={handleViewProject}
showOwner={false}
canEdit
onUpdate={() => {
if (!authLoading) {
getUserProjects().then((result) => {
if (result.success) {
setUserProjects(result.data || [])
}
})
}
}}
/>
)}
</section>
)}
{/* Sign-in CTA for unauthenticated users */}
{!isAuthenticated && (
<section className="rounded-2xl border border-border bg-neutral-50 dark:bg-neutral-900/50 px-8 py-12 text-center">
<h2 className="text-2xl font-semibold mb-2">Build with Pascal</h2>
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
Create and share 3D architectural projects. Sign in to get started.
</p>
<button
onClick={() => setIsSignInDialogOpen(true)}
className="rounded-lg bg-primary px-6 py-2.5 text-primary-foreground font-medium hover:bg-primary/90 transition-colors"
>
Sign in to create a project
</button>
</section>
)}
{/* Public Projects Section */}
<section>
<h2 className="text-xl font-semibold mb-6">Community Projects</h2>
{publicProjects.length > 0 ? (
<ProjectGrid
projects={publicProjects}
onProjectClick={handleViewProject}
showOwner
/>
) : (
<div className="text-center py-12 text-muted-foreground">No public projects yet</div>
)}
</section>
</main>
<HubFooter />
<SignInDialog open={isSignInDialogOpen} onOpenChange={setIsSignInDialogOpen} />
<NewProjectDialog
open={isNewProjectDialogOpen}
onOpenChange={setIsNewProjectDialogOpen}
onSuccess={handleProjectCreated}
/>
</div>
)
}
@@ -1,19 +0,0 @@
'use client'
import { Plus } from 'lucide-react'
interface CreateProjectButtonProps {
onCreateProject: () => void
}
export function CreateProjectButton({ onCreateProject }: CreateProjectButtonProps) {
return (
<button
onClick={onCreateProject}
className="flex items-center gap-2 rounded-full bg-primary px-5 py-2 text-primary-foreground hover:bg-primary/90 transition-colors"
>
<Plus className="w-4 h-4" />
<span>Create Project</span>
</button>
)
}
@@ -1,113 +0,0 @@
'use client'
import { Autocomplete, LoadScript } from '@react-google-maps/api'
import { MapPin } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
const libraries: ('places')[] = ['places']
interface AddressComponents {
streetNumber?: string
route?: string
city?: string
state?: string
postalCode?: string
country?: string
center: [number, number]
formattedAddress: string
}
interface GoogleAddressSearchProps {
onAddressSelect: (address: AddressComponents) => void
disabled?: boolean
}
export function GoogleAddressSearch({ onAddressSelect, disabled }: GoogleAddressSearchProps) {
const [autocomplete, setAutocomplete] = useState<google.maps.places.Autocomplete | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
// Fix Google Maps autocomplete dropdown z-index and pointer events to work with dialog
useEffect(() => {
const style = document.createElement('style')
style.textContent = `
.pac-container {
z-index: 9999 !important;
pointer-events: auto !important;
}
`
document.head.appendChild(style)
return () => {
document.head.removeChild(style)
}
}, [])
if (!apiKey) {
return (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
<p className="font-medium">Google Maps API Key Missing</p>
<p className="mt-1 text-xs">
Add NEXT_PUBLIC_GOOGLE_MAPS_API_KEY to your .env.local file
</p>
</div>
)
}
const onLoad = (autocompleteInstance: google.maps.places.Autocomplete) => {
setAutocomplete(autocompleteInstance)
}
const onPlaceChanged = () => {
if (!autocomplete) return
const place = autocomplete.getPlace()
if (!place.geometry?.location || !place.address_components) return
const components: AddressComponents = {
center: [place.geometry.location.lng(), place.geometry.location.lat()],
formattedAddress: place.formatted_address || '',
}
// Parse address components
for (const component of place.address_components) {
const types = component.types
if (types.includes('street_number')) {
components.streetNumber = component.long_name
} else if (types.includes('route')) {
components.route = component.long_name
} else if (types.includes('locality')) {
components.city = component.long_name
} else if (types.includes('administrative_area_level_1')) {
components.state = component.short_name
} else if (types.includes('postal_code')) {
components.postalCode = component.long_name
} else if (types.includes('country')) {
components.country = component.short_name
}
}
onAddressSelect(components)
}
return (
<LoadScript googleMapsApiKey={apiKey} libraries={libraries}>
<div className="space-y-2">
<label className="flex items-center gap-2 font-medium text-sm">
<MapPin className="h-4 w-4" />
Project Address
</label>
<Autocomplete onLoad={onLoad} onPlaceChanged={onPlaceChanged}>
<input
ref={inputRef}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={disabled}
placeholder="Search for an address..."
type="text"
/>
</Autocomplete>
</div>
</LoadScript>
)
}
@@ -1,69 +0,0 @@
import Link from 'next/link'
function GitHubIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
)
}
function NpmIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 256 256">
<rect width="256" height="256" rx="0" fill="#C12127" />
<polygon points="48,48 208,48 208,208 176,208 176,80 128,80 128,208 48,208" fill="#FFFFFF" />
</svg>
)
}
export function HubFooter() {
return (
<footer className="border-t border-border mt-16">
<div className="container mx-auto px-6 py-8">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<p className="text-muted-foreground text-sm">
Editor by{' '}
<a
href="https://pascal.app"
target="_blank"
rel="noopener noreferrer"
className="text-foreground hover:underline"
>
Pascal
</a>
</p>
<div className="flex items-center gap-4">
<a
href="https://github.com/pascalorg/editor"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors text-sm"
>
<GitHubIcon className="h-4 w-4" />
GitHub
</a>
<a
href="https://www.npmjs.com/package/@pascal-app/viewer"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors text-sm"
>
<NpmIcon className="h-4 w-4" />
Viewer
</a>
<a
href="https://www.npmjs.com/package/@pascal-app/core"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors text-sm"
>
<NpmIcon className="h-4 w-4" />
Core
</a>
</div>
</div>
</div>
</footer>
)
}
@@ -1,142 +0,0 @@
'use client'
import { X } from 'lucide-react'
import { useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/primitives/dialog'
import { Switch } from '@/components/ui/primitives/switch'
import { useScene } from '@pascal-app/core'
import { createProject } from '../lib/projects/actions'
interface NewProjectDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSuccess?: (projectId: string) => void
}
/**
* NewProjectDialog - Dialog for creating a new project
*/
export function NewProjectDialog({ open, onOpenChange, onSuccess }: NewProjectDialogProps) {
const [projectName, setProjectName] = useState('')
const [isPrivate, setIsPrivate] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
const name = projectName.trim() || 'Untitled Project'
setIsCreating(true)
try {
// Get the default scene graph
useScene.getState().clearScene()
const { nodes, rootNodeIds } = useScene.getState()
const sceneGraph = { nodes, rootNodeIds }
const result = await createProject({ name, isPrivate, sceneGraph })
if (result.success && result.data) {
onOpenChange(false)
setProjectName('')
setIsPrivate(false)
onSuccess?.(result.data.id)
} else {
setError(result.error || 'Failed to create project')
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An unexpected error occurred')
} finally {
setIsCreating(false)
}
}
const handleClose = () => {
if (!isCreating) {
onOpenChange(false)
setProjectName('')
setIsPrivate(false)
setError(null)
}
}
return (
<Dialog open={open} onOpenChange={handleClose} modal={false}>
<DialogContent
className="sm:max-w-125"
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Create New Project</DialogTitle>
<button
className="absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 disabled:pointer-events-none"
disabled={isCreating}
onClick={handleClose}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
{/* Project Name */}
<div>
<label htmlFor="project-name" className="text-sm font-medium">
Project Name
</label>
<input
id="project-name"
type="text"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
placeholder="My Project"
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
disabled={isCreating}
autoFocus
/>
</div>
{/* Privacy Toggle */}
<div className="flex items-center justify-between rounded-md border border-border p-3">
<div>
<div className="font-medium text-sm">Privacy</div>
<div className="text-xs text-muted-foreground">
{isPrivate ? 'Only you can view this project' : 'Anyone can view this project'}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Public</span>
<Switch checked={!isPrivate} onCheckedChange={(checked) => setIsPrivate(!checked)} />
</div>
</div>
{error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
{error}
</div>
)}
<div className="flex justify-end gap-2">
<button
className="rounded-md border border-input px-4 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
disabled={isCreating}
type="button"
onClick={handleClose}
>
Cancel
</button>
<button
className="rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm transition-colors hover:bg-primary/90 disabled:opacity-50"
disabled={isCreating}
type="submit"
>
{isCreating ? 'Creating...' : 'Create Project'}
</button>
</div>
</form>
</DialogContent>
</Dialog>
)
}
@@ -1,89 +0,0 @@
'use client'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { useAuth } from '../lib/auth/hooks'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/primitives/dropdown-menu'
function getInitials(name: string): string {
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2)
}
/**
* ProfileDropdown - User profile menu with avatar, settings, and sign out
*/
export function ProfileDropdown() {
const { user, signOut } = useAuth()
const router = useRouter()
const handleSignOut = async () => {
await signOut()
}
const initials = user?.name ? getInitials(user.name) : user?.email?.[0]?.toUpperCase() || 'U'
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex h-9 w-9 items-center justify-center overflow-hidden rounded-full bg-muted font-medium text-xs shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_3px_rgba(0,0,0,0.1)] transition-opacity hover:opacity-80 focus:outline-none"
type="button"
>
{user?.image ? (
<Image
src={user.image}
alt={user.name || 'Profile'}
width={36}
height={36}
className="h-full w-full object-cover"
/>
) : (
initials
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="flex items-center gap-3 px-2 py-2">
{user?.image ? (
<Image
src={user.image}
alt={user.name || 'Profile'}
width={32}
height={32}
className="h-8 w-8 rounded-full object-cover"
/>
) : (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted font-medium text-xs">
{initials}
</div>
)}
<div className="min-w-0 flex-1">
{user?.name && <div className="truncate font-medium text-sm">{user.name}</div>}
{user?.email && (
<div className="truncate text-muted-foreground text-xs">{user.email}</div>
)}
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer" onClick={() => router.push('/settings')}>
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer" variant="destructive" onClick={handleSignOut}>
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -1,110 +0,0 @@
'use client'
import { Check, ChevronDown, Home, Plus } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/primitives/dropdown-menu'
import { cn } from '@/lib/utils'
import { useProjectStore } from '../lib/projects/store'
import { NewProjectDialog } from './new-project-dialog'
/**
* ProjectDropdown - Shows active project and allows switching between projects
* Note: useProjectScene() is called in the Editor component, not here.
* Having it in both places caused duplicate subscriptions and 2x server action calls.
*/
export function ProjectDropdown() {
const router = useRouter()
// Use project store
const projects = useProjectStore((state) => state.projects)
const activeProject = useProjectStore((state) => state.activeProject)
const isLoading = useProjectStore((state) => state.isLoading)
const setActiveProject = useProjectStore((state) => state.setActiveProject)
const fetchProjects = useProjectStore((state) => state.fetchProjects)
const [isNewProjectDialogOpen, setIsNewProjectDialogOpen] = useState(false)
const handleProjectSelect = async (projectId: string) => {
await setActiveProject(projectId)
}
const handleAddNew = () => {
setIsNewProjectDialogOpen(true)
}
const handleProjectCreated = (projectId: string) => {
router.push(`/editor/${projectId}`)
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex h-9 items-center gap-2 rounded-lg border border-border bg-background/95 px-3 text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50 focus:outline-none"
disabled={isLoading}
type="button"
>
<Home className="h-4 w-4" />
<span className="max-w-[150px] truncate">
{activeProject
? activeProject.name
: projects.length > 0
? 'Select Project'
: 'Add Project'}
</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[280px]">
{/* Project list */}
{projects.length > 0 ? (
<div className="max-h-[300px] overflow-y-auto">
{projects.map((project) => (
<DropdownMenuItem
className={cn(
'cursor-pointer text-sm',
activeProject?.id === project.id && 'cursor-default bg-accent',
)}
key={project.id}
onClick={() =>
activeProject?.id === project.id ? null : handleProjectSelect(project.id)
}
>
<div className="flex w-full items-center justify-between gap-2">
<div className="flex-1 truncate font-medium">{project.name}</div>
{activeProject?.id === project.id && (
<Check className="h-4 w-4 shrink-0 text-primary" />
)}
</div>
</DropdownMenuItem>
))}
</div>
) : (
<div className="px-2 py-3 text-center text-muted-foreground text-sm">
No projects yet
</div>
)}
{/* Add new project option */}
<DropdownMenuItem className="cursor-pointer" onClick={handleAddNew}>
<Plus className="mr-2 h-4 w-4" />
<span>Add new project</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<NewProjectDialog
open={isNewProjectDialogOpen}
onOpenChange={setIsNewProjectDialogOpen}
onSuccess={handleProjectCreated}
/>
</>
)
}
@@ -1,218 +0,0 @@
'use client'
import { Eye, Heart, Settings } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { useAuth } from '../lib/auth/hooks'
import { getUserProjectLikes, toggleProjectLike } from '../lib/projects/actions'
import type { Project } from '../lib/projects/types'
import { ProjectSettingsDialog } from './project-settings-dialog'
interface ProjectGridProps {
projects: Project[]
onProjectClick: (id: string) => void
onViewClick?: (id: string) => void
showOwner: boolean
canEdit?: boolean
onUpdate?: () => void
}
export function ProjectGrid({
projects,
onProjectClick,
onViewClick,
showOwner,
canEdit = false,
onUpdate,
}: ProjectGridProps) {
const { isAuthenticated } = useAuth()
const [settingsProject, setSettingsProject] = useState<Project | null>(null)
const [userLikes, setUserLikes] = useState<Record<string, boolean>>({})
const [likeCounts, setLikeCounts] = useState<Record<string, number>>({})
useEffect(() => {
const counts: Record<string, number> = {}
projects.forEach((proj) => {
counts[proj.id] = proj.likes
})
setLikeCounts(counts)
}, [projects])
useEffect(() => {
if (!isAuthenticated) {
setUserLikes({})
return
}
const projectIds = projects.map((p) => p.id)
if (projectIds.length === 0) return
getUserProjectLikes(projectIds).then((result) => {
if (result.success && result.data) {
setUserLikes(result.data)
}
})
}, [projects, isAuthenticated])
const handleSettingsClick = (e: React.MouseEvent, project: Project) => {
e.stopPropagation()
setSettingsProject(project)
}
const handleViewClick = (e: React.MouseEvent, projectId: string) => {
e.stopPropagation()
onViewClick?.(projectId)
}
const handleLikeClick = async (e: React.MouseEvent, projectId: string) => {
e.stopPropagation()
if (!isAuthenticated) return
const wasLiked = userLikes[projectId] || false
const currentCount = likeCounts[projectId] || 0
setUserLikes((prev) => ({ ...prev, [projectId]: !wasLiked }))
setLikeCounts((prev) => ({
...prev,
[projectId]: wasLiked ? currentCount - 1 : currentCount + 1,
}))
const result = await toggleProjectLike(projectId)
if (result.success && result.data) {
const data = result.data
setUserLikes((prev) => ({ ...prev, [projectId]: data.liked }))
setLikeCounts((prev) => ({ ...prev, [projectId]: data.likes }))
} else {
setUserLikes((prev) => ({ ...prev, [projectId]: wasLiked }))
setLikeCounts((prev) => ({ ...prev, [projectId]: currentCount }))
}
}
return (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
{projects.map((project) => {
const owner = project.owner
return (
<div
key={project.id}
onClick={() => onProjectClick(project.id)}
className="group text-left cursor-pointer"
>
{/* Thumbnail card */}
<div className="relative aspect-[4/3] rounded-xl rounded-smooth-xl bg-neutral-50 overflow-hidden shadow-[0_1px_3px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.04)] transition-shadow group-hover:shadow-[0_4px_12px_rgba(0,0,0,0.08),0_0_0_1px_rgba(0,0,0,0.04)]">
{project.thumbnail_url ? (
<img
src={project.thumbnail_url}
alt={project.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-sm">
No preview
</div>
)}
{canEdit && (
<div className="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{onViewClick && (
<button
onClick={(e) => handleViewClick(e, project.id)}
className="bg-background/80 hover:bg-background rounded-md p-1.5"
aria-label="View"
title="View in viewer mode"
>
<Eye className="w-4 h-4" />
</button>
)}
<button
onClick={(e) => handleSettingsClick(e, project)}
className="bg-background/80 hover:bg-background rounded-md p-1.5"
aria-label="Settings"
title="Project settings"
>
<Settings className="w-4 h-4" />
</button>
</div>
)}
</div>
{/* Info row below the card */}
<div className="flex items-center gap-3 mt-3">
{showOwner && owner ? (
<Link
href={owner.username ? `/u/${owner.username}` : '#'}
onClick={(e) => e.stopPropagation()}
className="shrink-0"
>
{owner.image ? (
<img
src={owner.image}
alt={owner.name}
className="w-9 h-9 rounded-full object-cover shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_3px_rgba(0,0,0,0.1)]"
/>
) : (
<div className="w-9 h-9 rounded-full bg-neutral-100 flex items-center justify-center text-sm font-medium shadow-[0_0_0_1px_rgba(0,0,0,0.06)]">
{owner.name?.[0]?.toUpperCase() || '?'}
</div>
)}
</Link>
) : null}
<div className="min-w-0 flex-1">
<h3 className="font-medium text-sm truncate">{project.name}</h3>
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
{showOwner && owner && (
<>
<Link
href={owner.username ? `/u/${owner.username}` : '#'}
onClick={(e) => e.stopPropagation()}
className="hover:text-foreground transition-colors truncate"
>
{owner.username || owner.name}
</Link>
<span className="shrink-0">·</span>
</>
)}
<div className="flex items-center gap-0.5 shrink-0">
<Eye className="w-3.5 h-3.5" />
<span>{project.views}</span>
</div>
<span className="shrink-0">·</span>
<button
onClick={(e) => handleLikeClick(e, project.id)}
className="flex items-center gap-0.5 shrink-0 hover:text-red-500 transition-colors"
disabled={!isAuthenticated}
>
<Heart
className={`w-3.5 h-3.5 ${
userLikes[project.id] ? 'fill-red-500 text-red-500' : ''
}`}
/>
<span>{likeCounts[project.id] ?? project.likes}</span>
</button>
</div>
</div>
</div>
</div>
)
})}
</div>
{settingsProject && (
<ProjectSettingsDialog
project={settingsProject}
open={!!settingsProject}
onOpenChange={(open) => !open && setSettingsProject(null)}
onUpdate={onUpdate}
onDelete={() => {
setSettingsProject(null)
onUpdate?.()
}}
/>
)}
</>
)
}
@@ -1,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
}
-50
View File
@@ -1,50 +0,0 @@
export async function register() {
// Only run on the server
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { createClient } = await import('@supabase/supabase-js')
const url = process.env.NEXT_PUBLIC_SUPABASE_URL
const key = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!url || !key) return
const supabase = createClient(url, key)
const { data: buckets } = await supabase.storage.listBuckets()
const bucketNames = new Set(buckets?.map((b) => b.name))
if (!bucketNames.has('avatars')) {
await supabase.storage.createBucket('avatars', {
public: true,
fileSizeLimit: 5 * 1024 * 1024, // 5MB
allowedMimeTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
})
console.log('Created "avatars" storage bucket')
}
if (!bucketNames.has('project-thumbnails')) {
await supabase.storage.createBucket('project-thumbnails', {
public: true,
fileSizeLimit: 10 * 1024 * 1024, // 10MB (matches uploadProjectThumbnail validation)
allowedMimeTypes: ['image/png'],
})
console.log('Created "project-thumbnails" storage bucket')
}
if (!bucketNames.has('project-assets')) {
await supabase.storage.createBucket('project-assets', {
public: true,
fileSizeLimit: 500 * 1024 * 1024, // 500MB for GLB/GLTF scans
})
console.log('Created "project-assets" storage bucket')
}
if (!bucketNames.has('preset-thumbnails')) {
await supabase.storage.createBucket('preset-thumbnails', {
public: true,
fileSizeLimit: 5 * 1024 * 1024, // 5MB
allowedMimeTypes: ['image/png', 'image/jpeg', 'image/webp'],
})
console.log('Created "preset-thumbnails" storage bucket')
}
}
}
-49
View File
@@ -1,49 +0,0 @@
import { createAuth } from '@pascal-app/auth/server'
import { db } from '@pascal-app/db'
import { Resend } from 'resend'
import { env } from '@/env.mjs'
import { BASE_URL } from './utils'
// Initialize Resend only if API key is available
const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null
export const auth = createAuth({
db,
appName: 'Pascal Editor',
baseURL: BASE_URL,
secret: env.BETTER_AUTH_SECRET,
googleClientId: env.GOOGLE_CLIENT_ID,
googleClientSecret: env.GOOGLE_CLIENT_SECRET,
sendMagicLink: async ({ email, url }) => {
if (!resend) {
console.log(`[DEV] Magic link for ${email}: ${url}`)
return
}
try {
await resend.emails.send({
from: 'Pascal <noreply@pascal.app>',
to: email,
subject: 'Sign in to Pascal Editor',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2>Sign in to Pascal Editor</h2>
<p>Click the button below to sign in to your account:</p>
<a href="${url}" style="display: inline-block; background-color: #000; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 6px; margin: 20px 0;">
Sign In
</a>
<p style="color: #666; font-size: 14px;">This link will expire in 5 minutes.</p>
<p style="color: #666; font-size: 14px;">If you didn't request this email, you can safely ignore it.</p>
</div>
`,
})
console.log(`✓ Magic link email sent to ${email}`)
} catch (error) {
console.error('Failed to send magic link email:', error)
throw error
}
},
})
export type Session = typeof auth.$Infer.Session
export type User = typeof auth.$Infer.Session.user
-15
View File
@@ -1,15 +0,0 @@
/**
* Navigation helpers for project-based routing
*/
export function getEditorUrl(projectId: string): string {
return `/editor/${projectId}`
}
export function getViewerUrl(projectId: string): string {
return `/viewer/${projectId}`
}
export function getHomeUrl(): string {
return '/'
}
-58
View File
@@ -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
},
}
}
-11
View File
@@ -1,11 +0,0 @@
import { createClient } from '@supabase/supabase-js'
import type { SupabaseDatabase } from '@pascal-app/db'
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
/**
* Supabase client for client-side use with anon key
* Uses Row Level Security (RLS) policies
*/
export const supabase = createClient<SupabaseDatabase>(supabaseUrl, supabaseAnonKey)
-39
View File
@@ -1,39 +0,0 @@
import { createClient } from '@supabase/supabase-js'
import type { SupabaseDatabase } from '@pascal-app/db'
import { env } from '@/env.mjs'
/**
* Safety check: warn loudly if a Vercel preview deployment is using
* the production Supabase instance. This catches misconfigured branching.
*/
if (
process.env.VERCEL_ENV === 'preview' &&
process.env.SUPABASE_URL &&
env.NEXT_PUBLIC_SUPABASE_URL === process.env.SUPABASE_URL
) {
// If the Supabase integration set a branch-specific SUPABASE_URL,
// it should differ from NEXT_PUBLIC_SUPABASE_URL (which comes from the
// generic env vars pointing at production). When they match, the
// integration likely skipped branch creation for this PR.
console.warn(
'⚠️ [supabase] Preview deployment appears to be using the PRODUCTION ' +
'Supabase instance. Supabase branching may not be configured for this PR. ' +
'See: https://supabase.com/docs/guides/deployment/branching',
)
}
/**
* Supabase client for server-side use with service role key
* Bypasses Row Level Security (RLS) - use with caution
* Always filter by user_id to enforce permissions
*/
export const supabaseAdmin = createClient<SupabaseDatabase>(
env.NEXT_PUBLIC_SUPABASE_URL,
env.SUPABASE_SERVICE_ROLE_KEY,
{
auth: {
persistSession: false,
autoRefreshToken: false,
},
},
)
-123
View File
@@ -1,123 +0,0 @@
import {
type AnyNodeId,
ScanNode as ScanNodeSchema,
GuideNode as GuideNodeSchema,
useScene,
} from '@pascal-app/core'
import {
createAssetUploadUrl,
confirmAssetUpload,
type AssetType,
} from '@/features/community/lib/assets/actions'
import { useUploadStore } from '@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)
}
-44
View File
@@ -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'
})()
-6
View File
@@ -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.
-25
View File
@@ -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
-47
View File
@@ -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"
}
}
-5
View File
@@ -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.

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