splitting editor and community

This commit is contained in:
wass08
2026-03-11 12:16:26 +01:00
parent 5108636d49
commit 7359c1fcbf
586 changed files with 6477 additions and 1546 deletions
@@ -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.
+256
View 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
View 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
View 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 { useParams } from 'next/navigation'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { ErrorBoundary } from '@/components/ui/primitives/error-boundary' import { ErrorBoundary } from '@/components/ui/primitives/error-boundary'
import { SceneLoader } from '@/components/ui/scene-loader' import { SceneLoader } from '@pascal-app/editor'
import { import {
getProjectModelPublic, getProjectModelPublic,
incrementProjectViews, incrementProjectViews,
@@ -0,0 +1 @@
export { useCommandPalette } from '@pascal-app/editor'
+58
View 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} />
</>
);
}
@@ -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' 'use server'
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
import { createServerSupabaseClient } from '../database/server' import { createServerSupabaseClient } from '../database/server'
import { getSession } from '../auth/server' import { getSession } from '../auth/server'
import { createId } from '../utils/id-generator' import { createId } from '../utils/id-generator'
@@ -13,8 +12,8 @@ import type { ActionResult } from '../projects/actions'
import { isSceneGraphEmpty } from './scene-graph-utils' import { isSceneGraphEmpty } from './scene-graph-utils'
export interface SceneGraph { export interface SceneGraph {
nodes: Record<AnyNodeId, AnyNode> nodes: Record<string, unknown>
rootNodeIds: AnyNodeId[] rootNodeIds: string[]
} }
export interface ProjectModel { export interface ProjectModel {
@@ -5,60 +5,15 @@
'use client' 'use client'
import { useScene } from '@pascal-app/core' import { useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import useEditor from '@/store/use-editor' import { applySceneGraphToEditor } from '@pascal-app/editor'
import { useProjectStore } from '../projects/store' import { useProjectStore } from '../projects/store'
import { getProjectModel, saveProjectModel, type SceneGraph } from './actions' import { getProjectModel, saveProjectModel } from './actions'
/** Debounce interval for cloud auto-save (ms). */ /** Debounce interval for cloud auto-save (ms). */
const AUTOSAVE_DEBOUNCE_MS = 1_000 const AUTOSAVE_DEBOUNCE_MS = 1_000
function syncEditorSelectionFromCurrentScene() { export { applySceneGraphToEditor }
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()
}
/** /**
* Load the scene when a project becomes active. * Load the scene when a project becomes active.
@@ -12,7 +12,7 @@ import {
interface ProjectStore { interface ProjectStore {
// Autosave lifecycle for the latest draft scene // Autosave lifecycle for the latest draft scene
autosaveStatus: 'idle' | 'pending' | 'saving' | 'saved' | 'paused' autosaveStatus: 'idle' | 'pending' | 'saving' | 'saved' | 'paused' | 'error'
// State // State
activeProject: Project | null activeProject: Project | null
+50
View 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, confirmAssetUpload,
type AssetType, type AssetType,
} from '@/features/community/lib/assets/actions' } from '@/features/community/lib/assets/actions'
import { useUploadStore } from '@/store/use-upload' import { useUploadStore } from '@pascal-app/editor'
import useEditor from '@/store/use-editor' import { useEditor } from '@pascal-app/editor'
/** /**
* Upload a file directly to Supabase Storage via signed URL with progress tracking. * Upload a file directly to Supabase Storage via signed URL with progress tracking.
+44
View 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
View 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
View 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
View 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
View 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.

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