splitting editor and community
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Editor, SceneLoader } from '@pascal-app/editor'
|
||||
import type { SceneGraph } from '@pascal-app/editor'
|
||||
import { CommunityAppMenu } from '@/features/community/components/community-app-menu'
|
||||
import { ProjectHeader } from '@/features/community/components/project-header'
|
||||
import { useAuth } from '@/features/community/lib/auth/hooks'
|
||||
import { getProjectModel, saveProjectModel } from '@/features/community/lib/models/actions'
|
||||
import { uploadProjectThumbnail, updateProjectVisibility } from '@/features/community/lib/projects/actions'
|
||||
import { useProjectStore } from '@/features/community/lib/projects/store'
|
||||
import { uploadAssetWithProgress } from '@/lib/upload-asset'
|
||||
import { deleteProjectAssetByUrl } from '@/features/community/lib/assets/actions'
|
||||
|
||||
export default function EditorPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
const { isAuthenticated, isLoading } = useAuth()
|
||||
const setActiveProject = useProjectStore((state) => state.setActiveProject)
|
||||
const setAutosaveStatus = useProjectStore((state) => state.setAutosaveStatus)
|
||||
const isProjectLoading = useProjectStore((state) => state.isLoading)
|
||||
const isVersionPreviewMode = useProjectStore((state) => state.isVersionPreviewMode)
|
||||
const activeProject = useProjectStore((state) => state.activeProject)
|
||||
const router = useRouter()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return
|
||||
if (!isAuthenticated) {
|
||||
router.replace('/')
|
||||
return
|
||||
}
|
||||
if (projectId) {
|
||||
setActiveProject(projectId)
|
||||
}
|
||||
}, [projectId, isAuthenticated, isLoading, setActiveProject, router])
|
||||
|
||||
const onLoad = useCallback(async (): Promise<SceneGraph | null> => {
|
||||
const result = await getProjectModel(projectId)
|
||||
return result.success ? (result.data?.model?.scene_graph ?? null) : null
|
||||
}, [projectId])
|
||||
|
||||
const onSave = useCallback(async (scene: SceneGraph) => {
|
||||
await saveProjectModel(projectId, scene)
|
||||
}, [projectId])
|
||||
|
||||
const onThumbnailCapture = useCallback(async (blob: Blob) => {
|
||||
const result = await uploadProjectThumbnail(projectId, blob)
|
||||
if (result.success) {
|
||||
useProjectStore.getState().updateActiveThumbnail(result.data.thumbnail_url)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
if (!mounted || isLoading) {
|
||||
return <SceneLoader fullScreen />
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full max-w-screen">
|
||||
<div className="relative h-full w-full">
|
||||
<Editor
|
||||
appMenuButton={<CommunityAppMenu />}
|
||||
sidebarTop={<ProjectHeader />}
|
||||
onLoad={onLoad}
|
||||
onSave={onSave}
|
||||
onSaveStatusChange={setAutosaveStatus}
|
||||
isVersionPreviewMode={isVersionPreviewMode}
|
||||
isLoading={isProjectLoading}
|
||||
onThumbnailCapture={onThumbnailCapture}
|
||||
settingsPanelProps={{
|
||||
projectId,
|
||||
projectVisibility: activeProject ? {
|
||||
isPrivate: activeProject.is_private ?? false,
|
||||
showScansPublic: activeProject.show_scans_public ?? true,
|
||||
showGuidesPublic: activeProject.show_guides_public ?? true,
|
||||
} : undefined,
|
||||
onVisibilityChange: async (field, value) => {
|
||||
await updateProjectVisibility(projectId, { [field]: value })
|
||||
},
|
||||
}}
|
||||
sitePanelProps={{
|
||||
projectId,
|
||||
onUploadAsset: (pid, levelId, file, type) => {
|
||||
uploadAssetWithProgress(pid, levelId, file, type)
|
||||
},
|
||||
onDeleteAsset: (pid, url) => {
|
||||
deleteProjectAssetByUrl(pid, url)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,256 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@source "../../../packages/editor/src";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--font-sans: var(--font-barlow), var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
--font-barlow: var(--font-barlow), var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-barlow), sans-serif;
|
||||
--font-mono: var(--font-geist-mono), monospace;
|
||||
--font-barlow: var(--font-barlow), sans-serif;
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(0.998 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(0.998 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(0.998 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.205 0 0); /* ~171717 */
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.235 0 0); /* slightly lighter than background (0.205) but darker than previous (0.269) */
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.235 0 0); /* matching accent */
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
button,
|
||||
[role="button"],
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/* Apple-style smooth corners (squircle) — progressive enhancement */
|
||||
.rounded-smooth {
|
||||
border-radius: var(--radius-lg);
|
||||
corner-shape: squircle;
|
||||
}
|
||||
.rounded-smooth-xl {
|
||||
border-radius: var(--radius-xl);
|
||||
corner-shape: squircle;
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loaders */
|
||||
.pascal-loader-1 {
|
||||
width: 45px;
|
||||
aspect-ratio: 1;
|
||||
--c:no-repeat linear-gradient(currentColor 0 0);
|
||||
background: var(--c), var(--c), var(--c);
|
||||
animation:
|
||||
pascal-l1-1 1s infinite,
|
||||
pascal-l1-2 1s infinite;
|
||||
}
|
||||
@keyframes pascal-l1-1 {
|
||||
0%,100% {background-size:20% 100%}
|
||||
33%,66% {background-size:20% 20%}
|
||||
}
|
||||
@keyframes pascal-l1-2 {
|
||||
0%,33% {background-position: 0 0,50% 50%,100% 100%}
|
||||
66%,100% {background-position: 100% 0,50% 50%,0 100%}
|
||||
}
|
||||
|
||||
.pascal-loader-2 {
|
||||
width: 45px;
|
||||
aspect-ratio: .75;
|
||||
--c: no-repeat linear-gradient(currentColor 0 0);
|
||||
background:
|
||||
var(--c) 0% 50%,
|
||||
var(--c) 50% 50%,
|
||||
var(--c) 100% 50%;
|
||||
background-size: 20% 50%;
|
||||
animation: pascal-l2 1s infinite linear;
|
||||
}
|
||||
@keyframes pascal-l2 {
|
||||
20% {background-position: 0% 0% ,50% 50% ,100% 50% }
|
||||
40% {background-position: 0% 100%,50% 0% ,100% 50% }
|
||||
60% {background-position: 0% 50% ,50% 100%,100% 0% }
|
||||
80% {background-position: 0% 50% ,50% 50% ,100% 100%}
|
||||
}
|
||||
|
||||
.pascal-loader-3 {
|
||||
width: 45px;
|
||||
aspect-ratio: .75;
|
||||
--c:no-repeat linear-gradient(currentColor 0 0);
|
||||
background:
|
||||
var(--c) 0% 100%,
|
||||
var(--c) 50% 100%,
|
||||
var(--c) 100% 100%;
|
||||
background-size: 20% 65%;
|
||||
animation: pascal-l3 1s infinite linear;
|
||||
}
|
||||
@keyframes pascal-l3 {
|
||||
16.67% {background-position: 0% 0% ,50% 100%,100% 100%}
|
||||
33.33% {background-position: 0% 0% ,50% 0% ,100% 100%}
|
||||
50% {background-position: 0% 0% ,50% 0% ,100% 0% }
|
||||
66.67% {background-position: 0% 100%,50% 0% ,100% 0% }
|
||||
83.33% {background-position: 0% 100%,50% 100%,100% 0% }
|
||||
}
|
||||
|
||||
.pascal-loader-4 {
|
||||
width: 45px;
|
||||
aspect-ratio: 1;
|
||||
--c:no-repeat linear-gradient(currentColor 0 0);
|
||||
background: var(--c), var(--c), var(--c);
|
||||
animation:
|
||||
pascal-l4-1 1s infinite,
|
||||
pascal-l4-2 1s infinite;
|
||||
}
|
||||
@keyframes pascal-l4-1 {
|
||||
0%,100% {background-size:20% 100%}
|
||||
33%,66% {background-size:20% 40%}
|
||||
}
|
||||
@keyframes pascal-l4-2 {
|
||||
0%,33% {background-position: 0 0,50% 100%,100% 100%}
|
||||
66%,100% {background-position: 100% 0,0 100%,50% 100%}
|
||||
}
|
||||
|
||||
.pascal-loader-5 {
|
||||
width: 45px;
|
||||
aspect-ratio: 1;
|
||||
--c:no-repeat linear-gradient(currentColor 0 0);
|
||||
background: var(--c), var(--c), var(--c);
|
||||
animation:
|
||||
pascal-l5-1 1s infinite,
|
||||
pascal-l5-2 1s infinite;
|
||||
}
|
||||
@keyframes pascal-l5-1 {
|
||||
0%,100% {background-size:20% 100%}
|
||||
33%,66% {background-size:20% 40%}
|
||||
}
|
||||
@keyframes pascal-l5-2 {
|
||||
0%,33% {background-position: 0 0 ,50% 100%,100% 0}
|
||||
66%,100% {background-position: 0 100%,50% 0 ,100% 100%}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Script from 'next/script'
|
||||
import localFont from 'next/font/local'
|
||||
import { Barlow } from 'next/font/google'
|
||||
import { Analytics } from '@vercel/analytics/react'
|
||||
import { SpeedInsights } from '@vercel/speed-insights/next'
|
||||
import { VercelToolbar } from '@vercel/toolbar/next'
|
||||
import { UsernameGate } from '@/features/community/components/username-gate'
|
||||
import { siteConfig } from './seo'
|
||||
import './globals.css'
|
||||
|
||||
const geistSans = localFont({
|
||||
src: './fonts/GeistVF.woff',
|
||||
variable: '--font-geist-sans',
|
||||
})
|
||||
const geistMono = localFont({
|
||||
src: './fonts/GeistMonoVF.woff',
|
||||
variable: '--font-geist-mono',
|
||||
})
|
||||
|
||||
const barlow = Barlow({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-barlow',
|
||||
display: 'swap',
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(siteConfig.url),
|
||||
title: {
|
||||
default: siteConfig.name,
|
||||
template: '%s | Pascal Editor',
|
||||
},
|
||||
description: siteConfig.description,
|
||||
applicationName: siteConfig.name,
|
||||
keywords: [...siteConfig.keywords],
|
||||
authors: [{ name: 'Pascal', url: 'https://pascal.app' }],
|
||||
creator: 'Pascal',
|
||||
publisher: 'Pascal',
|
||||
alternates: {
|
||||
canonical: '/',
|
||||
},
|
||||
icons: [{ rel: 'icon', url: '/favicon.ico' }],
|
||||
openGraph: {
|
||||
title: siteConfig.name,
|
||||
description: siteConfig.description,
|
||||
url: siteConfig.url,
|
||||
siteName: siteConfig.name,
|
||||
images: [{ url: siteConfig.ogImage, alt: 'Pascal Editor' }],
|
||||
locale: 'en_US',
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: siteConfig.name,
|
||||
description: siteConfig.description,
|
||||
creator: siteConfig.twitterHandle,
|
||||
images: [siteConfig.ogImage],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
'max-image-preview': 'large',
|
||||
'max-snippet': -1,
|
||||
'max-video-preview': -1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
const shouldShowToolbar = process.env.NODE_ENV === 'development'
|
||||
|
||||
return (
|
||||
<html lang="en" className={`${geistSans.variable} ${geistMono.variable} ${barlow.variable}`}>
|
||||
<head>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<>
|
||||
<Script
|
||||
src="//unpkg.com/react-scan/dist/auto.global.js"
|
||||
crossOrigin="anonymous"
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
<Script
|
||||
src="//unpkg.com/react-grab/dist/index.global.js"
|
||||
crossOrigin="anonymous"
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</head>
|
||||
<body className="font-sans">
|
||||
<UsernameGate>{children}</UsernameGate>
|
||||
<Analytics />
|
||||
<SpeedInsights />
|
||||
{shouldShowToolbar && <VercelToolbar />}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Metadata } from 'next'
|
||||
import CommunityHub from '@/features/community/components/community-hub'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Community Projects',
|
||||
description:
|
||||
'Create and share 3D home projects with Pascal Editor, the open-source building editor.',
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return <CommunityHub />
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import Link from 'next/link'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ErrorBoundary } from '@/components/ui/primitives/error-boundary'
|
||||
import { SceneLoader } from '@/components/ui/scene-loader'
|
||||
import { SceneLoader } from '@pascal-app/editor'
|
||||
import {
|
||||
getProjectModelPublic,
|
||||
incrementProjectViews,
|
||||
@@ -0,0 +1 @@
|
||||
export { useCommandPalette } from '@pascal-app/editor'
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 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,
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
"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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
+2
-3
@@ -5,7 +5,6 @@
|
||||
|
||||
'use server'
|
||||
|
||||
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
|
||||
import { createServerSupabaseClient } from '../database/server'
|
||||
import { getSession } from '../auth/server'
|
||||
import { createId } from '../utils/id-generator'
|
||||
@@ -13,8 +12,8 @@ import type { ActionResult } from '../projects/actions'
|
||||
import { isSceneGraphEmpty } from './scene-graph-utils'
|
||||
|
||||
export interface SceneGraph {
|
||||
nodes: Record<AnyNodeId, AnyNode>
|
||||
rootNodeIds: AnyNodeId[]
|
||||
nodes: Record<string, unknown>
|
||||
rootNodeIds: string[]
|
||||
}
|
||||
|
||||
export interface ProjectModel {
|
||||
+3
-48
@@ -5,60 +5,15 @@
|
||||
'use client'
|
||||
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import useEditor from '@/store/use-editor'
|
||||
import { applySceneGraphToEditor } from '@pascal-app/editor'
|
||||
import { useProjectStore } from '../projects/store'
|
||||
import { getProjectModel, saveProjectModel, type SceneGraph } from './actions'
|
||||
import { getProjectModel, saveProjectModel } from './actions'
|
||||
|
||||
/** Debounce interval for cloud auto-save (ms). */
|
||||
const AUTOSAVE_DEBOUNCE_MS = 1_000
|
||||
|
||||
function syncEditorSelectionFromCurrentScene() {
|
||||
const sceneNodes = useScene.getState().nodes as Record<string, any>
|
||||
const sceneRootIds = useScene.getState().rootNodeIds
|
||||
const siteNode = sceneRootIds[0] ? sceneNodes[sceneRootIds[0]] : null
|
||||
const resolve = (child: any) =>
|
||||
typeof child === 'string' ? sceneNodes[child] : child
|
||||
const firstBuilding = siteNode?.children?.map(resolve).find((n: any) => n?.type === 'building')
|
||||
const firstLevel = firstBuilding?.children?.map(resolve).find((n: any) => n?.type === 'level')
|
||||
|
||||
if (firstBuilding && firstLevel) {
|
||||
useViewer.getState().setSelection({
|
||||
buildingId: firstBuilding.id,
|
||||
levelId: firstLevel.id,
|
||||
selectedIds: [],
|
||||
zoneId: null,
|
||||
})
|
||||
useEditor.getState().setPhase('structure')
|
||||
useEditor.getState().setStructureLayer('elements')
|
||||
|
||||
// Auto-select the wall tool if the level is empty (e.g., brand new project)
|
||||
if (!firstLevel.children || firstLevel.children.length === 0) {
|
||||
useEditor.getState().setMode('build')
|
||||
useEditor.getState().setTool('wall')
|
||||
}
|
||||
} else {
|
||||
useEditor.getState().setPhase('site')
|
||||
useViewer.getState().setSelection({
|
||||
buildingId: null,
|
||||
levelId: null,
|
||||
selectedIds: [],
|
||||
zoneId: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) {
|
||||
if (sceneGraph?.nodes && sceneGraph.rootNodeIds) {
|
||||
const { nodes, rootNodeIds } = sceneGraph
|
||||
useScene.getState().setScene(nodes, rootNodeIds)
|
||||
} else {
|
||||
useScene.getState().clearScene()
|
||||
}
|
||||
|
||||
syncEditorSelectionFromCurrentScene()
|
||||
}
|
||||
export { applySceneGraphToEditor }
|
||||
|
||||
/**
|
||||
* Load the scene when a project becomes active.
|
||||
+1
-1
@@ -12,7 +12,7 @@ import {
|
||||
|
||||
interface ProjectStore {
|
||||
// Autosave lifecycle for the latest draft scene
|
||||
autosaveStatus: 'idle' | 'pending' | 'saving' | 'saved' | 'paused'
|
||||
autosaveStatus: 'idle' | 'pending' | 'saving' | 'saved' | 'paused' | 'error'
|
||||
|
||||
// State
|
||||
activeProject: Project | null
|
||||
@@ -0,0 +1,50 @@
|
||||
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')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
confirmAssetUpload,
|
||||
type AssetType,
|
||||
} from '@/features/community/lib/assets/actions'
|
||||
import { useUploadStore } from '@/store/use-upload'
|
||||
import useEditor from '@/store/use-editor'
|
||||
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.
|
||||
@@ -0,0 +1,44 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export const isDevelopment =
|
||||
process.env.NODE_ENV === 'development' ||
|
||||
process.env.NEXT_PUBLIC_VERCEL_ENV === 'development'
|
||||
|
||||
export const isProduction =
|
||||
process.env.NODE_ENV === 'production' || process.env.NEXT_PUBLIC_VERCEL_ENV === 'production'
|
||||
|
||||
export const isPreview = process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview'
|
||||
|
||||
/**
|
||||
* Base URL for the application
|
||||
* Uses NEXT_PUBLIC_* variables which are available at build time
|
||||
*/
|
||||
export const BASE_URL = (() => {
|
||||
// Development: localhost
|
||||
if (isDevelopment) {
|
||||
return process.env.NEXT_PUBLIC_APP_URL || `http://localhost:${process.env.PORT || 3000}`
|
||||
}
|
||||
|
||||
// Preview deployments: use Vercel branch URL
|
||||
if (isPreview && process.env.NEXT_PUBLIC_VERCEL_URL) {
|
||||
return `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
|
||||
}
|
||||
|
||||
// Production: use custom domain or Vercel production URL
|
||||
if (isProduction) {
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_APP_URL ||
|
||||
(process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL
|
||||
? `https://${process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL}`
|
||||
: 'https://editor.pascal.app')
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback (should never reach here in normal operation)
|
||||
return process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
||||
})()
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <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.
|
||||
@@ -0,0 +1,25 @@
|
||||
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
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user