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>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
5077011c26
commit
98c1d5247e
@@ -1,11 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import Editor from '@/components/editor'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useProjectStore } from '@/features/community/lib/projects/store'
|
||||
import { useAuth } from '@/features/community/lib/auth/hooks'
|
||||
import Editor from '@/components/editor'
|
||||
import { SceneLoader } from '@/components/ui/scene-loader'
|
||||
import { useAuth } from '@/features/community/lib/auth/hooks'
|
||||
import { useProjectStore } from '@/features/community/lib/projects/store'
|
||||
|
||||
export default function EditorPage() {
|
||||
const params = useParams()
|
||||
|
||||
@@ -32,6 +32,12 @@ export const Grid = ({
|
||||
fadeStrength?: number
|
||||
revealRadius?: number
|
||||
}) => {
|
||||
const theme = useViewer((state) => state.theme)
|
||||
|
||||
// Use slightly lighter colors for dark mode grid to make it apparent
|
||||
const effectiveCellColor = theme === 'dark' ? '#555566' : cellColor
|
||||
const effectiveSectionColor = theme === 'dark' ? '#666677' : sectionColor
|
||||
|
||||
const cursorPositionRef = useRef(new Vector2(0, 0))
|
||||
|
||||
const material = useMemo(() => {
|
||||
@@ -78,13 +84,16 @@ export const Grid = ({
|
||||
|
||||
// Mix colors based on section grid
|
||||
const gridColor = mix(
|
||||
color(cellColor),
|
||||
color(sectionColor),
|
||||
color(effectiveCellColor),
|
||||
color(effectiveSectionColor),
|
||||
float(sectionThickness).mul(g2).min(1),
|
||||
)
|
||||
|
||||
// Combined alpha with cursor fade
|
||||
const alpha = g1.add(g2).mul(fade).mul(cursorFade)
|
||||
// Baseline alpha: small amount of opacity everywhere the grid exists
|
||||
const baseAlpha = float(0.4) // Subtle global visibility
|
||||
|
||||
// Combined alpha with cursor fade and baseline minimum
|
||||
const alpha = g1.add(g2).mul(fade).mul(cursorFade.max(baseAlpha))
|
||||
const finalAlpha = mix(alpha.mul(0.75), alpha, g2)
|
||||
|
||||
return new MeshBasicNodeMaterial({
|
||||
@@ -96,13 +105,14 @@ export const Grid = ({
|
||||
}, [
|
||||
cellSize,
|
||||
cellThickness,
|
||||
cellColor,
|
||||
effectiveCellColor,
|
||||
sectionSize,
|
||||
sectionThickness,
|
||||
sectionColor,
|
||||
effectiveSectionColor,
|
||||
fadeDistance,
|
||||
fadeStrength,
|
||||
revealRadius,
|
||||
theme,
|
||||
])
|
||||
|
||||
const gridRef = useRef<Mesh>(null!)
|
||||
@@ -137,8 +147,10 @@ export const Grid = ({
|
||||
setGridY(newY)
|
||||
})
|
||||
|
||||
const showGrid = useViewer((state) => state.showGrid)
|
||||
|
||||
return (
|
||||
<mesh rotation-x={-Math.PI / 2} material={material} ref={gridRef}>
|
||||
<mesh rotation-x={-Math.PI / 2} material={material} ref={gridRef} visible={showGrid}>
|
||||
<planeGeometry args={[fadeDistance * 2, fadeDistance * 2]} />
|
||||
</mesh>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { IconRail, type PanelId } from "./icon-rail";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { Pencil, Moon, Sun, Monitor } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -14,15 +15,23 @@ import { SettingsPanel } from "./panels/settings-panel";
|
||||
import { SitePanel } from "./panels/site-panel";
|
||||
import { useProjectStore } from "@/features/community/lib/projects/store";
|
||||
import { updateProjectName } from "@/features/community/lib/projects/actions";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
|
||||
export function AppSidebar() {
|
||||
const [activePanel, setActivePanel] = useState<PanelId>("site");
|
||||
const activeProject = useProjectStore((s) => s.activeProject);
|
||||
|
||||
const theme = useViewer((state) => state.theme);
|
||||
const setTheme = useViewer((state) => state.setTheme);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const [titleValue, setTitleValue] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditingTitle) {
|
||||
setTitleValue(activeProject?.name || "Untitled Project");
|
||||
@@ -93,29 +102,84 @@ export function AppSidebar() {
|
||||
|
||||
{/* Panel Content */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<SidebarHeader className="flex-col items-start justify-center px-3 py-3 gap-1 border-b border-border/50">
|
||||
{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"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-2 group/title cursor-pointer w-full h-7 border-b border-transparent"
|
||||
onClick={() => setIsEditingTitle(true)}
|
||||
>
|
||||
<h1 className="font-semibold text-lg truncate flex-1">
|
||||
{activeProject?.name || "Untitled Project"}
|
||||
</h1>
|
||||
<Pencil className="w-3.5 h-3.5 opacity-0 group-hover/title:opacity-100 transition-opacity text-muted-foreground shrink-0" />
|
||||
<SidebarHeader className="flex-col items-start justify-center px-3 py-3 gap-1 border-b border-border/50 relative">
|
||||
<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"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-2 group/title cursor-pointer w-full h-7 border-b border-transparent"
|
||||
onClick={() => setIsEditingTitle(true)}
|
||||
>
|
||||
<h1 className="font-semibold text-lg truncate">
|
||||
{activeProject?.name || "Untitled Project"}
|
||||
</h1>
|
||||
<Pencil className="w-3.5 h-3.5 opacity-0 group-hover/title:opacity-100 transition-opacity text-muted-foreground shrink-0" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mounted && (
|
||||
<button
|
||||
className="shrink-0 flex items-center bg-accent/50 rounded-full p-1 border border-border/50 cursor-pointer"
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
type="button"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
<div className="relative flex">
|
||||
{/* Sliding Background */}
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-white shadow-sm rounded-full dark:bg-white/20"
|
||||
initial={false}
|
||||
animate={{
|
||||
x: theme === "light" ? "100%" : "0%",
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 500,
|
||||
damping: 35,
|
||||
}}
|
||||
style={{ width: "50%" }}
|
||||
/>
|
||||
|
||||
{/* Dark Mode Icon */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 flex h-6 w-8 items-center justify-center rounded-full transition-colors duration-200 pointer-events-none",
|
||||
theme === "dark"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<Moon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
|
||||
{/* Light Mode Icon */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 flex h-6 w-8 items-center justify-center rounded-full transition-colors duration-200 pointer-events-none",
|
||||
theme === "light"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<Sun className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{getPanelTitle()}
|
||||
</span>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { Building2, Settings } from "lucide-react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/primitives/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
|
||||
export type PanelId = "site" | "settings";
|
||||
|
||||
@@ -18,9 +20,9 @@ interface IconRailProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const panels: { id: PanelId; icon: typeof Building2; label: string }[] = [
|
||||
{ id: "site", icon: Building2, label: "Site" },
|
||||
{ id: "settings", icon: Settings, label: "Settings" },
|
||||
const panels: { id: PanelId; iconSrc: string; label: string }[] = [
|
||||
{ id: "site", iconSrc: "/icons/level.png", label: "Site" },
|
||||
{ id: "settings", iconSrc: "/icons/settings.png", label: "Settings" },
|
||||
];
|
||||
|
||||
export function IconRail({
|
||||
@@ -28,10 +30,18 @@ export function IconRail({
|
||||
onPanelChange,
|
||||
className,
|
||||
}: IconRailProps) {
|
||||
const theme = useViewer((state) => state.theme);
|
||||
const setTheme = useViewer((state) => state.setTheme);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-11 flex-col items-center gap-1 border-border/50 border-r py-2",
|
||||
"flex h-full w-11 flex-col items-center gap-1 border-border/50 border-r py-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -58,7 +68,6 @@ export function IconRail({
|
||||
<div className="w-8 h-px bg-border/50 mb-1" />
|
||||
|
||||
{panels.map((panel) => {
|
||||
const Icon = panel.icon;
|
||||
const isActive = activePanel === panel.id;
|
||||
return (
|
||||
<Tooltip key={panel.id}>
|
||||
@@ -67,19 +76,45 @@ export function IconRail({
|
||||
className={cn(
|
||||
"flex h-9 w-9 items-center justify-center rounded-lg transition-all",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
? "bg-accent"
|
||||
: "hover:bg-accent",
|
||||
)}
|
||||
onClick={() => onPanelChange(panel.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<img
|
||||
src={panel.iconSrc}
|
||||
alt={panel.label}
|
||||
className={cn(
|
||||
"h-6 w-6 transition-all object-contain",
|
||||
!isActive && "opacity-50 saturate-0"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{panel.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Theme Toggle */}
|
||||
{mounted && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg transition-all text-muted-foreground hover:bg-accent hover:text-accent-foreground mb-2"
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
type="button"
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Toggle theme</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -309,6 +309,18 @@ export function SettingsPanel() {
|
||||
onCheckedChange={(checked) => handleVisibilityChange('showGuidesPublic', checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Show Grid</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Visible only in the editor
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={useViewer((state) => state.showGrid)}
|
||||
onCheckedChange={(checked) => useViewer.getState().setShowGrid(checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ This feature requires:
|
||||
- `properties` - Property records
|
||||
- `properties_addresses` - Property addresses
|
||||
- `properties_models` - Scene graph models
|
||||
- Database migrations are managed in `packages/db/supabase/migrations/`
|
||||
- Database migrations are managed in `supabase/migrations/`
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ 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>
|
||||
@@ -166,6 +167,14 @@ export async function saveProjectModel(
|
||||
}
|
||||
}
|
||||
|
||||
// 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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -8,7 +8,8 @@
|
||||
import { createServerSupabaseClient } from '../database/server'
|
||||
import { getSession } from '../auth/server'
|
||||
import { createId } from '../utils/id-generator'
|
||||
import type { CreateProjectParams, Project, Database } from './types'
|
||||
import { isSceneGraphEmpty } from '../models/scene-graph-utils'
|
||||
import type { CreateProjectParams, Project } from './types'
|
||||
|
||||
export type ActionResult<T = unknown> = {
|
||||
success: boolean
|
||||
@@ -250,6 +251,9 @@ export async function createProject(params: CreateProjectParams): Promise<Action
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if scene graph is empty
|
||||
const isEmpty = params.sceneGraph ? isSceneGraphEmpty(params.sceneGraph) : true
|
||||
|
||||
// Create the project
|
||||
const projectData = {
|
||||
id: projectId,
|
||||
@@ -257,6 +261,7 @@ export async function createProject(params: CreateProjectParams): Promise<Action
|
||||
address_id: addressId,
|
||||
owner_id: session.user.id,
|
||||
is_private: params.isPrivate !== undefined ? params.isPrivate : true,
|
||||
is_empty: isEmpty,
|
||||
details_json: params.center
|
||||
? {
|
||||
coordinates: params.center,
|
||||
@@ -401,6 +406,7 @@ export async function getPublicProjects(): Promise<ActionResult<Project[]>> {
|
||||
owner:auth_users!owner_id(id, name, username, image)
|
||||
`)
|
||||
.eq('is_private', false)
|
||||
.eq('is_empty', false)
|
||||
.order('views', { ascending: false })
|
||||
.limit(50)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export type DbProject = {
|
||||
created_at: string
|
||||
updated_at: string
|
||||
is_private: boolean
|
||||
is_empty: boolean
|
||||
show_scans_public: boolean
|
||||
show_guides_public: boolean
|
||||
views: number
|
||||
@@ -106,6 +107,7 @@ export type Project = {
|
||||
updated_at: string
|
||||
// Community features
|
||||
is_private: boolean
|
||||
is_empty: boolean
|
||||
show_scans_public: boolean
|
||||
show_guides_public: boolean
|
||||
views: number
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
Reference in New Issue
Block a user