Aymeric Rabot 98c1d5247e
feat: filter empty projects from public gallery (#125)
* feat(community): filter empty projects from public gallery

Add is_empty column to projects table so the public gallery only shows
projects with actual content. Scene graph emptiness is computed on save
and on creation, and a backfill migration marks existing non-empty
projects. Also cleans up import ordering, fixes README migration path,
and removes stale feedback table migration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Use ESNext modules and bundler resolution

Update packages/db tsconfig to emit ESNext modules and use 'bundler' moduleResolution. This enables modern ESM output and bundler-style resolution for the db package (keeps outDir and rootDir unchanged).

* feat(editor): add dark mode theme and grid visibility toggle

Add theme state to viewer store with light/dark modes, dark-aware
lighting and background colors, grid show/hide toggle in settings,
and theme toggle buttons in the sidebar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add ground occluder, animated lighting & UI tweaks

Introduce a ground occluder and smoother animated lighting while adjusting editor UI and icons. Viewer: add GroundOccluder (uses polygon-clipping to union slab/zone polygons) and AnimatedBackground, integrate both into the Canvas; improve Lights to interpolate intensities/colors/ambient for smooth theme transitions. Editor: refactor sidebar header to support inline project title editing and move the theme toggle; replace some lucide icons with image assets and add a settings icon (apps/editor/public/icons/settings.png). Also add polygon-clipping to the viewer package dependencies.

* Update dark mode background color and clean up imports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 23:49:48 -05:00

252 lines
6.3 KiB
TypeScript

/**
* Project model actions - Server actions for scene loading/saving
* Manages 3D models (scene graphs) stored in projects_models table
*/
'use server'
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
import { createServerSupabaseClient } from '../database/server'
import { getSession } from '../auth/server'
import { createId } from '../utils/id-generator'
import type { ActionResult } from '../projects/actions'
import { isSceneGraphEmpty } from './scene-graph-utils'
export interface SceneGraph {
nodes: Record<AnyNodeId, AnyNode>
rootNodeIds: AnyNodeId[]
}
export interface ProjectModel {
id: string
name: string
version: number
draft: boolean
project_id: string
scene_graph: SceneGraph | null
created_at: string
updated_at: string
}
/**
* Get the latest model for a project (highest version)
*/
export async function getProjectModel(projectId: string): Promise<ActionResult<ProjectModel | null>> {
try {
const session = await getSession()
if (!session?.user) {
return {
success: false,
error: 'Not authenticated',
data: null,
}
}
const supabase = await createServerSupabaseClient()
// Get the project to verify ownership
const { data: project, error: projectError } = await supabase
.from('projects')
.select('id, owner_id')
.eq('id', projectId)
.single<{ id: string; owner_id: string }>()
if (projectError || !project) {
return {
success: false,
error: 'Project not found',
data: null,
}
}
// Verify ownership
if (project.owner_id !== session.user.id) {
return {
success: false,
error: 'Unauthorized',
data: null,
}
}
// Get the latest model (highest version, then most recent)
const { data: model, error: modelError } = 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)
.single<ProjectModel>()
console.log('[getProjectModel] Query result:', {
projectId,
hasModel: !!model,
modelError: modelError?.message,
errorCode: modelError?.code,
modelKeys: model ? Object.keys(model) : [],
})
if (modelError) {
// No model found is not an error - just return null
if (modelError.code === 'PGRST116') {
console.log('[getProjectModel] No model found (PGRST116), returning null')
return {
success: true,
data: null,
}
}
console.log('[getProjectModel] Database error:', modelError)
return {
success: false,
error: modelError.message,
data: null,
}
}
console.log('[getProjectModel] Model found:', {
id: model.id,
version: model.version,
hasSceneGraph: !!model.scene_graph,
})
return {
success: true,
data: model as ProjectModel,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch project model',
data: null,
}
}
}
/**
* Save or update a project model's scene graph
* If a model exists, updates it. Otherwise creates a new one.
*/
export async function saveProjectModel(
projectId: string,
sceneGraph: SceneGraph,
): Promise<ActionResult<ProjectModel>> {
try {
const session = await getSession()
if (!session?.user) {
return {
success: false,
error: 'Not authenticated',
}
}
const supabase = await createServerSupabaseClient()
// Get the project to verify ownership
const { data: project, error: projectError } = await supabase
.from('projects')
.select('id, owner_id, name')
.eq('id', projectId)
.single<{ id: string; owner_id: string; name: string }>()
if (projectError || !project) {
return {
success: false,
error: 'Project not found',
}
}
// Verify ownership
if (project.owner_id !== session.user.id) {
return {
success: false,
error: 'Unauthorized',
}
}
// 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)
// Check if a model already exists
const { data: existingModel } = await supabase
.from('projects_models')
.select('id, version')
.eq('project_id', projectId)
.is('deleted_at', null)
.order('version', { ascending: false })
.order('created_at', { ascending: false })
.limit(1)
.single<{ id: string; version: number }>()
if (existingModel) {
// Update existing model
const updateData = {
scene_graph: sceneGraph,
updated_at: new Date().toISOString(),
}
const { data: updatedModel, error: updateError } = (await (supabase
.from('projects_models') as any)
.update(updateData)
.eq('id', existingModel.id)
.select()
.single()) as { data: ProjectModel | null; error: any }
if (updateError) {
return {
success: false,
error: updateError.message,
}
}
return {
success: true,
data: updatedModel as ProjectModel,
message: 'Model updated successfully',
}
} else {
// Create new model
const modelId = createId('model')
const insertData = {
id: modelId,
project_id: projectId,
name: `${project.name} - Editor`,
version: 1,
draft: true,
scene_graph: sceneGraph,
}
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,
}
}
return {
success: true,
data: newModel as ProjectModel,
message: 'Model created successfully',
}
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to save project model',
}
}
}