splitting editor and community
This commit is contained in:
parent
5108636d49
commit
7359c1fcbf
103
apps/community/app/editor/[projectId]/page.tsx
Normal file
103
apps/community/app/editor/[projectId]/page.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
||||
BIN
apps/community/app/favicon.ico
Normal file
BIN
apps/community/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
apps/community/app/fonts/GeistMonoVF.woff
Normal file
BIN
apps/community/app/fonts/GeistMonoVF.woff
Normal file
Binary file not shown.
BIN
apps/community/app/fonts/GeistVF.woff
Normal file
BIN
apps/community/app/fonts/GeistVF.woff
Normal file
Binary file not shown.
256
apps/community/app/globals.css
Normal file
256
apps/community/app/globals.css
Normal file
@ -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%}
|
||||
}
|
||||
106
apps/community/app/layout.tsx
Normal file
106
apps/community/app/layout.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
||||
12
apps/community/app/page.tsx
Normal file
12
apps/community/app/page.tsx
Normal file
@ -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,
|
||||
1
apps/community/components/ui/command-palette/index.tsx
Normal file
1
apps/community/components/ui/command-palette/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export { useCommandPalette } from '@pascal-app/editor'
|
||||
58
apps/community/env.mjs
Normal file
58
apps/community/env.mjs
Normal file
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
481
apps/community/features/community/components/project-header.tsx
Normal file
481
apps/community/features/community/components/project-header.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 {
|
||||
@ -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.
|
||||
@ -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
|
||||
50
apps/community/instrumentation.ts
Normal file
50
apps/community/instrumentation.ts
Normal file
@ -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.
|
||||
44
apps/community/lib/utils.ts
Normal file
44
apps/community/lib/utils.ts
Normal file
@ -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'
|
||||
})()
|
||||
6
apps/community/next-env.d.ts
vendored
Normal file
6
apps/community/next-env.d.ts
vendored
Normal file
@ -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.
|
||||
25
apps/community/next.config.ts
Normal file
25
apps/community/next.config.ts
Normal file
@ -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
|
||||
47
apps/community/package.json
Normal file
47
apps/community/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
5
apps/community/postcss.config.mjs
Normal file
5
apps/community/postcss.config.mjs
Normal file
@ -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.
BIN
apps/community/public/audios/radios/classic/Glass Atrium.mp3
Normal file
BIN
apps/community/public/audios/radios/classic/Glass Atrium.mp3
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
apps/community/public/audios/sfx/grid_snap.mp3
Normal file
BIN
apps/community/public/audios/sfx/grid_snap.mp3
Normal file
Binary file not shown.
BIN
apps/community/public/audios/sfx/item_delete.mp3
Normal file
BIN
apps/community/public/audios/sfx/item_delete.mp3
Normal file
Binary file not shown.
BIN
apps/community/public/audios/sfx/item_pick.mp3
Normal file
BIN
apps/community/public/audios/sfx/item_pick.mp3
Normal file
Binary file not shown.
BIN
apps/community/public/audios/sfx/item_place.mp3
Normal file
BIN
apps/community/public/audios/sfx/item_place.mp3
Normal file
Binary file not shown.
BIN
apps/community/public/audios/sfx/item_rotate.mp3
Normal file
BIN
apps/community/public/audios/sfx/item_rotate.mp3
Normal file
Binary file not shown.
BIN
apps/community/public/audios/sfx/structure_build.mp3
Normal file
BIN
apps/community/public/audios/sfx/structure_build.mp3
Normal file
Binary file not shown.
BIN
apps/community/public/audios/sfx/structure_delete.mp3
Normal file
BIN
apps/community/public/audios/sfx/structure_delete.mp3
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user