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
+198
View File
@@ -0,0 +1,198 @@
# Community Feature
This directory contains the **optional** community features (cloud synchronization and authentication) for the Pascal Editor. This feature is specific to the Pascal platform and can be safely removed if you're using the editor standalone.
## What This Does
The community feature provides:
- **Authentication** - Sign in with magic link via Better Auth
- **Property Management** - Create and manage properties with Google Maps address search
- **Scene Loading** - Automatically load property scenes from the database when a property is selected
- **Auto-Save** - Automatically save scene changes to the database (2-second debounce)
- **Database Sync** - Save and load editor state from a PostgreSQL database via Supabase
## Architecture
```
features/community/
├── lib/
│ ├── auth/
│ │ ├── client.ts # Re-exports from @pascal-app/auth
│ │ ├── server.ts # Server-side session handling
│ │ └── hooks.ts # useAuth React hook
│ ├── properties/
│ │ ├── actions.ts # Server actions for CRUD operations
│ │ ├── types.ts # TypeScript types for properties
│ │ ├── hooks.ts # Property React hooks
│ │ └── store.ts # Zustand store for property state
│ ├── models/
│ │ ├── actions.ts # Scene model CRUD operations
│ │ └── hooks.ts # Scene loading and auto-save hooks
│ ├── database/
│ │ └── server.ts # Re-exports from @pascal-app/db
│ └── utils/
│ └── id-generator.ts # nanoid-based ID generation
├── components/
│ ├── cloud-save-button.tsx # Main UI entry point (top-right button)
│ ├── sign-in-dialog.tsx # Magic link sign-in dialog
│ ├── profile-dropdown.tsx # User profile menu
│ ├── property-dropdown.tsx # Property selector dropdown
│ ├── new-property-dialog.tsx # Create new property dialog
│ └── google-address-search.tsx # Google Maps autocomplete
└── README.md # This file
```
## How It Works
### Authentication Flow
1. User clicks "Save to cloud" button
2. Signs in with magic link (email-based, no password)
3. Better Auth session is stored in cookies
4. Server actions validate session using Better Auth API
### Property Management
1. User creates a property with a real-world address (Google Maps)
2. Address and property are saved to PostgreSQL via Supabase
3. Properties are associated with the authenticated user
4. User can switch between properties
### Scene Management
1. When a property is selected, its scene is loaded from `properties_models` table
2. If no scene exists, loads default empty scene
3. Scene changes are auto-saved every 2 seconds (debounced)
4. Updates existing model (highest version) instead of creating new ones
5. Scene graph includes all nodes and hierarchy
### Database Integration
- Uses Supabase (PostgreSQL) for database access
- Better Auth manages authentication tables directly
- Server actions use service role key to bypass RLS
- Permissions enforced by filtering on `owner_id`
- Tables: `users`, `sessions`, `properties`, `properties_addresses`, `properties_models`
## Required Environment Variables
```bash
# Database Connection
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
# Supabase Configuration
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key_here
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key_here
# Better Auth
BETTER_AUTH_SECRET=<generate_with_openssl_rand_base64_32>
BETTER_AUTH_URL=http://localhost:3000
# Google Maps API Key (for address search)
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your_google_maps_key_here
```
Generate `BETTER_AUTH_SECRET`:
```bash
openssl rand -base64 32
```
## Dependencies
The community feature requires these packages:
```json
{
"better-auth": "^1.4.18",
"@supabase/supabase-js": "^2.95.3",
"@react-google-maps/api": "^2.20.8",
"nanoid": "^5.1.6"
}
```
## How to Remove (For Open Source Users)
If you want to use the editor without community features:
### 1. Delete this directory
```bash
rm -rf features/community
```
### 2. Remove the CloudSaveButton from the editor
Edit `components/editor/index.tsx`:
```diff
- import { CloudSaveButton } from '@/features/community/components/cloud-save-button'
export default function Editor() {
return (
<div className="w-full h-full">
<ActionMenu />
<PanelManager />
- <CloudSaveButton />
```
### 3. Remove dependencies (optional)
Edit `package.json`:
```diff
- "better-auth": "^1.4.18",
- "@supabase/supabase-js": "^2.95.3",
- "@react-google-maps/api": "^2.20.8",
- "nanoid": "^5.1.6"
```
### 4. Remove environment variables
Delete from `.env.local` and `.env.example`:
```diff
- NEXT_PUBLIC_API_URL=...
- NEXT_PUBLIC_SUPABASE_URL=...
- NEXT_PUBLIC_SUPABASE_ANON_KEY=...
- SUPABASE_SERVICE_ROLE_KEY=...
- NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=...
```
That's it! The editor will work as a standalone application without any cloud features.
## Backend Requirements
This feature requires:
- Supabase local development instance
- PostgreSQL database with the following tables:
- `users` - User accounts (Better Auth)
- `sessions` - Authentication sessions (Better Auth)
- `verification_tokens` - Magic link tokens (Better Auth)
- `properties` - Property records
- `properties_addresses` - Property addresses
- `properties_models` - Scene graph models
- Database migrations are managed in `supabase/migrations/`
## Development
To work on this feature:
1. Install dependencies:
```bash
bun install
```
2. Start Supabase local development:
```bash
bun db:start
```
3. Run database migrations:
```bash
bun db:reset
```
4. Configure all environment variables in `apps/editor/.env.local`
5. Run the editor: `bun dev`
The editor will be available at `http://localhost:3000`.
For detailed setup instructions, see [SETUP.md](../../../SETUP.md) in the root directory.
## Notes
- This feature uses **server actions** (Next.js App Router) for all database operations
- Authentication is handled by **Better Auth** with magic link support
- Better Auth server is configured in `packages/auth` and mounted at `/api/auth/*`
- The editor queries the database directly using Supabase with service role key
- IDs are generated using nanoid with custom alphabet
- Scene state is managed with a **Zustand store** for reliable property switching
- Scene changes are auto-saved with 2-second debouncing to the currently selected property
@@ -0,0 +1,57 @@
'use client'
import { Home } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
import { useAuth } from '../lib/auth/hooks'
import { useProjectStore } from '../lib/projects/store'
import { ProfileDropdown } from './profile-dropdown'
/**
* CloudSaveButton - Shows authentication state and project management
*
* Guest: Shows "Home" button
* Authenticated: Shows ProfileDropdown
*/
export function CloudSaveButton() {
const { isAuthenticated, isLoading } = useAuth()
const initialize = useProjectStore(state => state.initialize)
const router = useRouter()
// Initialize project store when authenticated
useEffect(() => {
if (isAuthenticated) {
initialize()
}
}, [isAuthenticated, initialize])
if (isLoading) {
return (
<div className="pointer-events-auto">
<div className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 shadow-lg backdrop-blur-md">
<div className="h-4 w-4 animate-pulse rounded-full bg-muted" />
</div>
</div>
)
}
if (!isAuthenticated) {
return (
<div className="pointer-events-auto">
<button
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 text-sm font-medium shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground"
onClick={() => router.push('/')}
>
<Home className="h-4 w-4" />
Home
</button>
</div>
)
}
return (
<div className="pointer-events-auto">
<ProfileDropdown />
</div>
)
}
@@ -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,186 @@
'use client'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useAuth } from '../lib/auth/hooks'
import { getPublicProjects, getUserProjects } from '../lib/projects/actions'
import type { Project } from '../lib/projects/types'
import { CreateProjectButton } from './create-project-button'
import { HubFooter } from './hub-footer'
import { NewProjectDialog } from './new-project-dialog'
import { ProfileDropdown } from './profile-dropdown'
import { ProjectGrid } from './project-grid'
import { SignInDialog } from './sign-in-dialog'
export default function CommunityHub() {
const { isAuthenticated, isLoading: authLoading } = useAuth()
const router = useRouter()
const [isSignInDialogOpen, setIsSignInDialogOpen] = useState(false)
const [isNewProjectDialogOpen, setIsNewProjectDialogOpen] = useState(false)
const [publicProjects, setPublicProjects] = useState<Project[]>([])
const [userProjects, setUserProjects] = useState<Project[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
async function loadProjects() {
setLoading(true)
const publicResult = await getPublicProjects()
if (publicResult.success) {
setPublicProjects(publicResult.data || [])
}
if (isAuthenticated) {
const userResult = await getUserProjects()
if (userResult.success) {
setUserProjects(userResult.data || [])
}
}
setLoading(false)
}
if (!authLoading) {
loadProjects()
}
}, [isAuthenticated, authLoading])
const handleProjectCreated = (projectId: string) => {
router.push(`/editor/${projectId}`)
}
const handleProjectClick = (projectId: string) => {
router.push(`/editor/${projectId}`)
}
const handleViewProject = (projectId: string) => {
router.push(`/viewer/${projectId}`)
}
if (authLoading || loading) {
return (
<div className="flex h-screen w-full items-center justify-center">
<p className="text-muted-foreground">Loading...</p>
</div>
)
}
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b border-border bg-background/95 backdrop-blur sticky top-0 z-10">
<div className="container mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Image
src="/pascal-logo-shape.svg"
alt="Pascal"
width={64}
height={64}
className="h-5 w-5"
/>
<h1 className="text-2xl font-bold">Pascal Editor</h1>
</div>
<div className="flex items-center gap-3">
<a
href="https://github.com/pascalorg/editor"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-muted-foreground transition-colors hover:border-foreground/20 hover:text-foreground"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
<span className="hidden sm:inline text-sm font-medium">Open Source</span>
</a>
{!isAuthenticated ? (
<button
onClick={() => setIsSignInDialogOpen(true)}
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
Sign In
</button>
) : (
<ProfileDropdown />
)}
</div>
</div>
</div>
</header>
<main className="container mx-auto px-6 py-8 space-y-12">
{/* User's Projects Section */}
{isAuthenticated && (
<section>
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold">My Projects</h2>
<CreateProjectButton onCreateProject={() => setIsNewProjectDialogOpen(true)} />
</div>
{userProjects.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-16 text-center">
<p className="text-muted-foreground">You don&apos;t have any projects yet.</p>
</div>
) : (
<ProjectGrid
projects={userProjects}
onProjectClick={handleProjectClick}
onViewClick={handleViewProject}
showOwner={false}
canEdit
onUpdate={() => {
if (!authLoading) {
getUserProjects().then((result) => {
if (result.success) {
setUserProjects(result.data || [])
}
})
}
}}
/>
)}
</section>
)}
{/* Sign-in CTA for unauthenticated users */}
{!isAuthenticated && (
<section className="rounded-2xl border border-border bg-neutral-50 dark:bg-neutral-900/50 px-8 py-12 text-center">
<h2 className="text-2xl font-semibold mb-2">Build with Pascal</h2>
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
Create and share 3D architectural projects. Sign in to get started.
</p>
<button
onClick={() => setIsSignInDialogOpen(true)}
className="rounded-lg bg-primary px-6 py-2.5 text-primary-foreground font-medium hover:bg-primary/90 transition-colors"
>
Sign in to create a project
</button>
</section>
)}
{/* Public Projects Section */}
<section>
<h2 className="text-xl font-semibold mb-6">Community Projects</h2>
{publicProjects.length > 0 ? (
<ProjectGrid
projects={publicProjects}
onProjectClick={handleViewProject}
showOwner
/>
) : (
<div className="text-center py-12 text-muted-foreground">No public projects yet</div>
)}
</section>
</main>
<HubFooter />
<SignInDialog open={isSignInDialogOpen} onOpenChange={setIsSignInDialogOpen} />
<NewProjectDialog
open={isNewProjectDialogOpen}
onOpenChange={setIsNewProjectDialogOpen}
onSuccess={handleProjectCreated}
/>
</div>
)
}
@@ -0,0 +1,19 @@
'use client'
import { Plus } from 'lucide-react'
interface CreateProjectButtonProps {
onCreateProject: () => void
}
export function CreateProjectButton({ onCreateProject }: CreateProjectButtonProps) {
return (
<button
onClick={onCreateProject}
className="flex items-center gap-2 rounded-full bg-primary px-5 py-2 text-primary-foreground hover:bg-primary/90 transition-colors"
>
<Plus className="w-4 h-4" />
<span>Create Project</span>
</button>
)
}
@@ -0,0 +1,113 @@
'use client'
import { Autocomplete, LoadScript } from '@react-google-maps/api'
import { MapPin } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
const libraries: ('places')[] = ['places']
interface AddressComponents {
streetNumber?: string
route?: string
city?: string
state?: string
postalCode?: string
country?: string
center: [number, number]
formattedAddress: string
}
interface GoogleAddressSearchProps {
onAddressSelect: (address: AddressComponents) => void
disabled?: boolean
}
export function GoogleAddressSearch({ onAddressSelect, disabled }: GoogleAddressSearchProps) {
const [autocomplete, setAutocomplete] = useState<google.maps.places.Autocomplete | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
// Fix Google Maps autocomplete dropdown z-index and pointer events to work with dialog
useEffect(() => {
const style = document.createElement('style')
style.textContent = `
.pac-container {
z-index: 9999 !important;
pointer-events: auto !important;
}
`
document.head.appendChild(style)
return () => {
document.head.removeChild(style)
}
}, [])
if (!apiKey) {
return (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
<p className="font-medium">Google Maps API Key Missing</p>
<p className="mt-1 text-xs">
Add NEXT_PUBLIC_GOOGLE_MAPS_API_KEY to your .env.local file
</p>
</div>
)
}
const onLoad = (autocompleteInstance: google.maps.places.Autocomplete) => {
setAutocomplete(autocompleteInstance)
}
const onPlaceChanged = () => {
if (!autocomplete) return
const place = autocomplete.getPlace()
if (!place.geometry?.location || !place.address_components) return
const components: AddressComponents = {
center: [place.geometry.location.lng(), place.geometry.location.lat()],
formattedAddress: place.formatted_address || '',
}
// Parse address components
for (const component of place.address_components) {
const types = component.types
if (types.includes('street_number')) {
components.streetNumber = component.long_name
} else if (types.includes('route')) {
components.route = component.long_name
} else if (types.includes('locality')) {
components.city = component.long_name
} else if (types.includes('administrative_area_level_1')) {
components.state = component.short_name
} else if (types.includes('postal_code')) {
components.postalCode = component.long_name
} else if (types.includes('country')) {
components.country = component.short_name
}
}
onAddressSelect(components)
}
return (
<LoadScript googleMapsApiKey={apiKey} libraries={libraries}>
<div className="space-y-2">
<label className="flex items-center gap-2 font-medium text-sm">
<MapPin className="h-4 w-4" />
Project Address
</label>
<Autocomplete onLoad={onLoad} onPlaceChanged={onPlaceChanged}>
<input
ref={inputRef}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={disabled}
placeholder="Search for an address..."
type="text"
/>
</Autocomplete>
</div>
</LoadScript>
)
}
@@ -0,0 +1,69 @@
import Link from 'next/link'
function GitHubIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
)
}
function NpmIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 256 256">
<rect width="256" height="256" rx="0" fill="#C12127" />
<polygon points="48,48 208,48 208,208 176,208 176,80 128,80 128,208 48,208" fill="#FFFFFF" />
</svg>
)
}
export function HubFooter() {
return (
<footer className="border-t border-border mt-16">
<div className="container mx-auto px-6 py-8">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<p className="text-muted-foreground text-sm">
Editor by{' '}
<a
href="https://pascal.app"
target="_blank"
rel="noopener noreferrer"
className="text-foreground hover:underline"
>
Pascal
</a>
</p>
<div className="flex items-center gap-4">
<a
href="https://github.com/pascalorg/editor"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors text-sm"
>
<GitHubIcon className="h-4 w-4" />
GitHub
</a>
<a
href="https://www.npmjs.com/package/@pascal-app/viewer"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors text-sm"
>
<NpmIcon className="h-4 w-4" />
Viewer
</a>
<a
href="https://www.npmjs.com/package/@pascal-app/core"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors text-sm"
>
<NpmIcon className="h-4 w-4" />
Core
</a>
</div>
</div>
</div>
</footer>
)
}
@@ -0,0 +1,142 @@
'use client'
import { X } from 'lucide-react'
import { useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/primitives/dialog'
import { Switch } from '@/components/ui/primitives/switch'
import { useScene } from '@pascal-app/core'
import { createProject } from '../lib/projects/actions'
interface NewProjectDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSuccess?: (projectId: string) => void
}
/**
* NewProjectDialog - Dialog for creating a new project
*/
export function NewProjectDialog({ open, onOpenChange, onSuccess }: NewProjectDialogProps) {
const [projectName, setProjectName] = useState('')
const [isPrivate, setIsPrivate] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
const name = projectName.trim() || 'Untitled Project'
setIsCreating(true)
try {
// Get the default scene graph
useScene.getState().clearScene()
const { nodes, rootNodeIds } = useScene.getState()
const sceneGraph = { nodes, rootNodeIds }
const result = await createProject({ name, isPrivate, sceneGraph })
if (result.success && result.data) {
onOpenChange(false)
setProjectName('')
setIsPrivate(false)
onSuccess?.(result.data.id)
} else {
setError(result.error || 'Failed to create project')
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An unexpected error occurred')
} finally {
setIsCreating(false)
}
}
const handleClose = () => {
if (!isCreating) {
onOpenChange(false)
setProjectName('')
setIsPrivate(false)
setError(null)
}
}
return (
<Dialog open={open} onOpenChange={handleClose} modal={false}>
<DialogContent
className="sm:max-w-125"
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Create New Project</DialogTitle>
<button
className="absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 disabled:pointer-events-none"
disabled={isCreating}
onClick={handleClose}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
{/* Project Name */}
<div>
<label htmlFor="project-name" className="text-sm font-medium">
Project Name
</label>
<input
id="project-name"
type="text"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
placeholder="My Project"
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
disabled={isCreating}
autoFocus
/>
</div>
{/* Privacy Toggle */}
<div className="flex items-center justify-between rounded-md border border-border p-3">
<div>
<div className="font-medium text-sm">Privacy</div>
<div className="text-xs text-muted-foreground">
{isPrivate ? 'Only you can view this project' : 'Anyone can view this project'}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Public</span>
<Switch checked={!isPrivate} onCheckedChange={(checked) => setIsPrivate(!checked)} />
</div>
</div>
{error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
{error}
</div>
)}
<div className="flex justify-end gap-2">
<button
className="rounded-md border border-input px-4 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
disabled={isCreating}
type="button"
onClick={handleClose}
>
Cancel
</button>
<button
className="rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm transition-colors hover:bg-primary/90 disabled:opacity-50"
disabled={isCreating}
type="submit"
>
{isCreating ? 'Creating...' : 'Create Project'}
</button>
</div>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,89 @@
'use client'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { useAuth } from '../lib/auth/hooks'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/primitives/dropdown-menu'
function getInitials(name: string): string {
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2)
}
/**
* ProfileDropdown - User profile menu with avatar, settings, and sign out
*/
export function ProfileDropdown() {
const { user, signOut } = useAuth()
const router = useRouter()
const handleSignOut = async () => {
await signOut()
}
const initials = user?.name ? getInitials(user.name) : user?.email?.[0]?.toUpperCase() || 'U'
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex h-9 w-9 items-center justify-center overflow-hidden rounded-full bg-muted font-medium text-xs shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_3px_rgba(0,0,0,0.1)] transition-opacity hover:opacity-80 focus:outline-none"
type="button"
>
{user?.image ? (
<Image
src={user.image}
alt={user.name || 'Profile'}
width={36}
height={36}
className="h-full w-full object-cover"
/>
) : (
initials
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="flex items-center gap-3 px-2 py-2">
{user?.image ? (
<Image
src={user.image}
alt={user.name || 'Profile'}
width={32}
height={32}
className="h-8 w-8 rounded-full object-cover"
/>
) : (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted font-medium text-xs">
{initials}
</div>
)}
<div className="min-w-0 flex-1">
{user?.name && <div className="truncate font-medium text-sm">{user.name}</div>}
{user?.email && (
<div className="truncate text-muted-foreground text-xs">{user.email}</div>
)}
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer" onClick={() => router.push('/settings')}>
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer" variant="destructive" onClick={handleSignOut}>
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,110 @@
'use client'
import { Check, ChevronDown, Home, Plus } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/primitives/dropdown-menu'
import { cn } from '@/lib/utils'
import { useProjectStore } from '../lib/projects/store'
import { NewProjectDialog } from './new-project-dialog'
/**
* ProjectDropdown - Shows active project and allows switching between projects
* Note: useProjectScene() is called in the Editor component, not here.
* Having it in both places caused duplicate subscriptions and 2x server action calls.
*/
export function ProjectDropdown() {
const router = useRouter()
// Use project store
const projects = useProjectStore((state) => state.projects)
const activeProject = useProjectStore((state) => state.activeProject)
const isLoading = useProjectStore((state) => state.isLoading)
const setActiveProject = useProjectStore((state) => state.setActiveProject)
const fetchProjects = useProjectStore((state) => state.fetchProjects)
const [isNewProjectDialogOpen, setIsNewProjectDialogOpen] = useState(false)
const handleProjectSelect = async (projectId: string) => {
await setActiveProject(projectId)
}
const handleAddNew = () => {
setIsNewProjectDialogOpen(true)
}
const handleProjectCreated = (projectId: string) => {
router.push(`/editor/${projectId}`)
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex h-9 items-center gap-2 rounded-lg border border-border bg-background/95 px-3 text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50 focus:outline-none"
disabled={isLoading}
type="button"
>
<Home className="h-4 w-4" />
<span className="max-w-[150px] truncate">
{activeProject
? activeProject.name
: projects.length > 0
? 'Select Project'
: 'Add Project'}
</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[280px]">
{/* Project list */}
{projects.length > 0 ? (
<div className="max-h-[300px] overflow-y-auto">
{projects.map((project) => (
<DropdownMenuItem
className={cn(
'cursor-pointer text-sm',
activeProject?.id === project.id && 'cursor-default bg-accent',
)}
key={project.id}
onClick={() =>
activeProject?.id === project.id ? null : handleProjectSelect(project.id)
}
>
<div className="flex w-full items-center justify-between gap-2">
<div className="flex-1 truncate font-medium">{project.name}</div>
{activeProject?.id === project.id && (
<Check className="h-4 w-4 shrink-0 text-primary" />
)}
</div>
</DropdownMenuItem>
))}
</div>
) : (
<div className="px-2 py-3 text-center text-muted-foreground text-sm">
No projects yet
</div>
)}
{/* Add new project option */}
<DropdownMenuItem className="cursor-pointer" onClick={handleAddNew}>
<Plus className="mr-2 h-4 w-4" />
<span>Add new project</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<NewProjectDialog
open={isNewProjectDialogOpen}
onOpenChange={setIsNewProjectDialogOpen}
onSuccess={handleProjectCreated}
/>
</>
)
}
@@ -0,0 +1,218 @@
'use client'
import { Eye, Heart, Settings } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { useAuth } from '../lib/auth/hooks'
import { getUserProjectLikes, toggleProjectLike } from '../lib/projects/actions'
import type { Project } from '../lib/projects/types'
import { ProjectSettingsDialog } from './project-settings-dialog'
interface ProjectGridProps {
projects: Project[]
onProjectClick: (id: string) => void
onViewClick?: (id: string) => void
showOwner: boolean
canEdit?: boolean
onUpdate?: () => void
}
export function ProjectGrid({
projects,
onProjectClick,
onViewClick,
showOwner,
canEdit = false,
onUpdate,
}: ProjectGridProps) {
const { isAuthenticated } = useAuth()
const [settingsProject, setSettingsProject] = useState<Project | null>(null)
const [userLikes, setUserLikes] = useState<Record<string, boolean>>({})
const [likeCounts, setLikeCounts] = useState<Record<string, number>>({})
useEffect(() => {
const counts: Record<string, number> = {}
projects.forEach((proj) => {
counts[proj.id] = proj.likes
})
setLikeCounts(counts)
}, [projects])
useEffect(() => {
if (!isAuthenticated) {
setUserLikes({})
return
}
const projectIds = projects.map((p) => p.id)
if (projectIds.length === 0) return
getUserProjectLikes(projectIds).then((result) => {
if (result.success && result.data) {
setUserLikes(result.data)
}
})
}, [projects, isAuthenticated])
const handleSettingsClick = (e: React.MouseEvent, project: Project) => {
e.stopPropagation()
setSettingsProject(project)
}
const handleViewClick = (e: React.MouseEvent, projectId: string) => {
e.stopPropagation()
onViewClick?.(projectId)
}
const handleLikeClick = async (e: React.MouseEvent, projectId: string) => {
e.stopPropagation()
if (!isAuthenticated) return
const wasLiked = userLikes[projectId] || false
const currentCount = likeCounts[projectId] || 0
setUserLikes((prev) => ({ ...prev, [projectId]: !wasLiked }))
setLikeCounts((prev) => ({
...prev,
[projectId]: wasLiked ? currentCount - 1 : currentCount + 1,
}))
const result = await toggleProjectLike(projectId)
if (result.success && result.data) {
const data = result.data
setUserLikes((prev) => ({ ...prev, [projectId]: data.liked }))
setLikeCounts((prev) => ({ ...prev, [projectId]: data.likes }))
} else {
setUserLikes((prev) => ({ ...prev, [projectId]: wasLiked }))
setLikeCounts((prev) => ({ ...prev, [projectId]: currentCount }))
}
}
return (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
{projects.map((project) => {
const owner = project.owner
return (
<div
key={project.id}
onClick={() => onProjectClick(project.id)}
className="group text-left cursor-pointer"
>
{/* Thumbnail card */}
<div className="relative aspect-[4/3] rounded-xl rounded-smooth-xl bg-neutral-50 overflow-hidden shadow-[0_1px_3px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.04)] transition-shadow group-hover:shadow-[0_4px_12px_rgba(0,0,0,0.08),0_0_0_1px_rgba(0,0,0,0.04)]">
{project.thumbnail_url ? (
<img
src={project.thumbnail_url}
alt={project.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-sm">
No preview
</div>
)}
{canEdit && (
<div className="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{onViewClick && (
<button
onClick={(e) => handleViewClick(e, project.id)}
className="bg-background/80 hover:bg-background rounded-md p-1.5"
aria-label="View"
title="View in viewer mode"
>
<Eye className="w-4 h-4" />
</button>
)}
<button
onClick={(e) => handleSettingsClick(e, project)}
className="bg-background/80 hover:bg-background rounded-md p-1.5"
aria-label="Settings"
title="Project settings"
>
<Settings className="w-4 h-4" />
</button>
</div>
)}
</div>
{/* Info row below the card */}
<div className="flex items-center gap-3 mt-3">
{showOwner && owner ? (
<Link
href={owner.username ? `/u/${owner.username}` : '#'}
onClick={(e) => e.stopPropagation()}
className="shrink-0"
>
{owner.image ? (
<img
src={owner.image}
alt={owner.name}
className="w-9 h-9 rounded-full object-cover shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_3px_rgba(0,0,0,0.1)]"
/>
) : (
<div className="w-9 h-9 rounded-full bg-neutral-100 flex items-center justify-center text-sm font-medium shadow-[0_0_0_1px_rgba(0,0,0,0.06)]">
{owner.name?.[0]?.toUpperCase() || '?'}
</div>
)}
</Link>
) : null}
<div className="min-w-0 flex-1">
<h3 className="font-medium text-sm truncate">{project.name}</h3>
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
{showOwner && owner && (
<>
<Link
href={owner.username ? `/u/${owner.username}` : '#'}
onClick={(e) => e.stopPropagation()}
className="hover:text-foreground transition-colors truncate"
>
{owner.username || owner.name}
</Link>
<span className="shrink-0">·</span>
</>
)}
<div className="flex items-center gap-0.5 shrink-0">
<Eye className="w-3.5 h-3.5" />
<span>{project.views}</span>
</div>
<span className="shrink-0">·</span>
<button
onClick={(e) => handleLikeClick(e, project.id)}
className="flex items-center gap-0.5 shrink-0 hover:text-red-500 transition-colors"
disabled={!isAuthenticated}
>
<Heart
className={`w-3.5 h-3.5 ${
userLikes[project.id] ? 'fill-red-500 text-red-500' : ''
}`}
/>
<span>{likeCounts[project.id] ?? project.likes}</span>
</button>
</div>
</div>
</div>
</div>
)
})}
</div>
{settingsProject && (
<ProjectSettingsDialog
project={settingsProject}
open={!!settingsProject}
onOpenChange={(open) => !open && setSettingsProject(null)}
onUpdate={onUpdate}
onDelete={() => {
setSettingsProject(null)
onUpdate?.()
}}
/>
)}
</>
)
}
@@ -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>
);
}
@@ -0,0 +1,160 @@
'use client'
import { useRef, useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/primitives/dialog'
import { Switch } from '@/components/ui/primitives/switch'
import { updateProjectName, updateProjectVisibility, deleteProject } from '../lib/projects/actions'
import type { Project } from '../lib/projects/types'
interface ProjectSettingsDialogProps {
project: Project
open: boolean
onOpenChange: (open: boolean) => void
onUpdate?: () => void
onDelete?: () => void
}
export function ProjectSettingsDialog({
project,
open,
onOpenChange,
onUpdate,
onDelete,
}: ProjectSettingsDialogProps) {
const [isDeleting, setIsDeleting] = useState(false)
const [name, setName] = useState(project.name || '')
const [isPrivate, setIsPrivate] = useState(project.is_private)
const [showScansPublic, setShowScansPublic] = useState(project.show_scans_public ?? true)
const [showGuidesPublic, setShowGuidesPublic] = useState(project.show_guides_public ?? true)
const nameTimerRef = useRef<ReturnType<typeof setTimeout>>(null)
const handleNameChange = (value: string) => {
setName(value)
if (nameTimerRef.current) clearTimeout(nameTimerRef.current)
nameTimerRef.current = setTimeout(async () => {
const trimmed = value.trim()
if (trimmed && trimmed !== (project.name || '')) {
await updateProjectName(project.id, trimmed)
onUpdate?.()
}
}, 500)
}
const handleVisibilityChange = async (
field: 'isPrivate' | 'showScansPublic' | 'showGuidesPublic',
value: boolean,
) => {
if (field === 'isPrivate') setIsPrivate(value)
if (field === 'showScansPublic') setShowScansPublic(value)
if (field === 'showGuidesPublic') setShowGuidesPublic(value)
await updateProjectVisibility(project.id, { [field]: value })
onUpdate?.()
}
const handleDelete = async () => {
if (!confirm('Are you sure you want to delete this project? This action cannot be undone.')) {
return
}
setIsDeleting(true)
try {
const result = await deleteProject(project.id)
if (result.success) {
onDelete?.()
onOpenChange(false)
} else {
alert(`Failed to delete project: ${result.error}`)
}
} catch (error) {
alert('Failed to delete project')
} finally {
setIsDeleting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Project Settings</DialogTitle>
<DialogDescription>Changes are saved automatically</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Project Name */}
<div>
<label htmlFor="project-name" className="font-medium text-sm">
Project Name
</label>
<input
id="project-name"
type="text"
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="My Project"
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
{/* Privacy Toggle */}
<div className="flex items-center justify-between">
<div>
<div className="font-medium">Privacy</div>
<div className="text-sm text-muted-foreground">
{isPrivate ? 'Only you can view this project' : 'Anyone can view this project'}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Public</span>
<Switch checked={!isPrivate} onCheckedChange={(checked) => handleVisibilityChange('isPrivate', !checked)} />
</div>
</div>
{/* Public Visibility Toggles */}
<div className="flex items-center justify-between">
<div>
<div className="font-medium">Show 3D Scans</div>
<div className="text-sm text-muted-foreground">
Visible to public viewers
</div>
</div>
<Switch checked={showScansPublic} onCheckedChange={(checked) => handleVisibilityChange('showScansPublic', checked)} />
</div>
<div className="flex items-center justify-between">
<div>
<div className="font-medium">Show Floorplans</div>
<div className="text-sm text-muted-foreground">
Visible to public viewers
</div>
</div>
<Switch checked={showGuidesPublic} onCheckedChange={(checked) => handleVisibilityChange('showGuidesPublic', checked)} />
</div>
{/* Danger Zone */}
<div className="border-t border-border pt-6">
<h3 className="font-medium text-destructive mb-2">Danger Zone</h3>
<p className="text-sm text-muted-foreground mb-3">
Once you delete a project, there is no going back. Please be certain.
</p>
<button
type="button"
onClick={handleDelete}
className="rounded-md border border-destructive bg-destructive/10 px-4 py-2 text-sm text-destructive hover:bg-destructive/20"
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete Project'}
</button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,157 @@
'use client'
import Image from 'next/image'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { ProjectGrid } from './project-grid'
import { HubFooter } from './hub-footer'
import type { Project } from '../lib/projects/types'
function GitHubIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
)
}
function XIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
)
}
function YouTubeIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg>
)
}
interface PublicProfilePageProps {
profile: {
id: string
name: string
image: string | null
username: string
githubUrl: string | null
xUrl: string | null
youtubeUrl: string | null
}
projects: Project[]
}
export function PublicProfilePage({ profile, projects }: PublicProfilePageProps) {
const router = useRouter()
return (
<div className="min-h-screen bg-background">
{/* Header — same layout as the community home */}
<header className="border-b border-border bg-background/95 backdrop-blur sticky top-0 z-10">
<div className="container mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<Link href="/" className="flex items-center gap-2">
<Image
src="/pascal-logo-shape.svg"
alt="Pascal"
width={64}
height={64}
className="h-5 w-5"
/>
<span className="text-2xl font-bold">Pascal Editor</span>
</Link>
<a
href="https://github.com/pascalorg/editor"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-muted-foreground transition-colors hover:border-foreground/20 hover:text-foreground"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
<span className="hidden sm:inline text-sm font-medium">Open Source</span>
</a>
</div>
</div>
</header>
<main className="container mx-auto max-w-4xl px-6 py-8 space-y-8">
{/* Profile Header */}
<div className="flex items-center gap-6">
{profile.image ? (
<Image
src={profile.image}
alt={profile.name}
width={80}
height={80}
className="h-20 w-20 rounded-full object-cover"
/>
) : (
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted font-bold text-2xl">
{profile.name[0]?.toUpperCase() || '?'}
</div>
)}
<div className="space-y-1">
<h1 className="text-2xl font-bold">{profile.name}</h1>
<p className="text-muted-foreground">@{profile.username}</p>
{(profile.githubUrl || profile.xUrl || profile.youtubeUrl) && (
<div className="flex items-center gap-3 pt-1">
{profile.githubUrl && (
<a
href={profile.githubUrl}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
>
<GitHubIcon className="h-5 w-5" />
</a>
)}
{profile.xUrl && (
<a
href={profile.xUrl}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
>
<XIcon className="h-5 w-5" />
</a>
)}
{profile.youtubeUrl && (
<a
href={profile.youtubeUrl}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
>
<YouTubeIcon className="h-5 w-5" />
</a>
)}
</div>
)}
</div>
</div>
{/* Projects */}
<section>
<h2 className="text-lg font-semibold mb-4">Public Projects</h2>
{projects.length > 0 ? (
<ProjectGrid
projects={projects}
onProjectClick={(id) => router.push(`/viewer/${id}`)}
showOwner={false}
/>
) : (
<div className="text-center py-12 text-muted-foreground">
No public projects yet
</div>
)}
</section>
</main>
<HubFooter />
</div>
)
}
@@ -0,0 +1,440 @@
'use client'
import Image from 'next/image'
import Link from 'next/link'
import { ArrowLeft, Pencil } from 'lucide-react'
import { useRef, useState } from 'react'
import { authClient } from '../lib/auth/client'
import { updateUsername, updateProfile, uploadAvatar, updateEmailNotifications } from '../lib/auth/actions'
function GoogleIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
)
}
interface SettingsPageProps {
user: {
id: string
name?: string | null
email?: string | null
image?: string | null
}
currentUsername: string | null
currentGithubUrl: string | null
currentXUrl: string | null
currentYoutubeUrl: string | null
currentEmailNotifications: boolean
connectedAccounts: { providerId: string; accountId: string }[]
}
export function SettingsPage({
user,
currentUsername,
currentGithubUrl,
currentXUrl,
currentYoutubeUrl,
currentEmailNotifications,
connectedAccounts,
}: SettingsPageProps) {
const [username, setUsername] = useState(currentUsername ?? '')
const [githubUrl, setGithubUrl] = useState(currentGithubUrl ?? '')
const [xUrl, setXUrl] = useState(currentXUrl ?? '')
const [youtubeUrl, setYoutubeUrl] = useState(currentYoutubeUrl ?? '')
const [avatarUrl, setAvatarUrl] = useState(user.image)
const [isSavingUsername, setIsSavingUsername] = useState(false)
const [isSavingSocial, setIsSavingSocial] = useState(false)
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
const [isConnectingGoogle, setIsConnectingGoogle] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const [usernameMessage, setUsernameMessage] = useState<{
type: 'success' | 'error'
text: string
} | null>(null)
const [socialMessage, setSocialMessage] = useState<{
type: 'success' | 'error'
text: string
} | null>(null)
const [emailNotifications, setEmailNotifications] = useState(currentEmailNotifications)
const [isSavingNotifications, setIsSavingNotifications] = useState(false)
const isGoogleConnected = connectedAccounts.some((a) => a.providerId === 'google')
const initials = currentUsername
? currentUsername.slice(0, 2).toUpperCase()
: user.name
? user.name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2)
: user.email?.[0]?.toUpperCase() || 'U'
const handleAvatarClick = () => {
fileInputRef.current?.click()
}
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setIsUploadingAvatar(true)
const formData = new FormData()
formData.append('avatar', file)
const result = await uploadAvatar(formData)
if (result.success && result.imageUrl) {
setAvatarUrl(result.imageUrl)
}
setIsUploadingAvatar(false)
// Reset input so the same file can be selected again
if (fileInputRef.current) fileInputRef.current.value = ''
}
const handleConnectGoogle = async () => {
setIsConnectingGoogle(true)
try {
await authClient.signIn.social({
provider: 'google',
callbackURL: '/settings',
})
} catch {
setIsConnectingGoogle(false)
}
}
const handleSaveUsername = async (e: React.FormEvent) => {
e.preventDefault()
setUsernameMessage(null)
setIsSavingUsername(true)
const result = await updateUsername(username)
setUsernameMessage({
type: result.success ? 'success' : 'error',
text: result.success ? 'Username updated successfully' : (result.error ?? 'Failed'),
})
setIsSavingUsername(false)
}
const handleSaveSocial = async (e: React.FormEvent) => {
e.preventDefault()
setSocialMessage(null)
setIsSavingSocial(true)
const result = await updateProfile({
githubUrl: githubUrl.trim() || null,
xUrl: xUrl.trim() || null,
youtubeUrl: youtubeUrl.trim() || null,
})
setSocialMessage({
type: result.success ? 'success' : 'error',
text: result.success
? 'Social links updated successfully'
: (result.error ?? 'Failed'),
})
setIsSavingSocial(false)
}
const usernameChanged = username.trim() !== (currentUsername ?? '')
const socialChanged =
(githubUrl.trim() || '') !== (currentGithubUrl ?? '') ||
(xUrl.trim() || '') !== (currentXUrl ?? '') ||
(youtubeUrl.trim() || '') !== (currentYoutubeUrl ?? '')
const handleToggleEmailNotifications = async () => {
const newValue = !emailNotifications
setEmailNotifications(newValue)
setIsSavingNotifications(true)
await updateEmailNotifications(newValue)
setIsSavingNotifications(false)
}
return (
<div className="min-h-screen bg-background">
<header className="border-b border-border bg-background/95 backdrop-blur sticky top-0 z-10">
<div className="container mx-auto px-6 py-4">
<div className="flex items-center gap-4">
<Link
href="/"
className="flex items-center gap-1 text-muted-foreground transition-colors hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
<span className="text-sm">Back</span>
</Link>
<h1 className="text-xl font-bold">Settings</h1>
</div>
</div>
</header>
<main className="container mx-auto max-w-2xl px-6 py-8 space-y-8">
{/* Profile Section */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">Profile</h2>
<div className="rounded-lg border border-border p-6 space-y-6">
<div className="flex items-center gap-4">
{/* Avatar with upload */}
<button
type="button"
onClick={handleAvatarClick}
disabled={isUploadingAvatar}
className="relative group shrink-0"
>
{avatarUrl ? (
<Image
src={avatarUrl}
alt={user.name || 'Profile'}
width={64}
height={64}
className="h-16 w-16 rounded-full object-cover"
/>
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted font-semibold text-lg">
{initials}
</div>
)}
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity">
<Pencil className="h-4 w-4 text-white" />
</div>
{isUploadingAvatar && (
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent" />
</div>
)}
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleAvatarUpload}
className="hidden"
/>
<div>
{user.name && <div className="font-medium">{user.name}</div>}
{user.email && (
<div className="text-muted-foreground text-sm">{user.email}</div>
)}
</div>
</div>
<form onSubmit={handleSaveUsername} className="space-y-4">
<div className="space-y-2">
<label htmlFor="username" className="font-medium text-sm">
Public Username
</label>
<p className="text-muted-foreground text-xs">
Your public display name on the community hub.
</p>
<input
id="username"
type="text"
value={username}
onChange={(e) => {
setUsername(e.target.value)
setUsernameMessage(null)
}}
placeholder="your-username"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSavingUsername}
minLength={3}
maxLength={30}
pattern="[a-zA-Z0-9_-]+"
/>
<p className="text-muted-foreground text-xs">
3-30 characters. Letters, numbers, hyphens, and underscores only.
</p>
</div>
{usernameMessage && (
<div
className={`rounded-md border p-3 text-sm ${
usernameMessage.type === 'success'
? 'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900/20 dark:text-green-400'
: 'border-destructive/50 bg-destructive/10 text-destructive'
}`}
>
{usernameMessage.text}
</div>
)}
<button
type="submit"
disabled={isSavingUsername || !usernameChanged || !username.trim()}
className="rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-50"
>
{isSavingUsername ? 'Saving...' : 'Save Username'}
</button>
</form>
</div>
</section>
{/* Connected Accounts Section */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">Connected Accounts</h2>
<div className="rounded-lg border border-border p-6 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<GoogleIcon className="h-5 w-5" />
<div>
<div className="text-sm font-medium">Google</div>
{isGoogleConnected ? (
<div className="text-xs text-muted-foreground">
Connected
</div>
) : (
<div className="text-xs text-muted-foreground">
Not connected
</div>
)}
</div>
</div>
{isGoogleConnected ? (
<span className="text-xs text-green-600 dark:text-green-400 font-medium px-2 py-1 rounded-full bg-green-50 dark:bg-green-900/20">
Connected
</span>
) : (
<button
type="button"
onClick={handleConnectGoogle}
disabled={isConnectingGoogle}
className="rounded-md border border-input px-3 py-1.5 text-sm transition-colors hover:bg-accent disabled:opacity-50"
>
{isConnectingGoogle ? 'Connecting...' : 'Connect'}
</button>
)}
</div>
</div>
</section>
{/* Notifications Section */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">Notifications</h2>
<div className="rounded-lg border border-border p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">Email notifications</div>
<p className="text-muted-foreground text-xs">
Receive emails about new features and updates.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={emailNotifications}
onClick={handleToggleEmailNotifications}
disabled={isSavingNotifications}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${
emailNotifications ? 'bg-primary' : 'bg-input'
}`}
>
<span
className={`pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform ${
emailNotifications ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
</button>
</div>
</div>
</section>
{/* Social Links Section */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">Social Links</h2>
<div className="rounded-lg border border-border p-6">
<form onSubmit={handleSaveSocial} className="space-y-4">
<div className="space-y-2">
<label htmlFor="github" className="font-medium text-sm">
GitHub
</label>
<input
id="github"
type="url"
value={githubUrl}
onChange={(e) => {
setGithubUrl(e.target.value)
setSocialMessage(null)
}}
placeholder="https://github.com/yourusername"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSavingSocial}
/>
</div>
<div className="space-y-2">
<label htmlFor="x" className="font-medium text-sm">
X (Twitter)
</label>
<input
id="x"
type="url"
value={xUrl}
onChange={(e) => {
setXUrl(e.target.value)
setSocialMessage(null)
}}
placeholder="https://x.com/yourusername"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSavingSocial}
/>
</div>
<div className="space-y-2">
<label htmlFor="youtube" className="font-medium text-sm">
YouTube
</label>
<input
id="youtube"
type="url"
value={youtubeUrl}
onChange={(e) => {
setYoutubeUrl(e.target.value)
setSocialMessage(null)
}}
placeholder="https://youtube.com/@yourchannel"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSavingSocial}
/>
</div>
{socialMessage && (
<div
className={`rounded-md border p-3 text-sm ${
socialMessage.type === 'success'
? 'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900/20 dark:text-green-400'
: 'border-destructive/50 bg-destructive/10 text-destructive'
}`}
>
{socialMessage.text}
</div>
)}
<button
type="submit"
disabled={isSavingSocial || !socialChanged}
className="rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-50"
>
{isSavingSocial ? 'Saving...' : 'Save Social Links'}
</button>
</form>
</div>
</section>
</main>
</div>
)
}
@@ -0,0 +1,237 @@
'use client'
import Link from 'next/link'
import { Mail, X } from 'lucide-react'
import { useState } from 'react'
import { authClient } from '../lib/auth/client'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/primitives/dialog'
interface SignInDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
function GoogleIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
)
}
/**
* SignInDialog - Authentication dialog with Google OAuth and magic link
*/
const LOGIN_METHOD_LABELS: Record<string, string> = {
google: 'Google',
'magic-link': 'email link',
}
export function SignInDialog({ open, onOpenChange }: SignInDialogProps) {
const [email, setEmail] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isGoogleLoading, setIsGoogleLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const lastMethod = authClient.getLastUsedLoginMethod?.()
const lastMethodLabel = lastMethod ? LOGIN_METHOD_LABELS[lastMethod] ?? lastMethod : null
const handleGoogleSignIn = async () => {
setError(null)
setIsGoogleLoading(true)
try {
await authClient.signIn.social({
provider: 'google',
callbackURL: window.location.origin,
})
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to sign in with Google')
setIsGoogleLoading(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setIsLoading(true)
try {
const result = await authClient.signIn.magicLink({
email,
callbackURL: window.location.origin,
})
if (result.error) {
setError(result.error.message || 'Failed to send magic link')
} else {
setSuccess(true)
setEmail('')
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An unexpected error occurred')
} finally {
setIsLoading(false)
}
}
const handleClose = () => {
if (!isLoading && !isGoogleLoading) {
onOpenChange(false)
setTimeout(() => {
setEmail('')
setError(null)
setSuccess(false)
}, 200)
}
}
const anyLoading = isLoading || isGoogleLoading
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Sign in to Pascal</DialogTitle>
<button
className="absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 disabled:pointer-events-none"
disabled={anyLoading}
onClick={handleClose}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
</DialogHeader>
{success ? (
<div className="space-y-4 py-4">
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/20">
<Mail className="h-6 w-6 text-green-600 dark:text-green-400" />
</div>
<div className="space-y-2">
<h3 className="font-semibold text-lg">Check your email</h3>
<p className="text-muted-foreground text-sm">
We've sent a magic link to <strong>{email}</strong>
</p>
<p className="text-muted-foreground text-sm">
Click the link in the email to sign in to your account.
</p>
</div>
</div>
<button
className="w-full rounded-md border border-input px-4 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
onClick={handleClose}
>
Close
</button>
</div>
) : (
<div className="space-y-4">
{lastMethodLabel && (
<p className="text-center text-muted-foreground text-xs">
Last signed in with {lastMethodLabel}
</p>
)}
{/* Google Sign-In */}
<button
className="flex w-full items-center justify-center gap-2 rounded-md border border-input bg-background px-4 py-2.5 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
disabled={anyLoading}
onClick={handleGoogleSignIn}
type="button"
>
{isGoogleLoading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-foreground border-t-transparent" />
) : (
<GoogleIcon className="h-4 w-4" />
)}
Continue with Google
</button>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">or</span>
</div>
</div>
{/* Magic Link Form */}
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<label className="font-medium text-sm" htmlFor="email">
Email address
</label>
<input
autoComplete="email"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={anyLoading}
id="email"
placeholder="you@example.com"
required
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
{error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
{error}
</div>
)}
<button
className="flex w-full items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm transition-colors hover:bg-primary/90 disabled:opacity-50"
disabled={anyLoading || !email}
type="submit"
>
{isLoading ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent" />
Sending magic link...
</>
) : (
<>
<Mail className="h-4 w-4" />
Send magic link
</>
)}
</button>
</form>
<p className="text-center text-muted-foreground text-xs">
By signing in, you agree to our{' '}
<Link href="/terms" className="underline hover:text-foreground">
Terms of Service
</Link>{' '}
and{' '}
<Link href="/privacy" className="underline hover:text-foreground">
Privacy Policy
</Link>
.
</p>
</div>
)}
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,40 @@
'use client'
import { useEffect, useState } from 'react'
import { useAuth } from '../lib/auth/hooks'
import { getUsername } from '../lib/auth/actions'
import { UsernameOnboardingDialog } from './username-onboarding-dialog'
export function UsernameGate({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth()
const [needsUsername, setNeedsUsername] = useState(false)
const [checking, setChecking] = useState(true)
useEffect(() => {
if (isLoading) return
if (!isAuthenticated) {
setChecking(false)
setNeedsUsername(false)
return
}
getUsername()
.then((username) => {
setNeedsUsername(!username)
setChecking(false)
})
.catch(() => {
setNeedsUsername(false)
setChecking(false)
})
}, [isAuthenticated, isLoading])
return (
<>
{children}
<UsernameOnboardingDialog
open={needsUsername && !checking}
onComplete={() => setNeedsUsername(false)}
/>
</>
)
}
@@ -0,0 +1,146 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/primitives/dialog'
import { updateUsername, checkUsernameAvailability } from '../lib/auth/actions'
interface UsernameOnboardingDialogProps {
open: boolean
onComplete: () => void
}
export function UsernameOnboardingDialog({ open, onComplete }: UsernameOnboardingDialogProps) {
const [username, setUsername] = useState('')
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [availability, setAvailability] = useState<'idle' | 'checking' | 'available' | 'taken'>(
'idle',
)
const validate = (value: string): string | null => {
if (value.length < 3) return 'Must be at least 3 characters'
if (value.length > 30) return 'Must be at most 30 characters'
if (!/^[a-zA-Z0-9_-]+$/.test(value))
return 'Only letters, numbers, hyphens, and underscores'
return null
}
const checkAvailability = useCallback(async (value: string) => {
const validationError = validate(value)
if (validationError) {
setAvailability('idle')
return
}
setAvailability('checking')
const result = await checkUsernameAvailability(value)
setAvailability(result.available ? 'available' : 'taken')
}, [])
useEffect(() => {
if (!username.trim()) {
setAvailability('idle')
return
}
const timer = setTimeout(() => checkAvailability(username.trim()), 300)
return () => clearTimeout(timer)
}, [username, checkAvailability])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const trimmed = username.trim()
const validationError = validate(trimmed)
if (validationError) {
setError(validationError)
return
}
setError(null)
setIsSaving(true)
const result = await updateUsername(trimmed)
if (result.success) {
onComplete()
} else {
setError(result.error ?? 'Failed to set username')
}
setIsSaving(false)
}
const validationError = username.trim() ? validate(username.trim()) : null
const canSubmit = !isSaving && !validationError && availability === 'available'
return (
<Dialog open={open} onOpenChange={() => {}}>
<DialogContent className="sm:max-w-[420px] [&>button]:hidden">
<DialogHeader>
<DialogTitle>Choose your username</DialogTitle>
</DialogHeader>
<p className="text-muted-foreground text-sm">
Pick a public username for the community hub. This will be visible on projects you share.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm">
@
</span>
<input
type="text"
value={username}
onChange={(e) => {
setUsername(e.target.value)
setError(null)
}}
placeholder="your-username"
className="w-full rounded-md border border-input bg-background pl-7 pr-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSaving}
autoFocus
minLength={3}
maxLength={30}
/>
</div>
{/* Status indicators */}
{username.trim() && !validationError && (
<div className="text-xs">
{availability === 'checking' && (
<span className="text-muted-foreground">Checking availability...</span>
)}
{availability === 'available' && (
<span className="text-green-600 dark:text-green-400">Username is available</span>
)}
{availability === 'taken' && (
<span className="text-destructive">Username is already taken</span>
)}
</div>
)}
{validationError && (
<p className="text-destructive text-xs">{validationError}</p>
)}
{!username.trim() && (
<p className="text-muted-foreground text-xs">
3-30 characters. Letters, numbers, hyphens, and underscores only.
</p>
)}
</div>
{error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={!canSubmit}
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-50"
>
{isSaving ? 'Setting username...' : 'Continue'}
</button>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,289 @@
'use server'
import { createServerSupabaseClient } from '../database/server'
import { getSession } from '../auth/server'
import { createId } from '../utils/id-generator'
const BUCKET = 'project-assets'
export type AssetType = 'scan' | 'guide'
export type UploadAssetResult =
| { success: true; url: string }
| { success: false; error: string }
export type CreateUploadUrlResult =
| { success: true; signedUrl: string; storageKey: string; assetId: string }
| { success: false; error: string }
export type ConfirmUploadResult =
| { success: true; url: string }
| { success: false; error: string }
export type DeleteAssetResult =
| { success: true }
| { success: false; error: string }
/**
* Upload a scan or guide file to Supabase Storage and record it in project_assets.
* Returns the public HTTPS URL that can be stored directly on the scene node.
*/
export async function uploadProjectAsset(
projectId: string,
file: File,
type: AssetType,
): Promise<UploadAssetResult> {
try {
const session = await getSession()
if (!session?.user?.id) {
return { success: false, error: 'Not authenticated' }
}
const supabase = await createServerSupabaseClient()
// Verify the user owns this project
const { data: project, error: projectError } = await supabase
.from('projects')
.select('owner_id')
.eq('id', projectId)
.single()
if (projectError || !project) {
return { success: false, error: 'Project not found' }
}
if ((project as any).owner_id !== session.user.id) {
return { success: false, error: 'Not authorized to upload to this project' }
}
// Derive extension from file name
const ext = file.name.includes('.') ? file.name.split('.').pop()! : ''
const assetId = createId('asset')
const storageKey = ext ? `${projectId}/${assetId}.${ext}` : `${projectId}/${assetId}`
const arrayBuffer = await file.arrayBuffer()
const bytes = new Uint8Array(arrayBuffer)
const { data: uploadData, error: uploadError } = await supabase.storage
.from(BUCKET)
.upload(storageKey, bytes, {
contentType: file.type || 'application/octet-stream',
upsert: false,
})
if (uploadError) {
return { success: false, error: `Upload failed: ${uploadError.message}` }
}
const { data: urlData } = supabase.storage
.from(BUCKET)
.getPublicUrl(uploadData.path)
const url = urlData.publicUrl
// Record in project_assets table
const { error: insertError } = await (supabase as any).from('project_assets').insert({
id: assetId,
project_id: projectId,
storage_key: storageKey,
url,
type,
original_name: file.name,
mime_type: file.type || null,
})
if (insertError) {
// Best-effort cleanup: remove the uploaded file
await supabase.storage.from(BUCKET).remove([storageKey])
return { success: false, error: `Failed to record asset: ${insertError.message}` }
}
return { success: true, url }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to upload asset',
}
}
}
/**
* Create a signed upload URL so the client can upload directly to Supabase Storage.
* Bypasses Next.js body-size limits — supports files up to the bucket limit (500 MB).
*/
export async function createAssetUploadUrl(
projectId: string,
fileName: string,
contentType: string,
type: AssetType,
): Promise<CreateUploadUrlResult> {
try {
const session = await getSession()
if (!session?.user?.id) {
return { success: false, error: 'Not authenticated' }
}
const supabase = await createServerSupabaseClient()
const { data: project, error: projectError } = await supabase
.from('projects')
.select('owner_id')
.eq('id', projectId)
.single()
if (projectError || !project) {
return { success: false, error: 'Project not found' }
}
if ((project as any).owner_id !== session.user.id) {
return { success: false, error: 'Not authorized to upload to this project' }
}
const ext = fileName.includes('.') ? fileName.split('.').pop()! : ''
const assetId = createId('asset')
const storageKey = ext ? `${projectId}/${assetId}.${ext}` : `${projectId}/${assetId}`
const { data, error } = await supabase.storage
.from(BUCKET)
.createSignedUploadUrl(storageKey)
if (error || !data) {
return { success: false, error: `Failed to create upload URL: ${error?.message}` }
}
return { success: true, signedUrl: data.signedUrl, storageKey, assetId }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to create upload URL',
}
}
}
/**
* Record a successfully uploaded asset in the project_assets table.
* Called after the client uploads the file directly to Supabase Storage.
*/
export async function confirmAssetUpload(
projectId: string,
assetId: string,
storageKey: string,
originalName: string,
mimeType: string | null,
type: AssetType,
): Promise<ConfirmUploadResult> {
try {
const session = await getSession()
if (!session?.user?.id) {
return { success: false, error: 'Not authenticated' }
}
const supabase = await createServerSupabaseClient()
const { data: project, error: projectError } = await supabase
.from('projects')
.select('owner_id')
.eq('id', projectId)
.single()
if (projectError || !project) {
return { success: false, error: 'Project not found' }
}
if ((project as any).owner_id !== session.user.id) {
return { success: false, error: 'Not authorized' }
}
const { data: urlData } = supabase.storage
.from(BUCKET)
.getPublicUrl(storageKey)
const url = urlData.publicUrl
const { error: insertError } = await (supabase as any).from('project_assets').insert({
id: assetId,
project_id: projectId,
storage_key: storageKey,
url,
type,
original_name: originalName,
mime_type: mimeType,
})
if (insertError) {
await supabase.storage.from(BUCKET).remove([storageKey])
return { success: false, error: `Failed to record asset: ${insertError.message}` }
}
return { success: true, url }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to confirm upload',
}
}
}
/**
* Delete a project asset by its public URL.
* Removes both the storage file and the project_assets row.
*/
export async function deleteProjectAssetByUrl(
projectId: string,
url: string,
): Promise<DeleteAssetResult> {
try {
const session = await getSession()
if (!session?.user?.id) {
return { success: false, error: 'Not authenticated' }
}
const supabase = await createServerSupabaseClient()
// Verify ownership
const { data: project, error: projectError } = await supabase
.from('projects')
.select('owner_id')
.eq('id', projectId)
.single()
if (projectError || !project) {
return { success: false, error: 'Project not found' }
}
if ((project as any).owner_id !== session.user.id) {
return { success: false, error: 'Not authorized' }
}
// Derive storage_key from the public URL
// URL format: https://<project>.supabase.co/storage/v1/object/public/project-assets/<storageKey>
const storageKeyFromUrl = url.split(`/${BUCKET}/`)[1]?.split('?')[0]
if (!storageKeyFromUrl) {
return { success: false, error: 'Could not derive storage key from URL' }
}
// Delete from storage directly — remove() is a no-op if the file doesn't exist
const { error: storageError } = await supabase.storage.from(BUCKET).remove([storageKeyFromUrl])
if (storageError) {
return { success: false, error: `Storage delete failed: ${storageError.message}` }
}
// Delete DB row by storage_key scoped to this project
const { error: dbError } = await (supabase as any).from('project_assets')
.delete()
.eq('project_id', projectId)
.eq('storage_key', storageKeyFromUrl)
if (dbError) {
return { success: false, error: `DB delete failed: ${dbError.message}` }
}
return { success: true }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to delete asset',
}
}
}
@@ -0,0 +1,311 @@
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { db, schema } from '@pascal-app/db'
import { eq, and, ne, sql } from 'drizzle-orm'
import { auth } from '@/lib/auth'
import { getSession } from './server'
import { createServerSupabaseClient } from '../database/server'
/**
* Sign in with a social provider (Google)
*/
export async function signInSocial(provider: 'google', callbackURL?: string) {
const result = await auth.api.signInSocial({
body: { provider, callbackURL: callbackURL ?? '/' },
})
revalidatePath('/')
if (result.url && result.redirect) {
redirect(result.url as '/')
}
return result
}
/**
* Update the current user's public username
*/
export async function updateUsername(
username: string,
): Promise<{ success: boolean; error?: string }> {
const session = await getSession()
if (!session?.user) {
return { success: false, error: 'Not authenticated' }
}
// Validate username format
const trimmed = username.trim()
if (trimmed.length < 3) {
return { success: false, error: 'Username must be at least 3 characters' }
}
if (trimmed.length > 30) {
return { success: false, error: 'Username must be at most 30 characters' }
}
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
return {
success: false,
error: 'Username can only contain letters, numbers, hyphens, and underscores',
}
}
// Check if username is already taken (case-insensitive)
const existing = await db
.select({ id: schema.users.id })
.from(schema.users)
.where(
and(
sql`lower(${schema.users.username}) = lower(${trimmed})`,
ne(schema.users.id, session.user.id),
),
)
.limit(1)
if (existing.length > 0) {
return { success: false, error: 'Username is already taken' }
}
const updated = await db
.update(schema.users)
.set({ username: trimmed })
.where(eq(schema.users.id, session.user.id))
.returning({ id: schema.users.id })
if (updated.length === 0) {
return { success: false, error: 'User not found. Please sign out and sign in again.' }
}
revalidatePath('/')
revalidatePath('/settings')
return { success: true }
}
/**
* Get the current user's username
*/
export async function getUsername(): Promise<string | null> {
const session = await getSession()
if (!session?.user) return null
const result = await db
.select({ username: schema.users.username })
.from(schema.users)
.where(eq(schema.users.id, session.user.id))
.limit(1)
return result[0]?.username ?? null
}
/**
* Check if a username is available
*/
export async function checkUsernameAvailability(
username: string,
): Promise<{ available: boolean }> {
const trimmed = username.trim()
if (trimmed.length < 3 || !/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
return { available: false }
}
const existing = await db
.select({ id: schema.users.id })
.from(schema.users)
.where(sql`lower(${schema.users.username}) = lower(${trimmed})`)
.limit(1)
return { available: existing.length === 0 }
}
/**
* Get the current user's full profile
*/
export async function getUserProfile(): Promise<{
username: string | null
githubUrl: string | null
xUrl: string | null
youtubeUrl: string | null
emailNotifications: boolean
} | null> {
const session = await getSession()
if (!session?.user) return null
const result = await db
.select({
username: schema.users.username,
githubUrl: schema.users.githubUrl,
xUrl: schema.users.xUrl,
youtubeUrl: schema.users.youtubeUrl,
emailNotifications: schema.users.emailNotifications,
})
.from(schema.users)
.where(eq(schema.users.id, session.user.id))
.limit(1)
return result[0] ?? null
}
/**
* Update the current user's social profile links
*/
export async function updateProfile(data: {
githubUrl?: string | null
xUrl?: string | null
youtubeUrl?: string | null
}): Promise<{ success: boolean; error?: string }> {
const session = await getSession()
if (!session?.user) {
return { success: false, error: 'Not authenticated' }
}
if (data.githubUrl && !/^https:\/\/(www\.)?github\.com\/.+/.test(data.githubUrl)) {
return { success: false, error: 'Invalid GitHub URL' }
}
if (data.xUrl && !/^https:\/\/(www\.)?(x|twitter)\.com\/.+/.test(data.xUrl)) {
return { success: false, error: 'Invalid X/Twitter URL' }
}
if (data.youtubeUrl && !/^https:\/\/(www\.)?(youtube\.com|youtu\.be)\/.+/.test(data.youtubeUrl)) {
return { success: false, error: 'Invalid YouTube URL' }
}
await db
.update(schema.users)
.set({
githubUrl: data.githubUrl ?? null,
xUrl: data.xUrl ?? null,
youtubeUrl: data.youtubeUrl ?? null,
})
.where(eq(schema.users.id, session.user.id))
revalidatePath('/settings')
return { success: true }
}
/**
* Get a user's public profile by username
*/
export async function getPublicProfile(username: string): Promise<{
success: boolean
data?: {
id: string
name: string
image: string | null
username: string
githubUrl: string | null
xUrl: string | null
youtubeUrl: string | null
}
error?: string
}> {
const result = await db
.select({
id: schema.users.id,
name: schema.users.name,
image: schema.users.image,
username: schema.users.username,
githubUrl: schema.users.githubUrl,
xUrl: schema.users.xUrl,
youtubeUrl: schema.users.youtubeUrl,
})
.from(schema.users)
.where(sql`lower(${schema.users.username}) = lower(${username})`)
.limit(1)
const user = result[0]
if (!user || !user.username) {
return { success: false, error: 'User not found' }
}
return { success: true, data: user as typeof user & { username: string } }
}
/**
* Get connected accounts for the current user
*/
export async function getConnectedAccounts(): Promise<
{ providerId: string; accountId: string }[]
> {
const session = await getSession()
if (!session?.user) return []
const result = await db
.select({
providerId: schema.accounts.providerId,
accountId: schema.accounts.accountId,
})
.from(schema.accounts)
.where(eq(schema.accounts.userId, session.user.id))
return result
}
/**
* Upload avatar image to Supabase Storage and update user record
*/
export async function uploadAvatar(
formData: FormData,
): Promise<{ success: boolean; imageUrl?: string; error?: string }> {
const session = await getSession()
if (!session?.user) {
return { success: false, error: 'Not authenticated' }
}
const file = formData.get('avatar') as File | null
if (!file) {
return { success: false, error: 'No file provided' }
}
if (file.size > 5 * 1024 * 1024) {
return { success: false, error: 'File too large (max 5MB)' }
}
if (!file.type.startsWith('image/')) {
return { success: false, error: 'File must be an image' }
}
const supabase = await createServerSupabaseClient()
const ext = file.name.split('.').pop() || 'png'
const filename = `${session.user.id}/avatar.${ext}`
const { data: uploadData, error: uploadError } = await supabase.storage
.from('avatars')
.upload(filename, file, {
contentType: file.type,
upsert: true,
})
if (uploadError) {
return { success: false, error: `Upload failed: ${uploadError.message}` }
}
const { data: urlData } = supabase.storage.from('avatars').getPublicUrl(uploadData.path)
const imageUrl = `${urlData.publicUrl}?t=${Date.now()}`
// Update user image in database
await db
.update(schema.users)
.set({ image: imageUrl })
.where(eq(schema.users.id, session.user.id))
revalidatePath('/')
revalidatePath('/settings')
return { success: true, imageUrl }
}
/**
* Update the current user's email notification preference
*/
export async function updateEmailNotifications(
enabled: boolean,
): Promise<{ success: boolean; error?: string }> {
const session = await getSession()
if (!session?.user) {
return { success: false, error: 'Not authenticated' }
}
await db
.update(schema.users)
.set({ emailNotifications: enabled })
.where(eq(schema.users.id, session.user.id))
revalidatePath('/settings')
return { success: true }
}
@@ -0,0 +1,7 @@
/**
* Auth client for the editor using better-auth
* Re-exports from @pascal-app/auth package
*/
export { authClient } from '@pascal-app/auth/client'
export type { AuthState, User, Session } from '@pascal-app/auth/client'
@@ -0,0 +1,20 @@
'use client'
import { authClient } from './client'
/**
* Hook to access authentication state using better-auth
* @returns Current auth state including user, session, and loading status
*/
export function useAuth() {
const session = authClient.useSession()
return {
user: session.data?.user ?? null,
session: session.data?.session ?? null,
isAuthenticated: !!session.data?.user && !!session.data?.session,
isLoading: session.isPending,
signOut: () => authClient.signOut(),
signIn: authClient.signIn,
}
}
@@ -0,0 +1,47 @@
import { headers as nextHeaders } from 'next/headers'
import { BASE_URL } from '@/lib/utils'
/**
* Get the current session from Better Auth backend (server-side)
*/
export async function getSession() {
try {
const headersList = await nextHeaders()
// Make authenticated request to the auth backend to get session
const response = await fetch(`${BASE_URL}/api/auth/get-session`, {
headers: {
cookie: headersList.get('cookie') || '',
},
credentials: 'include',
cache: 'no-store',
})
if (!response.ok) {
return null
}
const data = await response.json()
// Better Auth returns the session data directly
if (data?.user && data?.session) {
return {
user: data.user,
session: data.session,
}
}
return null
} catch (error) {
console.error('Failed to get session:', error)
return null
}
}
/**
* Get the current user from the session
*/
export async function getUser() {
const session = await getSession()
return session?.user ?? null
}
@@ -0,0 +1,14 @@
/**
* Supabase server client for database access
*/
import { supabaseAdmin } from '@/lib/supabase/server'
/**
* Create a Supabase client for server-side use with service role key
* This bypasses RLS and allows server actions to query the database directly
* Authentication is handled by Better Auth, permissions enforced by filtering on user_id
*/
export async function createServerSupabaseClient() {
return supabaseAdmin
}
@@ -0,0 +1,92 @@
'use server'
import { createId } from '@pascal-app/db'
import { createServerSupabaseClient } from '../database/server'
import { getSession } from '../auth/server'
const MAX_IMAGES = 5
/**
* Create signed upload URLs so the client can upload images directly to
* Supabase Storage — bypasses Vercel's 4.5 MB serverless body-size limit.
*/
export async function createImageUploadUrls(
files: { name: string; type: string }[],
): Promise<
| { success: true; uploads: { path: string; signedUrl: string }[] }
| { success: false; error: string }
> {
try {
if (files.length > MAX_IMAGES) {
return { success: false, error: `Maximum ${MAX_IMAGES} images allowed` }
}
const supabase = await createServerSupabaseClient()
const uploads: { path: string; signedUrl: string }[] = []
for (const file of files) {
if (!file.type.startsWith('image/')) continue
const ext = file.name.split('.').pop() || 'jpg'
const path = `${createId('img')}.${ext}`
const { data, error } = await (
supabase as ReturnType<typeof import('@supabase/supabase-js').createClient>
).storage
.from('feedback-images')
.createSignedUploadUrl(path)
if (error || !data) {
console.error(`Failed to create signed URL for ${file.name}:`, error)
continue
}
uploads.push({ path, signedUrl: data.signedUrl })
}
return { success: true, uploads }
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : 'Failed to create upload URLs',
}
}
}
/**
* Submit feedback with pre-uploaded image paths.
* Images are already in Supabase Storage — this just records the metadata.
*/
export async function submitFeedback(data: {
message: string
projectId?: string | null
sceneGraph?: unknown
imagePaths?: string[]
}): Promise<{ success: true } | { success: false; error: string }> {
try {
const { message, projectId, sceneGraph, imagePaths } = data
if (!message?.trim()) return { success: false, error: 'Message cannot be empty' }
const session = await getSession()
const supabase = await createServerSupabaseClient()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { error } = await (supabase as any).from('feedback').insert({
id: createId('feedback'),
user_id: session?.user?.id ?? null,
project_id: projectId ?? null,
message: message.trim(),
images: imagePaths && imagePaths.length > 0 ? imagePaths : null,
scene_graph: sceneGraph ?? null,
})
if (error) return { success: false, error: error.message }
return { success: true }
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : 'Failed to submit feedback',
}
}
}
@@ -0,0 +1,999 @@
/**
* Project model actions - Server actions for scene loading/saving
* Manages 3D models (scene graphs) stored in projects_models table
*/
'use server'
import { createServerSupabaseClient } from '../database/server'
import { getSession } from '../auth/server'
import { createId } from '../utils/id-generator'
import type { ActionResult } from '../projects/actions'
import { isSceneGraphEmpty } from './scene-graph-utils'
export interface SceneGraph {
nodes: Record<string, unknown>
rootNodeIds: string[]
}
export interface ProjectModel {
id: string
name: string
version: number
draft: boolean
project_id: string
scene_graph: SceneGraph | null
metadata: Record<string, unknown> | null
created_at: string
updated_at: string
}
export interface ProjectVersionStatus {
publishedVersion: number | null
draftVersion: number | null
latestSavedVersion: number | null
hasUnsavedDraftChanges: boolean
hasPublishableVersion: boolean
}
export interface ProjectModelState extends ProjectVersionStatus {
model: ProjectModel | null
}
export interface ProjectVersionListItem {
id: string
version: number
createdAt: string
updatedAt: string
isPublished: boolean
isDraft: boolean
restoredFromVersion: number | null
}
type ProjectVersionListRow = {
id: string
version: number
draft: boolean
metadata: unknown
created_at: string
updated_at: string
}
interface ProjectOwnershipRow {
id: string
owner_id: string
name: string
published_model_version: number | null
}
type AuthenticatedProjectContext = {
supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>
project: ProjectOwnershipRow
}
function sceneGraphsEqual(
left: SceneGraph | null | undefined,
right: SceneGraph | null | undefined,
): boolean {
return JSON.stringify(left ?? null) === JSON.stringify(right ?? null)
}
function parseModelMetadata(input: unknown): Record<string, unknown> {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
return {}
}
return { ...(input as Record<string, unknown>) }
}
function readRestoredFromVersion(input: unknown): number | null {
const metadata = parseModelMetadata(input)
const restoredFromVersion = metadata.restoredFromVersion
if (typeof restoredFromVersion !== 'number' || !Number.isFinite(restoredFromVersion)) {
return null
}
return restoredFromVersion
}
function buildVersionStatus(params: {
publishedVersion: number | null
draftModel: ProjectModel | null
latestSavedModel: ProjectModel | null
}): ProjectVersionStatus {
const publishedVersion = params.publishedVersion
const draftVersion = params.draftModel?.version ?? null
const latestSavedVersion = params.latestSavedModel?.version ?? null
const hasUnsavedDraftChanges = params.draftModel
? params.latestSavedModel
? !sceneGraphsEqual(params.draftModel.scene_graph, params.latestSavedModel.scene_graph)
: true
: false
const hasPublishableVersion =
latestSavedVersion !== null && latestSavedVersion !== publishedVersion
return {
publishedVersion,
draftVersion: draftVersion,
latestSavedVersion,
hasUnsavedDraftChanges,
hasPublishableVersion,
}
}
async function getProjectVersionModels(
supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>,
projectId: string,
): Promise<
ActionResult<{
draftModel: ProjectModel | null
latestSavedModel: ProjectModel | null
}>
> {
const { data: draftModel, error: draftModelError } = await supabase
.from('projects_models')
.select('*')
.eq('project_id', projectId)
.eq('draft', true)
.is('deleted_at', null)
.order('version', { ascending: false })
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle<ProjectModel>()
if (draftModelError) {
return {
success: false,
error: draftModelError.message,
}
}
const { data: latestSavedModel, error: latestSavedModelError } = await supabase
.from('projects_models')
.select('*')
.eq('project_id', projectId)
.eq('draft', false)
.is('deleted_at', null)
.order('version', { ascending: false })
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle<ProjectModel>()
if (latestSavedModelError) {
return {
success: false,
error: latestSavedModelError.message,
}
}
return {
success: true,
data: {
draftModel: draftModel ?? null,
latestSavedModel: latestSavedModel ?? null,
},
}
}
async function getAuthenticatedProjectContext(
projectId: string,
): Promise<ActionResult<AuthenticatedProjectContext>> {
const session = await getSession()
if (!session?.user) {
return {
success: false,
error: 'Not authenticated',
}
}
const supabase = await createServerSupabaseClient()
const { data: project, error: projectError } = await supabase
.from('projects')
.select('id, owner_id, name, published_model_version')
.eq('id', projectId)
.single<ProjectOwnershipRow>()
if (projectError || !project) {
return {
success: false,
error: 'Project not found',
}
}
if (project.owner_id !== session.user.id) {
return {
success: false,
error: 'Unauthorized',
}
}
return {
success: true,
data: {
supabase,
project,
},
}
}
/**
* Returns publish/draft status for the current project.
*/
export async function getProjectVersionStatus(
projectId: string,
): Promise<ActionResult<ProjectVersionStatus>> {
try {
const contextResult = await getAuthenticatedProjectContext(projectId)
if (!contextResult.success || !contextResult.data) {
return {
success: false,
error: contextResult.error,
}
}
const { supabase, project } = contextResult.data
const versionModelsResult = await getProjectVersionModels(supabase, projectId)
if (!versionModelsResult.success || !versionModelsResult.data) {
return {
success: false,
error: versionModelsResult.error,
}
}
const { draftModel, latestSavedModel } = versionModelsResult.data
return {
success: true,
data: buildVersionStatus({
publishedVersion: project.published_model_version ?? null,
draftModel,
latestSavedModel,
}),
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch project version status',
}
}
}
/**
* List all project versions (including current draft), newest first.
*/
export async function getProjectVersionList(
projectId: string,
): Promise<ActionResult<ProjectVersionListItem[]>> {
try {
const contextResult = await getAuthenticatedProjectContext(projectId)
if (!contextResult.success || !contextResult.data) {
return {
success: false,
error: contextResult.error,
data: [],
}
}
const { supabase, project } = contextResult.data
const { data: versions, error: versionsError } = await supabase
.from('projects_models')
.select('id, version, draft, metadata, created_at, updated_at')
.eq('project_id', projectId)
.is('deleted_at', null)
.order('version', { ascending: false })
.order('created_at', { ascending: false })
.returns<ProjectVersionListRow[]>()
if (versionsError) {
return {
success: false,
error: versionsError.message,
data: [],
}
}
const publishedVersion = project.published_model_version ?? null
return {
success: true,
data: (versions ?? []).map((item) => ({
id: item.id,
version: item.version,
createdAt: item.created_at,
updatedAt: item.updated_at,
isPublished: publishedVersion !== null && item.version === publishedVersion,
isDraft: item.draft,
restoredFromVersion: readRestoredFromVersion(item.metadata),
})),
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch project version list',
data: [],
}
}
}
/**
* Fetch a single version by version number (saved or draft).
*/
export async function getProjectVersionByNumber(
projectId: string,
version: number,
): Promise<ActionResult<ProjectModel | null>> {
try {
const contextResult = await getAuthenticatedProjectContext(projectId)
if (!contextResult.success || !contextResult.data) {
return {
success: false,
error: contextResult.error,
data: null,
}
}
const { supabase } = contextResult.data
const { data: model, error: modelError } = await supabase
.from('projects_models')
.select('*')
.eq('project_id', projectId)
.eq('version', version)
.is('deleted_at', null)
.limit(1)
.maybeSingle<ProjectModel>()
if (modelError) {
return {
success: false,
error: modelError.message,
data: null,
}
}
return {
success: true,
data: model ?? null,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch project version',
data: null,
}
}
}
/**
* Fetch a single version by model id.
*/
export async function getProjectVersionById(
projectId: string,
modelId: string,
): Promise<ActionResult<ProjectModel | null>> {
try {
const contextResult = await getAuthenticatedProjectContext(projectId)
if (!contextResult.success || !contextResult.data) {
return {
success: false,
error: contextResult.error,
data: null,
}
}
const { supabase } = contextResult.data
const { data: model, error: modelError } = await supabase
.from('projects_models')
.select('*')
.eq('project_id', projectId)
.eq('id', modelId)
.is('deleted_at', null)
.limit(1)
.maybeSingle<ProjectModel>()
if (modelError) {
return {
success: false,
error: modelError.message,
data: null,
}
}
return {
success: true,
data: model ?? null,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch project version',
data: null,
}
}
}
/**
* Get the editor model for a project:
* - Draft if one exists
* - Otherwise the published version
* - Otherwise latest available model (legacy fallback)
*/
export async function getProjectModel(projectId: string): Promise<ActionResult<ProjectModelState>> {
try {
const contextResult = await getAuthenticatedProjectContext(projectId)
if (!contextResult.success || !contextResult.data) {
return {
success: false,
error: contextResult.error,
}
}
const { supabase, project } = contextResult.data
const publishedVersion = project.published_model_version ?? null
const versionModelsResult = await getProjectVersionModels(supabase, projectId)
if (!versionModelsResult.success || !versionModelsResult.data) {
return {
success: false,
error: versionModelsResult.error,
}
}
const { draftModel, latestSavedModel } = versionModelsResult.data
let modelToLoad = draftModel ?? null
if (!modelToLoad && publishedVersion !== null) {
const { data: publishedModel, error: publishedModelError } = await supabase
.from('projects_models')
.select('*')
.eq('project_id', projectId)
.eq('version', publishedVersion)
.eq('draft', false)
.is('deleted_at', null)
.limit(1)
.maybeSingle<ProjectModel>()
if (publishedModelError) {
return {
success: false,
error: publishedModelError.message,
}
}
modelToLoad = publishedModel ?? null
}
if (!modelToLoad && latestSavedModel) {
modelToLoad = latestSavedModel
}
if (!modelToLoad) {
const { data: latestModel, error: latestModelError } = await supabase
.from('projects_models')
.select('*')
.eq('project_id', projectId)
.is('deleted_at', null)
.order('version', { ascending: false })
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle<ProjectModel>()
if (latestModelError) {
return {
success: false,
error: latestModelError.message,
}
}
modelToLoad = latestModel ?? null
}
const status = buildVersionStatus({
publishedVersion,
draftModel,
latestSavedModel,
})
return {
success: true,
data: {
model: modelToLoad,
...status,
},
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch project model',
}
}
}
/**
* Save or update the project's draft model scene graph.
*/
export interface SaveProjectModelOptions {
restoredFromVersion?: number | null
}
export async function saveProjectModel(
projectId: string,
sceneGraph: SceneGraph,
options?: SaveProjectModelOptions,
): Promise<ActionResult<ProjectModelState>> {
try {
const contextResult = await getAuthenticatedProjectContext(projectId)
if (!contextResult.success || !contextResult.data) {
return {
success: false,
error: contextResult.error,
}
}
const { supabase, project } = contextResult.data
// Determine if scene graph is empty
const isEmpty = isSceneGraphEmpty(sceneGraph)
// Update the project's is_empty flag
await (supabase.from('projects') as any)
.update({ is_empty: isEmpty })
.eq('id', projectId)
const versionModelsResult = await getProjectVersionModels(supabase, projectId)
if (!versionModelsResult.success || !versionModelsResult.data) {
return {
success: false,
error: versionModelsResult.error,
}
}
const { draftModel: existingDraftModel, latestSavedModel } = versionModelsResult.data
let savedModel: ProjectModel | null = null
const restoredFromVersionOption = options?.restoredFromVersion
const metadataOverride =
restoredFromVersionOption === undefined
? undefined
: (() => {
const metadata = parseModelMetadata(existingDraftModel?.metadata ?? null)
if (typeof restoredFromVersionOption === 'number') {
metadata.restoredFromVersion = restoredFromVersionOption
} else {
delete metadata.restoredFromVersion
}
return Object.keys(metadata).length > 0 ? metadata : null
})()
if (existingDraftModel) {
const updateData: Record<string, unknown> = {
scene_graph: sceneGraph,
updated_at: new Date().toISOString(),
}
if (metadataOverride !== undefined) {
updateData.metadata = metadataOverride
}
const { data: updatedModel, error: updateError } = (await (supabase
.from('projects_models') as any)
.update(updateData)
.eq('id', existingDraftModel.id)
.select()
.single()) as { data: ProjectModel | null; error: any }
if (updateError) {
return {
success: false,
error: updateError.message,
}
}
savedModel = updatedModel as ProjectModel
} else {
const baselineModel = latestSavedModel
if (baselineModel && sceneGraphsEqual(baselineModel.scene_graph, sceneGraph)) {
return {
success: true,
data: {
model: baselineModel,
...buildVersionStatus({
publishedVersion: project.published_model_version ?? null,
draftModel: null,
latestSavedModel: baselineModel,
}),
},
message: 'No draft changes to save',
}
}
const { data: latestModel, error: latestModelError } = await supabase
.from('projects_models')
.select('version')
.eq('project_id', projectId)
.is('deleted_at', null)
.order('version', { ascending: false })
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle<{ version: number }>()
if (latestModelError) {
return {
success: false,
error: latestModelError.message,
}
}
const nextVersion = (latestModel?.version ?? 0) + 1
const modelId = createId('model')
const insertData = {
id: modelId,
project_id: projectId,
name: `${project.name} - Draft v${nextVersion}`,
version: nextVersion,
draft: true,
scene_graph: sceneGraph,
...(metadataOverride !== undefined ? { metadata: metadataOverride } : {}),
}
const { data: newModel, error: createError } = (await (supabase
.from('projects_models') as any)
.insert(insertData)
.select()
.single()) as { data: ProjectModel | null; error: any }
if (createError) {
return {
success: false,
error: createError.message,
}
}
savedModel = newModel as ProjectModel
}
if (!savedModel) {
return {
success: false,
error: 'Failed to save project model',
}
}
const status = buildVersionStatus({
publishedVersion: project.published_model_version ?? null,
draftModel: savedModel,
latestSavedModel,
})
return {
success: true,
data: {
model: savedModel,
...status,
},
message: existingDraftModel
? 'Draft model updated successfully'
: 'Draft model created successfully',
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to save project model',
}
}
}
async function createNextDraftVersion(
supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>,
params: {
projectId: string
projectName: string
sceneGraph: SceneGraph | null
},
): Promise<ActionResult<ProjectModel>> {
const { data: latestModel, error: latestModelError } = await supabase
.from('projects_models')
.select('version')
.eq('project_id', params.projectId)
.is('deleted_at', null)
.order('version', { ascending: false })
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle<{ version: number }>()
if (latestModelError) {
return {
success: false,
error: latestModelError.message,
}
}
const nextVersion = (latestModel?.version ?? 0) + 1
const modelId = createId('model')
const insertData = {
id: modelId,
project_id: params.projectId,
name: `${params.projectName} - Draft v${nextVersion}`,
version: nextVersion,
draft: true,
scene_graph: params.sceneGraph,
}
const { data: newDraftModel, error: createError } = (await (supabase
.from('projects_models') as any)
.insert(insertData)
.select()
.single()) as { data: ProjectModel | null; error: any }
if (createError || !newDraftModel) {
return {
success: false,
error: createError?.message ?? 'Failed to create next draft version',
}
}
return {
success: true,
data: newDraftModel as ProjectModel,
}
}
export interface SaveProjectVersionOptions {
publish?: boolean
}
/**
* Save the current draft into a locked version, and optionally publish it.
*
* Behavior:
* - Save only: lock draft as a saved version, then create the next draft.
* - Save + publish: lock draft, publish it, then create the next draft.
* - Publish when already saved: publish latest saved version directly.
*/
export async function saveProjectVersion(
projectId: string,
options?: SaveProjectVersionOptions,
): Promise<ActionResult<ProjectVersionStatus>> {
try {
const contextResult = await getAuthenticatedProjectContext(projectId)
if (!contextResult.success || !contextResult.data) {
return {
success: false,
error: contextResult.error,
}
}
const { supabase, project } = contextResult.data
const shouldPublish = options?.publish ?? false
let publishedVersion = project.published_model_version ?? null
const versionModelsResult = await getProjectVersionModels(supabase, projectId)
if (!versionModelsResult.success || !versionModelsResult.data) {
return {
success: false,
error: versionModelsResult.error,
}
}
let { draftModel, latestSavedModel } = versionModelsResult.data
let didSaveVersion = false
let didPublishVersion = false
if (draftModel) {
const draftDiffersFromSaved = latestSavedModel
? !sceneGraphsEqual(draftModel.scene_graph, latestSavedModel.scene_graph)
: true
if (draftDiffersFromSaved) {
const { data: lockedModel, error: lockDraftError } = (await (supabase
.from('projects_models') as any)
.update({
draft: false,
updated_at: new Date().toISOString(),
})
.eq('id', draftModel.id)
.select()
.single()) as { data: ProjectModel | null; error: any }
if (lockDraftError || !lockedModel) {
return {
success: false,
error: lockDraftError?.message ?? 'Failed to lock draft version',
}
}
latestSavedModel = lockedModel as ProjectModel
draftModel = null
didSaveVersion = true
}
}
if (shouldPublish) {
if (!latestSavedModel) {
return {
success: false,
error: 'No saved version available to publish',
}
}
if (publishedVersion !== latestSavedModel.version) {
const { error: updateProjectError } = await (supabase
.from('projects') as any)
.update({
published_model_version: latestSavedModel.version,
})
.eq('id', projectId)
if (updateProjectError) {
return {
success: false,
error: updateProjectError.message,
}
}
publishedVersion = latestSavedModel.version
didPublishVersion = true
}
}
// Keep autosave flowing onto a fresh draft whenever we lock/publish a version.
if ((didSaveVersion || didPublishVersion) && !draftModel && latestSavedModel) {
const nextDraftResult = await createNextDraftVersion(supabase, {
projectId,
projectName: project.name,
sceneGraph: latestSavedModel.scene_graph,
})
if (!nextDraftResult.success || !nextDraftResult.data) {
return {
success: false,
error: nextDraftResult.error,
}
}
draftModel = nextDraftResult.data
}
const status = buildVersionStatus({
publishedVersion,
draftModel,
latestSavedModel,
})
let message = 'No version changes'
if (didSaveVersion && didPublishVersion && latestSavedModel) {
message = `Saved and published v${latestSavedModel.version}`
} else if (didSaveVersion && latestSavedModel) {
message = `Saved version v${latestSavedModel.version}`
} else if (didPublishVersion && latestSavedModel) {
message = `Published version v${latestSavedModel.version}`
} else if (shouldPublish && latestSavedModel && publishedVersion === latestSavedModel.version) {
message = `Version v${latestSavedModel.version} is already published`
}
return {
success: true,
data: status,
message,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to save project version',
}
}
}
export interface PublishProjectModelOptions {
version?: number
}
/**
* Publish a project model version to the community.
*
* - If `options.version` is provided, republish that saved version.
* - Otherwise publish using saveProjectVersion(publish=true) behavior.
*/
export async function publishProjectModel(
projectId: string,
options?: PublishProjectModelOptions,
): Promise<ActionResult<ProjectVersionStatus>> {
try {
if (typeof options?.version !== 'number') {
return await saveProjectVersion(projectId, { publish: true })
}
const contextResult = await getAuthenticatedProjectContext(projectId)
if (!contextResult.success || !contextResult.data) {
return {
success: false,
error: contextResult.error,
}
}
const { supabase, project } = contextResult.data
const { data: targetVersionModel, error: targetVersionError } = await supabase
.from('projects_models')
.select('*')
.eq('project_id', projectId)
.eq('version', options.version)
.eq('draft', false)
.is('deleted_at', null)
.limit(1)
.maybeSingle<ProjectModel>()
if (targetVersionError) {
return {
success: false,
error: targetVersionError.message,
}
}
if (!targetVersionModel) {
return {
success: false,
error: `Version ${options.version} is not a saved version`,
}
}
const { error: updateProjectError } = await (supabase
.from('projects') as any)
.update({
published_model_version: targetVersionModel.version,
})
.eq('id', projectId)
if (updateProjectError) {
return {
success: false,
error: updateProjectError.message,
}
}
const versionModelsResult = await getProjectVersionModels(supabase, projectId)
if (!versionModelsResult.success || !versionModelsResult.data) {
return {
success: false,
error: versionModelsResult.error,
}
}
let { draftModel, latestSavedModel } = versionModelsResult.data
if (!latestSavedModel || latestSavedModel.version < targetVersionModel.version) {
latestSavedModel = targetVersionModel
}
if (!draftModel) {
const nextDraftResult = await createNextDraftVersion(supabase, {
projectId,
projectName: project.name,
sceneGraph: targetVersionModel.scene_graph,
})
if (!nextDraftResult.success || !nextDraftResult.data) {
return {
success: false,
error: nextDraftResult.error,
}
}
draftModel = nextDraftResult.data
}
return {
success: true,
data: buildVersionStatus({
publishedVersion: targetVersionModel.version,
draftModel,
latestSavedModel,
}),
message: `Published version v${targetVersionModel.version}`,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to publish project model',
}
}
}
@@ -0,0 +1,263 @@
/**
* Hooks for project model (scene) loading and auto-saving
*/
'use client'
import { useScene } from '@pascal-app/core'
import { useEffect, useRef } from 'react'
import { applySceneGraphToEditor } from '@pascal-app/editor'
import { useProjectStore } from '../projects/store'
import { getProjectModel, saveProjectModel } from './actions'
/** Debounce interval for cloud auto-save (ms). */
const AUTOSAVE_DEBOUNCE_MS = 1_000
export { applySceneGraphToEditor }
/**
* Load the scene when a project becomes active.
* Saves changes automatically with debouncing.
*
* ⚠️ This hook must be mounted in exactly ONE component (the Editor).
* Mounting it in multiple components causes duplicate save calls.
*/
export function useProjectScene() {
// Subscribe to project store
const activeProject = useProjectStore((state) => state.activeProject)
const isLoadingProject = useProjectStore((state) => state.isLoading)
const isVersionPreviewMode = useProjectStore((state) => state.isVersionPreviewMode)
const setAutosaveStatus = useProjectStore((state) => state.setAutosaveStatus)
const lastProjectIdRef = useRef<string | null>(null)
const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
const isSavingRef = useRef(false)
const currentProjectIdRef = useRef<string | null>(null)
// Track whether the scene was just loaded from the server so we can skip
// the first store update (which is the load itself, not a user edit).
const isLoadingSceneRef = useRef(false)
// Track whether there are pending changes that arrived while a save was
// in-flight so we can coalesce them into one follow-up save.
const pendingSaveRef = useRef(false)
const executeSaveRef = useRef<(() => Promise<void>) | null>(null)
// Extract project ID for dependency tracking
const projectId = activeProject?.id ?? null
// Load scene when active project changes
useEffect(() => {
if (isLoadingProject) {
return
}
if (!projectId) {
useProjectStore.getState().setIsVersionPreviewMode(false)
setAutosaveStatus('idle')
return
}
// Skip if same project
if (lastProjectIdRef.current === projectId) {
return
}
lastProjectIdRef.current = projectId
// Load the project's scene
async function loadScene() {
// Suppress auto-save for the store update caused by setScene/clearScene
isLoadingSceneRef.current = true
useProjectStore.getState().setIsVersionPreviewMode(false)
setAutosaveStatus('idle')
useProjectStore.getState().setIsSceneLoading(true)
try {
const result = await getProjectModel(projectId || '')
applySceneGraphToEditor(result.success ? result.data?.model?.scene_graph ?? null : null)
} catch (error) {
// Fall back to an empty scene while preserving editor selection sync.
applySceneGraphToEditor(null)
} finally {
useProjectStore.getState().setIsSceneLoading(false)
}
// Allow auto-save again after a tick (let the store update propagate)
requestAnimationFrame(() => {
isLoadingSceneRef.current = false
setAutosaveStatus('saved')
})
}
loadScene()
}, [projectId, isLoadingProject, setAutosaveStatus])
// Track whether there are unsaved changes (dirty flag for flush-on-exit).
const hasDirtyChangesRef = useRef(false)
// Auto-save scene changes with debouncing
useEffect(() => {
if (!projectId) {
currentProjectIdRef.current = null
executeSaveRef.current = null
setAutosaveStatus('idle')
return
}
currentProjectIdRef.current = projectId
// Use JSON stringification to detect node changes, not just count
let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes)
const unsubscribe = useScene.subscribe((state) => {
// Skip saves triggered by loading a scene from the server
if (isLoadingSceneRef.current) {
// Update the snapshot so the next real edit is compared correctly
lastNodesSnapshot = JSON.stringify(state.nodes)
return
}
if (useProjectStore.getState().isVersionPreviewMode) {
// Do not autosave preview scenes. Keep snapshot aligned so returning to
// latest does not schedule a false-positive save.
setAutosaveStatus('paused')
lastNodesSnapshot = JSON.stringify(state.nodes)
return
}
const currentNodesSnapshot = JSON.stringify(state.nodes)
// Only trigger save if nodes actually changed
if (currentNodesSnapshot === lastNodesSnapshot) {
return
}
lastNodesSnapshot = currentNodesSnapshot
hasDirtyChangesRef.current = true
setAutosaveStatus('pending')
// If a save is in-flight, mark pending so we do one follow-up save
// instead of queuing unlimited concurrent saves.
if (isSavingRef.current) {
pendingSaveRef.current = true
return
}
// Clear existing timeout (debounce reset)
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
}
// Debounce save
saveTimeoutRef.current = setTimeout(() => {
saveTimeoutRef.current = undefined
executeSave()
}, AUTOSAVE_DEBOUNCE_MS)
})
async function executeSave() {
const currentProjectId = currentProjectIdRef.current
if (!currentProjectId) return
if (isLoadingSceneRef.current || useProjectStore.getState().isVersionPreviewMode) {
// Save is paused while previewing older versions.
pendingSaveRef.current = true
setAutosaveStatus('paused')
return
}
const { nodes, rootNodeIds } = useScene.getState()
const sceneGraph = { nodes, rootNodeIds }
isSavingRef.current = true
pendingSaveRef.current = false
setAutosaveStatus('saving')
try {
await saveProjectModel(currentProjectId, sceneGraph)
hasDirtyChangesRef.current = false
setAutosaveStatus('saved')
} finally {
isSavingRef.current = false
// If changes arrived while we were saving, schedule one more save
if (pendingSaveRef.current) {
pendingSaveRef.current = false
setAutosaveStatus('pending')
saveTimeoutRef.current = setTimeout(() => {
saveTimeoutRef.current = undefined
executeSave()
}, AUTOSAVE_DEBOUNCE_MS)
}
}
}
executeSaveRef.current = executeSave
// Flush unsaved changes when the user leaves the page / closes the tab.
// Uses sendBeacon via keepalive fetch so the request survives page unload.
function flushOnExit() {
if (!hasDirtyChangesRef.current || !currentProjectIdRef.current) return
const { nodes, rootNodeIds } = useScene.getState()
const sceneGraph = { nodes, rootNodeIds }
// Best-effort fire-and-forget save. We use the server action directly
// (it's just a POST to a Next.js endpoint). If the browser kills it,
// localStorage still has the data and will sync on next load.
saveProjectModel(currentProjectIdRef.current, sceneGraph).catch(() => {
// Swallow — nothing we can do during unload
})
hasDirtyChangesRef.current = false
}
window.addEventListener('beforeunload', flushOnExit)
return () => {
executeSaveRef.current = null
window.removeEventListener('beforeunload', flushOnExit)
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
}
// Flush on unmount (e.g. navigating away within the SPA)
flushOnExit()
unsubscribe()
}
}, [projectId, setAutosaveStatus])
useEffect(() => {
if (!projectId) return
if (isVersionPreviewMode) {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
saveTimeoutRef.current = undefined
}
if (hasDirtyChangesRef.current) {
pendingSaveRef.current = true
}
setAutosaveStatus('paused')
return
}
if (isSavingRef.current) {
return
}
if (hasDirtyChangesRef.current) {
setAutosaveStatus('pending')
if (!saveTimeoutRef.current) {
saveTimeoutRef.current = setTimeout(() => {
saveTimeoutRef.current = undefined
executeSaveRef.current?.()
}, AUTOSAVE_DEBOUNCE_MS)
}
return
}
setAutosaveStatus('saved')
}, [isVersionPreviewMode, projectId, setAutosaveStatus])
}
@@ -0,0 +1,25 @@
import type { SceneGraph } from './actions'
const DEFAULT_NODE_TYPES = ['site', 'building', 'level']
export function isSceneGraphEmpty(sceneGraph: SceneGraph | any): boolean {
if (!sceneGraph?.nodes) return true
const nodes = Object.values(sceneGraph.nodes) as any[]
if (nodes.length > 3) {
return false
}
const hasNonDefaultNodes = nodes.some((n) => !DEFAULT_NODE_TYPES.includes(n.type))
if (hasNonDefaultNodes) {
return false
}
const levelNode = nodes.find((n) => n.type === 'level')
if (Array.isArray(levelNode?.children) && levelNode.children.length > 0) {
return false
}
return true
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,122 @@
/**
* Project store - Zustand store for project state management
*/
import { create } from 'zustand'
import type { Project } from './types'
import {
getActiveProject,
getUserProjects,
getProjectById,
} from './actions'
interface ProjectStore {
// Autosave lifecycle for the latest draft scene
autosaveStatus: 'idle' | 'pending' | 'saving' | 'saved' | 'paused' | 'error'
// State
activeProject: Project | null
projects: Project[]
isLoading: boolean
isSceneLoading: boolean
isVersionPreviewMode: boolean
error: string | null
// Actions
fetchProjects: () => Promise<void>
fetchActiveProject: () => Promise<void>
setActiveProject: (projectId: string) => Promise<void>
setIsSceneLoading: (loading: boolean) => void
setIsVersionPreviewMode: (preview: boolean) => void
setAutosaveStatus: (status: ProjectStore['autosaveStatus']) => void
initialize: () => Promise<void>
updateActiveThumbnail: (thumbnailUrl: string) => void
}
export const useProjectStore = create<ProjectStore>((set, get) => ({
// Initial state
autosaveStatus: 'idle',
activeProject: null,
projects: [],
isLoading: true,
isSceneLoading: false,
isVersionPreviewMode: false,
error: null,
// Fetch all projects
fetchProjects: async () => {
const result = await getUserProjects()
if (result.success) {
set({ projects: result.data || [], error: null })
} else {
set({ error: result.error || 'Failed to fetch projects', projects: [] })
}
},
// Fetch the active project from database
fetchActiveProject: async () => {
set({ isLoading: true })
const result = await getActiveProject()
if (result.success) {
set({
activeProject: result.data || null,
isLoading: false,
error: null
})
// Note: Auto-select logic removed - now using URL-based routing
// The URL parameter determines which project to load
} else {
set({
error: result.error || 'Failed to fetch active project',
activeProject: null,
isLoading: false
})
}
},
// Set active project by fetching it directly by ID (URL-based, no session update)
setActiveProject: async (projectId: string) => {
set({ isLoading: true })
const result = await getProjectById(projectId)
if (result.success && result.data) {
set({ activeProject: result.data, isLoading: false, error: null })
} else {
set({ isLoading: false, error: result.error || 'Project not found' })
}
},
setIsSceneLoading: (loading: boolean) => {
set({ isSceneLoading: loading })
},
setIsVersionPreviewMode: (preview: boolean) => {
set({ isVersionPreviewMode: preview })
},
setAutosaveStatus: (status) => {
set({ autosaveStatus: status })
},
// Patch the active project's thumbnail URL in place (no refetch)
updateActiveThumbnail: (thumbnailUrl: string) => {
set((state) => ({
activeProject: state.activeProject
? { ...state.activeProject, thumbnail_url: thumbnailUrl }
: null,
}))
},
// Initialize - fetch both projects and active project
initialize: async () => {
set({ isLoading: true })
await Promise.all([
get().fetchProjects(),
get().fetchActiveProject(),
])
},
}))
@@ -0,0 +1,150 @@
/**
* Project-related type definitions
* Isolated from monorepo database schema
*/
// Database table row types
export type DbProject = {
id: string
name: string
owner_id: string
organization_id: string | null
address_id: string | null
created_at: string
updated_at: string
is_private: boolean
is_empty: boolean
show_scans_public: boolean
show_guides_public: boolean
views: number
likes: number
thumbnail_url: string | null
published_model_version: number | null
}
export type DbProjectAddress = {
id: string
street_number?: string
route?: string
city?: string
state?: string
postal_code?: string
country?: string
latitude?: string
longitude?: string
created_at: string
updated_at: string
}
export type DbProjectModel = {
id: string
project_id: string
version: number
scene_graph: any
created_at: string
updated_at: string
deleted_at: string | null
}
export type DbProjectLike = {
id: string
project_id: string
user_id: string
created_at: string
}
// Database schema type for Supabase
export type Database = {
public: {
Tables: {
projects: {
Row: DbProject
Insert: Omit<DbProject, 'created_at' | 'updated_at' | 'views' | 'likes' | 'show_scans_public' | 'show_guides_public' | 'published_model_version'> & { show_scans_public?: boolean; show_guides_public?: boolean; published_model_version?: number | null }
Update: Partial<Omit<DbProject, 'id' | 'created_at' | 'updated_at'>>
}
projects_addresses: {
Row: DbProjectAddress
Insert: Omit<DbProjectAddress, 'created_at' | 'updated_at'>
Update: Partial<Omit<DbProjectAddress, 'id' | 'created_at' | 'updated_at'>>
}
projects_models: {
Row: DbProjectModel
Insert: Omit<DbProjectModel, 'created_at' | 'updated_at' | 'deleted_at'>
Update: Partial<Omit<DbProjectModel, 'id' | 'created_at' | 'updated_at'>>
}
projects_likes: {
Row: DbProjectLike
Insert: Omit<DbProjectLike, 'created_at'>
Update: Partial<Omit<DbProjectLike, 'id' | 'created_at'>>
}
}
Functions: {
increment_project_views: {
Args: { project_id: string }
Returns: undefined
}
get_project_like_count: {
Args: { project_id: string }
Returns: number
}
}
}
}
export type ProjectOwner = {
id: string
name: string
username: string | null
image: string | null
}
export type Project = {
id: string
name: string
owner_id: string
organization_id: string | null
address_id: string | null
created_at: string
updated_at: string
// Community features
is_private: boolean
is_empty: boolean
show_scans_public: boolean
show_guides_public: boolean
views: number
likes: number
thumbnail_url: string | null
published_model_version: number | null
address: {
id: string
street_number?: string
route?: string
city?: string
state?: string
postal_code?: string
country?: string
latitude?: string
longitude?: string
} | null
owner?: ProjectOwner | null
}
export type CreateProjectParams = {
name: string
center?: [number, number]
streetNumber?: string
route?: string
routeShort?: string
neighborhood?: string
city?: string
county?: string
state?: string
stateLong?: string
postalCode?: string
postalCodeSuffix?: string
country?: string
countryLong?: string
rawJson?: Record<string, unknown>
isPrivate?: boolean
sceneGraph?: any
}
@@ -0,0 +1,13 @@
import { customAlphabet } from 'nanoid'
const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
const nanoid = customAlphabet(alphabet, 16)
/**
* Generate a unique ID with optional prefix (matches monorepo implementation)
* @example createId('user') => 'user_Abc123...'
*/
export const createId = (prefix?: string) => {
const id = nanoid()
return prefix ? `${prefix}_${id}` : id
}