splitting editor and community
This commit is contained in:
@@ -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'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
|
||||
}
|
||||
Reference in New Issue
Block a user