diff --git a/apps/editor/app/api/auth/[...all]/route.ts b/apps/community/app/api/auth/[...all]/route.ts similarity index 100% rename from apps/editor/app/api/auth/[...all]/route.ts rename to apps/community/app/api/auth/[...all]/route.ts diff --git a/apps/editor/app/api/presets/[id]/route.ts b/apps/community/app/api/presets/[id]/route.ts similarity index 100% rename from apps/editor/app/api/presets/[id]/route.ts rename to apps/community/app/api/presets/[id]/route.ts diff --git a/apps/editor/app/api/presets/[id]/thumbnail/route.ts b/apps/community/app/api/presets/[id]/thumbnail/route.ts similarity index 100% rename from apps/editor/app/api/presets/[id]/thumbnail/route.ts rename to apps/community/app/api/presets/[id]/thumbnail/route.ts diff --git a/apps/editor/app/api/presets/route.ts b/apps/community/app/api/presets/route.ts similarity index 100% rename from apps/editor/app/api/presets/route.ts rename to apps/community/app/api/presets/route.ts diff --git a/apps/editor/app/editor/[projectId]/error.tsx b/apps/community/app/editor/[projectId]/error.tsx similarity index 100% rename from apps/editor/app/editor/[projectId]/error.tsx rename to apps/community/app/editor/[projectId]/error.tsx diff --git a/apps/editor/app/editor/[projectId]/layout.tsx b/apps/community/app/editor/[projectId]/layout.tsx similarity index 100% rename from apps/editor/app/editor/[projectId]/layout.tsx rename to apps/community/app/editor/[projectId]/layout.tsx diff --git a/apps/community/app/editor/[projectId]/page.tsx b/apps/community/app/editor/[projectId]/page.tsx new file mode 100644 index 00000000..89c32a01 --- /dev/null +++ b/apps/community/app/editor/[projectId]/page.tsx @@ -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 => { + 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 + } + + if (!isAuthenticated) { + return null + } + + return ( +
+
+ } + sidebarTop={} + 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) + }, + }} + /> +
+
+ ) +} diff --git a/apps/community/app/favicon.ico b/apps/community/app/favicon.ico new file mode 100644 index 00000000..e9f36729 Binary files /dev/null and b/apps/community/app/favicon.ico differ diff --git a/apps/community/app/fonts/GeistMonoVF.woff b/apps/community/app/fonts/GeistMonoVF.woff new file mode 100644 index 00000000..f2ae185c Binary files /dev/null and b/apps/community/app/fonts/GeistMonoVF.woff differ diff --git a/apps/community/app/fonts/GeistVF.woff b/apps/community/app/fonts/GeistVF.woff new file mode 100644 index 00000000..1b62daac Binary files /dev/null and b/apps/community/app/fonts/GeistVF.woff differ diff --git a/apps/community/app/globals.css b/apps/community/app/globals.css new file mode 100644 index 00000000..4908f5a6 --- /dev/null +++ b/apps/community/app/globals.css @@ -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%} +} diff --git a/apps/community/app/layout.tsx b/apps/community/app/layout.tsx new file mode 100644 index 00000000..ef3b645c --- /dev/null +++ b/apps/community/app/layout.tsx @@ -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 ( + + + {process.env.NODE_ENV === 'development' && ( + <> +