Feat/ux round 3 (#124)

* Tree: add isLast prop & draw connectors

Introduce an optional isLast prop across all site panel tree node components and TreeNode so children know when they're the last sibling. TreeNodeWrapper now renders vertical and horizontal connector lines (adjusted by depth and isLast) and keeps the toggle button clickable (z-index). Children are passed isLast when mapped, so the UI can stop the vertical line for final siblings. Also bump several deps in apps/editor package.json and update the lockfile accordingly.

* chore: clean up monorepo dependencies and update packages

Move app/library dependencies out of root package.json into their
respective workspaces. Root now only contains monorepo tooling
(turbo, biome, supabase CLI, typescript, portless, ultracite).

Updates:
- next 16.1.0 → 16.1.6
- drizzle-orm ^0.39.0 → ^0.45.1
- drizzle-kit ^0.30.0 → ^0.31.9
- drizzle-zod ^0.5.1 → ^0.8.3
- three-bvh-csg ^0.0.17 → ^0.0.18
- supabase 2.75.3 → 2.76.15
- typescript 5.9.2 → 5.9.3 (all workspaces)

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

* Use semantic theme tokens and enable dark mode

Replace hardcoded zinc color classes with semantic design tokens (text-muted-foreground, text-foreground, border, bg-background, bg-border) across the action menu, camera controls, control modes, and view toggles for consistent theming. Update separators and borders to use bg-border/border-border. Add useEffect in the editor root to add/remove the 'dark' class on document.body and apply 'dark text-foreground' to the root container so the UI renders in dark mode; also import useEffect.

* Persistable sidebar width & resizer

Introduce a zustand store to persist sidebar width (key: `sidebar-preferences`) and track dragging state. Default width is 288px (18rem) and is clamped between 288 and 800px. Wire the store into SidebarProvider to drive the CSS variable for width and expose a data-dragging attribute. Add a SidebarResizer component that handles pointer events to resize the sidebar, toggles dragging state, updates cursor/user-select, and disables transitions while dragging. Small class and structure adjustments ensure the resizer is rendered and resizing behaves smoothly.

* Editor: floating action menu, selection/phase tweaks

Add a FloatingActionMenu (move/duplicate/delete) that attaches to selected 3D nodes and integrates with editor/viewer state; includes new cursor SVG and layout wrapper to use the custom cursor. Improve selection and phase behavior: auto-select first building+level on scene load (moved/centralized in project scene hook and editor init), add resolveBuildingId helper, and enhance SelectionManager to auto-switch between structure/furnish based on clicked nodes and to preserve building/level context when changing selection. Update sidebar tree node click handlers to switch phases for structural vs item nodes and stop hiding nodes by phase (keep tree items visible across phases). Add keyboard escape behavior to clear selections while keeping building/level context and reset selected reference. Misc: register unified event listeners for selection, position floating menu over objects using three.js Box3, and various small integration fixes.

* Add SceneLoader, loading states & editor fixes

Add a SceneLoader component and associated CSS loader styles and wire it into Editor, Viewer and page-level flows so a loading UI displays while project/scene data loads. Replace useLayoutEffect in editor page with a client-mounted useEffect guard to avoid hydration issues. Introduce isSceneLoading state and setter in the project store and set/clear it in useProjectScene; include sceneGraph when creating a new project and clear the scene beforehand.

Other changes: type-guard moving/duplicating logic in FloatingActionMenu and remove duplicated id on clones; refactor auto-selection logic for site/building/level and auto-select build/wall for empty levels in editor index and useProjectScene; optimize ZoneSystem to run per-frame (useFrame) and reduce unnecessary updates to visibility/label positions; add animated active background for layer toggle (motion) in site panel; add keyboard shortcuts to jump between levels (Ctrl/Cmd+ArrowUp/Down); simplify community-hub project-created handler to navigate straight to editor. These changes improve UX during load, enforce safer node operations, and optimize runtime rendering.

* Improve selection logic and refine UI components

Multiple editor and viewer updates:

- Selection: enhance selection-manager to support modifier (Meta/Ctrl) multi-select, compute next selection logic, track modifier keys, prevent grid deselect races, and add debug logs. Adapted selection handlers throughout scene/editor components to pass native events and modifier state.

- Sidebar / Site panel: refactor site panel to inline a LevelReferences UI (replacing the removed references dialog). Add upload flow for scan (.glb/.gltf) and guide images, handling file size/type checks, project asset upload/delete calls, and per-level reference lists. Add MultiSelectionBadge for showing/clearing multi-selection and animated expand/collapse for levels. Several tree-node components now use a shared handleTreeSelection helper and propagate click events properly.

- UI polish & icons: change dark theme color tokens for better contrast; increase button sizes and icon sizes across action menus; swap some lucide icons for image assets (rotate, topview, select, build, mesh, floorplan, level, etc.) and add new public icons.

- PascalRadio: replace popover with a framer-motion expandable panel, add outside-click handling, animated transitions, and minor UI tweaks.

- Other: minor viewer hook/selection-manager updates and tweaks to control modes and view toggles to support image icons and consistent sizing.

These changes improve multi-select reliability, user interactions in the sidebar, and overall visual polish with new icons and animations.

* Fix TypeScript build errors in selection-manager and scene-loader

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:
Aymeric Rabot
2026-02-26 22:09:40 -05:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 441517424d
commit 5ecefbdc06
48 changed files with 1866 additions and 953 deletions
@@ -14,5 +14,9 @@ export default function EditorProjectLayout({
}: Readonly<{
children: React.ReactNode
}>) {
return children
return (
<div style={{ cursor: "url('/cursor.svg') 4 2, default" }}>
{children}
</div>
)
}
+13 -3
View File
@@ -2,9 +2,10 @@
import Editor from '@/components/editor'
import { useParams, useRouter } from 'next/navigation'
import { useLayoutEffect } from 'react'
import { useEffect, useState } from 'react'
import { useProjectStore } from '@/features/community/lib/projects/store'
import { useAuth } from '@/features/community/lib/auth/hooks'
import { SceneLoader } from '@/components/ui/scene-loader'
export default function EditorPage() {
const params = useParams()
@@ -12,9 +13,14 @@ export default function EditorPage() {
const { isAuthenticated, isLoading } = useAuth()
const setActiveProject = useProjectStore((state) => state.setActiveProject)
const router = useRouter()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
// Use layoutEffect to set active project BEFORE the editor renders and hooks run
useLayoutEffect(() => {
useEffect(() => {
if (isLoading) return
if (!isAuthenticated) {
router.replace('/')
@@ -25,7 +31,11 @@ export default function EditorPage() {
}
}, [projectId, isAuthenticated, isLoading, setActiveProject, router])
if (isLoading || !isAuthenticated) {
if (!mounted || isLoading) {
return <SceneLoader fullScreen />
}
if (!isAuthenticated) {
return null
}
+95 -3
View File
@@ -85,7 +85,7 @@
}
.dark {
--background: oklch(0.145 0 0);
--background: oklch(0.205 0 0); /* ~171717 */
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
@@ -97,7 +97,7 @@
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent: oklch(0.235 0 0); /* slightly lighter than background (0.205) but darker than previous (0.269) */
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
@@ -112,7 +112,7 @@
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent: oklch(0.235 0 0); /* matching accent */
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
@@ -161,3 +161,95 @@
scroll-behavior: auto !important;
}
}
/* Loaders */
.pascal-loader-1 {
width: 45px;
aspect-ratio: 1;
--c:no-repeat linear-gradient(currentColor 0 0);
background: var(--c), var(--c), var(--c);
animation:
pascal-l1-1 1s infinite,
pascal-l1-2 1s infinite;
}
@keyframes pascal-l1-1 {
0%,100% {background-size:20% 100%}
33%,66% {background-size:20% 20%}
}
@keyframes pascal-l1-2 {
0%,33% {background-position: 0 0,50% 50%,100% 100%}
66%,100% {background-position: 100% 0,50% 50%,0 100%}
}
.pascal-loader-2 {
width: 45px;
aspect-ratio: .75;
--c: no-repeat linear-gradient(currentColor 0 0);
background:
var(--c) 0% 50%,
var(--c) 50% 50%,
var(--c) 100% 50%;
background-size: 20% 50%;
animation: pascal-l2 1s infinite linear;
}
@keyframes pascal-l2 {
20% {background-position: 0% 0% ,50% 50% ,100% 50% }
40% {background-position: 0% 100%,50% 0% ,100% 50% }
60% {background-position: 0% 50% ,50% 100%,100% 0% }
80% {background-position: 0% 50% ,50% 50% ,100% 100%}
}
.pascal-loader-3 {
width: 45px;
aspect-ratio: .75;
--c:no-repeat linear-gradient(currentColor 0 0);
background:
var(--c) 0% 100%,
var(--c) 50% 100%,
var(--c) 100% 100%;
background-size: 20% 65%;
animation: pascal-l3 1s infinite linear;
}
@keyframes pascal-l3 {
16.67% {background-position: 0% 0% ,50% 100%,100% 100%}
33.33% {background-position: 0% 0% ,50% 0% ,100% 100%}
50% {background-position: 0% 0% ,50% 0% ,100% 0% }
66.67% {background-position: 0% 100%,50% 0% ,100% 0% }
83.33% {background-position: 0% 100%,50% 100%,100% 0% }
}
.pascal-loader-4 {
width: 45px;
aspect-ratio: 1;
--c:no-repeat linear-gradient(currentColor 0 0);
background: var(--c), var(--c), var(--c);
animation:
pascal-l4-1 1s infinite,
pascal-l4-2 1s infinite;
}
@keyframes pascal-l4-1 {
0%,100% {background-size:20% 100%}
33%,66% {background-size:20% 40%}
}
@keyframes pascal-l4-2 {
0%,33% {background-position: 0 0,50% 100%,100% 100%}
66%,100% {background-position: 100% 0,0 100%,50% 100%}
}
.pascal-loader-5 {
width: 45px;
aspect-ratio: 1;
--c:no-repeat linear-gradient(currentColor 0 0);
background: var(--c), var(--c), var(--c);
animation:
pascal-l5-1 1s infinite,
pascal-l5-2 1s infinite;
}
@keyframes pascal-l5-1 {
0%,100% {background-size:20% 100%}
33%,66% {background-size:20% 40%}
}
@keyframes pascal-l5-2 {
0%,33% {background-position: 0 0 ,50% 100%,100% 0}
66%,100% {background-position: 0 100%,50% 0 ,100% 100%}
}
+16 -15
View File
@@ -14,6 +14,8 @@ import { ViewerGuestCTA } from './viewer-guest-cta'
import { ViewerOverlay } from './viewer-overlay'
import { ViewerZoneSystem } from './viewer-zone-system'
import { SceneLoader } from '@/components/ui/scene-loader'
export default function ViewerPage() {
const params = useParams()
const id = params.id as string
@@ -90,14 +92,6 @@ export default function ViewerPage() {
loadContent()
}, [id, setScene])
if (loading) {
return (
<div className="flex h-screen w-full items-center justify-center bg-neutral-100">
<p className="text-muted-foreground">Loading...</p>
</div>
)
}
if (error) {
return (
<div className="flex h-screen w-full items-center justify-center bg-neutral-100">
@@ -108,13 +102,20 @@ export default function ViewerPage() {
return (
<div className="relative h-screen w-full">
<ViewerOverlay
projectName={projectName}
owner={owner}
canShowScans={canShowScans}
canShowGuides={canShowGuides}
/>
<ViewerGuestCTA />
{loading && <SceneLoader fullScreen />}
{!loading && (
<>
<ViewerOverlay
projectName={projectName}
owner={owner}
canShowScans={canShowScans}
canShowGuides={canShowGuides}
/>
<ViewerGuestCTA />
</>
)}
<Viewer>
<ViewerCameraControls />
<ViewerZoneSystem />
@@ -0,0 +1,141 @@
'use client'
import { type AnyNode, type AnyNodeId, ItemNode, WindowNode, DoorNode, sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useFrame } from '@react-three/fiber'
import { Copy, Move, Trash2 } from 'lucide-react'
import { useCallback, useRef } from 'react'
import * as THREE from 'three'
import { sfxEmitter } from '@/lib/sfx-bus'
import useEditor from '@/store/use-editor'
const ALLOWED_TYPES = ['item', 'door', 'window']
export function FloatingActionMenu() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const nodes = useScene((s) => s.nodes)
const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const setSelection = useViewer((s) => s.setSelection)
const isEditor = useViewer((state) => state.isEditor)
const groupRef = useRef<THREE.Group>(null)
// Only show for single selection of specific types
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
const node = selectedId ? nodes[selectedId as AnyNodeId] : null
const isValidType = node ? ALLOWED_TYPES.includes(node.type) : false
useFrame(() => {
if (!selectedId || !isValidType || !groupRef.current) return
const obj = sceneRegistry.nodes.get(selectedId)
if (obj) {
// Calculate bounding box in world space
const box = new THREE.Box3().setFromObject(obj)
if (!box.isEmpty()) {
const center = box.getCenter(new THREE.Vector3())
// Position slightly above the object
groupRef.current.position.set(center.x, box.max.y + 0.3, center.z)
}
}
})
const handleMove = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
if (!node) return
sfxEmitter.emit('sfx:item-pick')
if (node.type === 'item' || node.type === 'window' || node.type === 'door') {
setMovingNode(node as any)
}
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
const handleDuplicate = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
if (!node || !node.parentId) return
sfxEmitter.emit('sfx:item-pick')
useScene.temporal.getState().pause()
let duplicateInfo = structuredClone(node) as any
delete duplicateInfo.id
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
let duplicate: AnyNode | null = null
try {
if (node.type === 'door') {
duplicate = DoorNode.parse(duplicateInfo)
} else if (node.type === 'window') {
duplicate = WindowNode.parse(duplicateInfo)
} else if (node.type === 'item') {
duplicate = ItemNode.parse(duplicateInfo)
}
} catch (error) {
console.error('Failed to parse duplicate', error)
return
}
if (duplicate) {
if (duplicate.type === 'door' || duplicate.type === 'window') {
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
}
if (duplicate.type === 'item' || duplicate.type === 'window' || duplicate.type === 'door') {
setMovingNode(duplicate as any)
}
setSelection({ selectedIds: [] })
}
}, [node, setMovingNode, setSelection])
const handleDelete = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
if (!selectedId || !node) return
sfxEmitter.emit('sfx:item-delete')
deleteNode(selectedId as AnyNodeId)
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
setSelection({ selectedIds: [] })
}, [selectedId, node, deleteNode, setSelection])
if (!isEditor || !selectedId || !node || !isValidType) return null
return (
<group ref={groupRef}>
<Html
center
zIndexRange={[100, 0]}
style={{
pointerEvents: 'auto',
touchAction: 'none'
}}
>
<div
className="flex items-center gap-1 p-1 rounded-lg border border-border bg-background/95 shadow-xl backdrop-blur-md"
onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()}
>
<button
onClick={handleMove}
className="p-1.5 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors tooltip-trigger"
title="Move"
>
<Move className="w-4 h-4" />
</button>
<button
onClick={handleDuplicate}
className="p-1.5 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors tooltip-trigger"
title="Duplicate"
>
<Copy className="w-4 h-4" />
</button>
<button
onClick={handleDelete}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors tooltip-trigger"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</Html>
</group>
)
}
+45 -3
View File
@@ -1,7 +1,8 @@
'use client'
import { useEffect } from 'react'
import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core'
import { Viewer } from '@pascal-app/viewer'
import { Viewer, useViewer } from '@pascal-app/viewer'
import { useProjectScene } from '@/features/community/lib/models/hooks'
import { useKeyboard } from '@/hooks/use-keyboard'
import { initSFXBus } from '@/lib/sfx-bus'
@@ -18,15 +19,43 @@ import { SidebarProvider } from '../ui/primitives/sidebar'
import { AppSidebar } from '../ui/sidebar/app-sidebar'
import { CustomCameraControls } from './custom-camera-controls'
import { ExportManager } from './export-manager'
import { FloatingActionMenu } from './floating-action-menu'
import { Grid } from './grid'
import { SelectionManager } from './selection-manager'
import { ThumbnailGenerator } from './thumbnail-generator'
import { useProjectStore } from '@/features/community/lib/projects/store'
import { SceneLoader } from '../ui/scene-loader'
// Load default scene initially (will be replaced when project loads)
useScene.getState().loadScene()
initSpatialGridSync()
initSpaceDetectionSync(useScene, useEditor)
// Auto-select the first building and level for the default scene
const sceneNodes = useScene.getState().nodes as Record<string, any>
const sceneRootIds = useScene.getState().rootNodeIds
const siteNode = sceneRootIds[0] ? sceneNodes[sceneRootIds[0]] : null
const resolve = (child: any) => typeof child === 'string' ? sceneNodes[child] : child
const firstBuilding = siteNode?.children?.map(resolve).find((n: any) => n?.type === 'building')
const firstLevel = firstBuilding?.children?.map(resolve).find((n: any) => n?.type === 'level')
if (firstBuilding && firstLevel) {
useViewer.getState().setSelection({
buildingId: firstBuilding.id,
levelId: firstLevel.id,
selectedIds: [],
zoneId: null,
})
useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('elements')
// Auto-select the wall tool if the level is empty
if (!firstLevel.children || firstLevel.children.length === 0) {
useEditor.getState().setMode('build')
useEditor.getState().setTool('wall')
}
}
// Initialize SFX bus to connect events to sound effects
initSFXBus()
@@ -38,14 +67,26 @@ export default function Editor({ projectId }: EditorProps) {
useKeyboard()
useProjectScene()
const isProjectLoading = useProjectStore((state) => state.isLoading)
const isSceneLoading = useProjectStore((state) => state.isSceneLoading)
const isLoading = isProjectLoading || isSceneLoading
useEffect(() => {
document.body.classList.add('dark')
return () => {
document.body.classList.remove('dark')
}
}, [])
return (
<div className="w-full h-full">
<div className="w-full h-full dark text-foreground">
{isLoading && <SceneLoader />}
<ActionMenu />
<PanelManager />
<HelperManager />
{/* Top-right controls */}
<div className="pointer-events-none fixed top-4 right-4 z-50 flex items-center gap-2">
<div className="pointer-events-none fixed top-4 right-4 z-50 flex items-start gap-2">
<div className="pointer-events-auto">
<PascalRadio />
</div>
@@ -59,6 +100,7 @@ export default function Editor({ projectId }: EditorProps) {
</SidebarProvider>
<Viewer selectionManager="custom" isEditor={true}>
<SelectionManager />
<FloatingActionMenu />
<ExportManager />
{/* Editor only system to toggle zone visibility */}
<ZoneSystem />
@@ -10,7 +10,7 @@ import {
} from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import useEditor from "@/store/use-editor";
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
@@ -22,13 +22,58 @@ const isNodeInCurrentLevel = (node: AnyNode): boolean => {
type SelectableNodeType = "wall" | "item" | "building" | "zone" | 'slab' | 'ceiling' | 'roof' | 'window' | 'door';
type ModifierKeys = {
meta: boolean;
ctrl: boolean;
};
interface SelectionStrategy {
types: SelectableNodeType[];
handleSelect: (node: AnyNode, isShift: boolean) => void;
handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void;
handleDeselect: () => void;
isValid: (node: AnyNode) => boolean;
}
export const resolveBuildingId = (levelId: string, nodes: Record<string, AnyNode>): string | null => {
const level = nodes[levelId];
if (!level) return null;
if (level.parentId && nodes[level.parentId]?.type === "building") {
return level.parentId;
}
return null;
};
const computeNextIds = (
node: AnyNode,
selectedIds: string[],
event?: any,
modifierKeys?: ModifierKeys
): string[] => {
const isMeta = event?.metaKey || event?.nativeEvent?.metaKey || modifierKeys?.meta || false;
const isCtrl = event?.ctrlKey || event?.nativeEvent?.ctrlKey || modifierKeys?.ctrl || false;
console.log("computeNextIds:", {
nodeId: node.id,
selectedIds,
isMeta,
isCtrl,
eventMeta: event?.metaKey,
nativeMeta: event?.nativeEvent?.metaKey,
modMeta: modifierKeys?.meta
});
if (isMeta || isCtrl) {
if (selectedIds.includes(node.id)) {
return selectedIds.filter((id) => id !== node.id);
} else {
return [...selectedIds, node.id];
}
}
// Not holding modifiers: select only this node
return [node.id];
};
const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
site: {
types: ["building"],
@@ -45,17 +90,28 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
structure: {
types: ["wall", "item", "zone", "slab", "ceiling", "roof", "window", "door"],
handleSelect: (node, isShift) => {
handleSelect: (node, nativeEvent, modifierKeys) => {
const { selection, setSelection } = useViewer.getState();
const nodes = useScene.getState().nodes;
const nodeLevelId = resolveLevelId(node, nodes);
const buildingId = resolveBuildingId(nodeLevelId, nodes);
const updates: any = {};
if (nodeLevelId !== "default" && nodeLevelId !== selection.levelId) {
updates.levelId = nodeLevelId;
}
if (buildingId && buildingId !== selection.buildingId) {
updates.buildingId = buildingId;
}
if (node.type === 'zone') {
setSelection({ zoneId: node.id });
updates.zoneId = node.id;
// Don't reset selectedIds in structure phase for zone, but if we changed level, it might reset them via hierarchy guard.
// Wait, the hierarchy guard resets zoneId if levelId changes. That's fine since we provide zoneId.
setSelection(updates);
} else {
const nextIds = isShift
? selection.selectedIds.includes(node.id)
? selection.selectedIds.filter((id) => id !== node.id)
: [...selection.selectedIds, node.id]
: [node.id];
setSelection({ selectedIds: nextIds });
updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys);
setSelection(updates);
}
},
handleDeselect: () => {
@@ -89,14 +145,22 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
furnish: {
types: ["item"],
handleSelect: (node, isShift) => {
handleSelect: (node, nativeEvent, modifierKeys) => {
const { selection, setSelection } = useViewer.getState();
const nextIds = isShift
? selection.selectedIds.includes(node.id)
? selection.selectedIds.filter((id) => id !== node.id)
: [...selection.selectedIds, node.id]
: [node.id];
setSelection({ selectedIds: nextIds });
const nodes = useScene.getState().nodes;
const nodeLevelId = resolveLevelId(node, nodes);
const buildingId = resolveBuildingId(nodeLevelId, nodes);
const updates: any = {};
if (nodeLevelId !== "default" && nodeLevelId !== selection.levelId) {
updates.levelId = nodeLevelId;
}
if (buildingId && buildingId !== selection.buildingId) {
updates.buildingId = buildingId;
}
updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys);
setSelection(updates);
},
handleDeselect: () => {
useViewer.getState().setSelection({ selectedIds: [] });
@@ -113,39 +177,116 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
export const SelectionManager = () => {
const phase = useEditor((s) => s.phase);
const mode = useEditor((s) => s.mode);
const modifierKeysRef = useRef<ModifierKeys>({
meta: false,
ctrl: false,
});
const clickHandledRef = useRef(false);
const movingNode = useEditor((s) => s.movingNode);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Meta") modifierKeysRef.current.meta = true;
if (event.key === "Control") modifierKeysRef.current.ctrl = true;
};
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === "Meta") modifierKeysRef.current.meta = false;
if (event.key === "Control") modifierKeysRef.current.ctrl = false;
};
const clearModifiers = () => {
modifierKeysRef.current.meta = false;
modifierKeysRef.current.ctrl = false;
};
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
window.addEventListener("blur", clearModifiers);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("blur", clearModifiers);
};
}, []);
useEffect(() => {
if (mode !== "select") return;
if (movingNode) return;
const strategy = SELECTION_STRATEGIES[phase];
if (!strategy) return;
const onClick = (event: NodeEvent) => {
if (!strategy.isValid(event.node)) return;
const node = event.node;
let currentPhase = useEditor.getState().phase;
let targetPhase = currentPhase;
event.stopPropagation();
const isShift = event.nativeEvent?.shiftKey;
strategy.handleSelect(event.node, isShift ?? false);
// Auto-switch between structure and furnish phases when clicking elements on the same level
if (currentPhase === "structure" || currentPhase === "furnish") {
if (isNodeInCurrentLevel(node)) {
if (
node.type === "wall" ||
node.type === "slab" ||
node.type === "ceiling" ||
node.type === "roof" ||
node.type === "window" ||
node.type === "door"
) {
targetPhase = "structure";
} else if (node.type === "item") {
const item = node as ItemNode;
if (item.asset.category === "door" || item.asset.category === "window") {
targetPhase = "structure";
} else {
targetPhase = "furnish";
}
}
if (targetPhase !== currentPhase) {
useEditor.getState().setPhase(targetPhase);
if (targetPhase === "structure" && useEditor.getState().structureLayer === "zones") {
useEditor.getState().setStructureLayer("elements");
}
currentPhase = targetPhase;
}
}
}
const activeStrategy = SELECTION_STRATEGIES[currentPhase];
if (activeStrategy?.isValid(node)) {
event.stopPropagation();
clickHandledRef.current = true;
console.log("[SelectionManager] Valid click on:", node.type, node.id, "Shift:", event.nativeEvent.shiftKey);
activeStrategy.handleSelect(node, event.nativeEvent, modifierKeysRef.current);
// Reset the handled flag after a short delay to allow grid:click to be ignored
setTimeout(() => {
clickHandledRef.current = false;
}, 50);
}
};
// Bind listeners for all potential types this strategy might care about
strategy.types.forEach((type) => {
emitter.on(`${type}:click`, onClick);
const allTypes = ["wall", "item", "building", "zone", "slab", "ceiling", "roof", "window", "door"];
allTypes.forEach((type) => {
emitter.on(`${type}:click` as any, onClick as any);
});
const onGridClick = () => strategy.handleDeselect();
const onGridClick = () => {
if (clickHandledRef.current) return;
console.log("onGridClick triggered! Deselecting.");
const activeStrategy = SELECTION_STRATEGIES[useEditor.getState().phase];
if (activeStrategy) activeStrategy.handleDeselect();
};
emitter.on("grid:click", onGridClick);
return () => {
strategy.types.forEach((type) => {
emitter.off(`${type}:click`, onClick);
allTypes.forEach((type) => {
emitter.off(`${type}:click` as any, onClick as any);
});
emitter.off("grid:click", onGridClick);
};
}, [phase, mode, movingNode]);
}, [mode, movingNode]);
// Global double-click handler for auto-switching phases and cross-phase hover
useEffect(() => {
@@ -231,8 +372,7 @@ export const SelectionManager = () => {
const strategy = SELECTION_STRATEGIES[targetPhase];
if (strategy) {
const isShift = event.nativeEvent?.shiftKey;
strategy.handleSelect(node, isShift ?? false);
strategy.handleSelect(node, event.nativeEvent, modifierKeysRef.current);
}
}
};
+116 -62
View File
@@ -3,10 +3,10 @@
import { Howl } from 'howler'
import { Disc3, Settings2, SkipBack, SkipForward, Volume2, VolumeX } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/primitives/popover'
import { Slider } from '@/components/ui/slider'
import { cn } from '@/lib/utils'
import useAudio from '@/store/use-audio'
import { motion, AnimatePresence } from 'framer-motion'
const PLAYLIST = [
{
@@ -66,6 +66,8 @@ export function PascalRadio() {
const [currentTrackIndex, setCurrentTrackIndex] = useState(0)
const { masterVolume, radioVolume, muted, isRadioPlaying, setRadioPlaying } = useAudio()
const soundRef = useRef<Howl | null>(null)
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const currentTrack = shuffledPlaylist[currentTrackIndex]!
@@ -144,74 +146,126 @@ export function PascalRadio() {
useAudio.setState({ radioVolume: value[0] })
}
// Handle click outside to close
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [isOpen])
return (
<div 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">
<Disc3 className={cn('h-4 w-4', isRadioPlaying && 'animate-spin')} />
<span className="hidden sm:inline">Radio Pascal</span>
<div
onClick={handlePlayPause}
className="rounded-sm p-1 transition-all cursor-pointer bg-accent/30 hover:bg-accent hover:text-accent-foreground hover:shadow-sm"
role="button"
tabIndex={0}
aria-label={isRadioPlaying ? 'Pause' : 'Play'}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handlePlayPause()
}
}}
>
{isRadioPlaying ? <Volume2 className="h-3.5 w-3.5" /> : <VolumeX className="h-3.5 w-3.5" />}
</div>
<Popover>
<PopoverTrigger asChild>
<motion.div
ref={containerRef}
layout
onClick={() => {
if (!isOpen) setIsOpen(true)
}}
transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
className={cn(
"flex flex-col rounded-lg border border-border bg-background/95 shadow-lg backdrop-blur-md overflow-hidden",
!isOpen && "cursor-pointer hover:bg-accent/30 transition-colors"
)}
>
<div className="flex items-center justify-between gap-2 px-3 py-2 text-sm font-medium">
<div className="flex items-center gap-2">
<Disc3 className={cn('h-4 w-4 shrink-0', isRadioPlaying && 'animate-spin')} />
<span className="hidden sm:inline whitespace-nowrap">Radio Pascal</span>
</div>
<div className="flex items-center gap-2">
<div
onClick={(e) => {
e.stopPropagation()
handlePlayPause()
}}
className="rounded-sm p-1 transition-all cursor-pointer bg-accent/30 hover:bg-accent hover:text-accent-foreground hover:shadow-sm"
role="button"
tabIndex={0}
aria-label={isRadioPlaying ? 'Pause' : 'Play'}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
handlePlayPause()
}
}}
>
{isRadioPlaying ? <Volume2 className="h-3.5 w-3.5" /> : <VolumeX className="h-3.5 w-3.5" />}
</div>
<button
className="rounded-sm p-1 transition-all cursor-pointer hover:bg-accent hover:text-accent-foreground"
onClick={(e) => {
e.stopPropagation()
setIsOpen(!isOpen)
}}
className={cn(
"rounded-sm p-1 transition-all cursor-pointer hover:bg-accent hover:text-accent-foreground",
isOpen && "bg-accent text-accent-foreground"
)}
aria-label="Radio Settings"
>
<Settings2 className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="w-64" align="end">
<div className="space-y-3">
{/* Current song info with prev/next */}
<div>
<p className="text-xs text-muted-foreground mb-2">Now Playing</p>
<div className="flex items-center justify-between gap-2">
<button
onClick={handlePrevious}
className="rounded-full p-1.5 transition-colors hover:bg-accent shrink-0"
aria-label="Previous"
>
<SkipBack className="h-4 w-4" />
</button>
<p className="text-sm font-medium text-center flex-1 truncate">{currentTrack.title}</p>
<button
onClick={handleNext}
className="rounded-full p-1.5 transition-colors hover:bg-accent shrink-0"
aria-label="Next"
>
<SkipForward className="h-4 w-4" />
</button>
</div>
</div>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
>
<div className="px-3 pb-3 space-y-3 w-[16rem]">
<div className="h-px w-full bg-border/50 mb-3" />
{/* Current song info with prev/next */}
<div>
<p className="text-xs text-muted-foreground mb-2">Now Playing</p>
<div className="flex items-center justify-between gap-2">
<button
onClick={handlePrevious}
className="rounded-full p-1.5 transition-colors hover:bg-accent shrink-0"
aria-label="Previous"
>
<SkipBack className="h-4 w-4" />
</button>
<p className="text-sm font-medium text-center flex-1 truncate" title={currentTrack.title}>
{currentTrack.title}
</p>
<button
onClick={handleNext}
className="rounded-full p-1.5 transition-colors hover:bg-accent shrink-0"
aria-label="Next"
>
<SkipForward className="h-4 w-4" />
</button>
</div>
</div>
{/* Volume control */}
<div className="flex items-center gap-2">
<Volume2 className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<Slider
value={[radioVolume]}
onValueChange={handleVolumeChange}
max={100}
step={1}
className="flex-1"
aria-label="Radio Volume"
/>
<span className="w-8 text-right text-xs text-muted-foreground shrink-0">{radioVolume}%</span>
</div>
</div>
{/* Volume control */}
<div className="flex items-center gap-2">
<Volume2 className="h-3.5 w-3.5 text-muted-foreground" />
<Slider
value={[radioVolume]}
onValueChange={handleVolumeChange}
max={100}
step={1}
className="flex-1"
aria-label="Radio Volume"
/>
<span className="w-8 text-right text-xs text-muted-foreground">{radioVolume}%</span>
</div>
</div>
</PopoverContent>
</Popover>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)
}
@@ -1,14 +1,14 @@
import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react'
import { useFrame } from '@react-three/fiber'
import useEditor from '@/store/use-editor'
export const ZoneSystem = () => {
const structureLayer = useEditor((state) => state.structureLayer)
const levelMode = useViewer((state) => state.levelMode)
const selectedLevelId = useViewer((state) => state.selection.levelId)
useFrame(() => {
const structureLayer = useEditor.getState().structureLayer
const levelMode = useViewer.getState().levelMode
const selectedLevelId = useViewer.getState().selection.levelId
useEffect(() => {
const visible = structureLayer === 'zones'
const zones = sceneRegistry.byType.zone || new Set()
const nodes = useScene.getState().nodes
@@ -23,21 +23,27 @@ export const ZoneSystem = () => {
const isOnSelectedLevel = zone?.parentId === selectedLevelId
const hideInSoloMode = levelMode === 'solo' && selectedLevelId && !isOnSelectedLevel
obj.visible = visible
if (obj.visible !== visible) {
obj.visible = visible
}
const label = obj.getObjectByName('label')
if (label) {
// Hide label if zone layer is off OR if in solo mode on a different level
const showLabel = visible && !hideInSoloMode;
const labelPosition = obj.userData.labelPosition as [number, number, number] | undefined
if (showLabel && labelPosition) {
label.position.set(...labelPosition)
} else {
label.position.set(-9999, -9999, -9999)
const targetX = showLabel && labelPosition ? labelPosition[0] : -9999
if (label.position.x !== targetX) {
if (showLabel && labelPosition) {
label.position.set(...labelPosition)
} else {
label.position.set(-9999, -9999, -9999)
}
}
}
})
}, [structureLayer, levelMode, selectedLevelId])
})
return null
}
@@ -1,7 +1,7 @@
'use client'
import { emitter } from '@pascal-app/core'
import { RotateCcw, RotateCw, Rotate3D } from 'lucide-react'
import Image from 'next/image'
import { Button } from '@/components/ui/primitives/button'
import {
Tooltip,
@@ -28,12 +28,18 @@ export function CameraActions() {
<Tooltip>
<TooltipTrigger asChild>
<Button
className="h-8 w-8 text-zinc-400 transition-all hover:text-sky-400"
className="group h-9 w-9 text-muted-foreground transition-all hover:bg-white/5"
onClick={orbitCCW}
size="icon"
variant="ghost"
>
<RotateCcw className="h-4 w-4" />
<Image
alt="Orbit Left"
className="h-[30px] w-[30px] object-contain opacity-70 transition-opacity group-hover:opacity-100 -scale-x-100"
height={30}
src="/icons/rotate.png"
width={30}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
@@ -45,12 +51,18 @@ export function CameraActions() {
<Tooltip>
<TooltipTrigger asChild>
<Button
className="h-8 w-8 text-zinc-400 transition-all hover:text-sky-400"
className="group h-9 w-9 text-muted-foreground transition-all hover:bg-white/5"
onClick={orbitCW}
size="icon"
variant="ghost"
>
<RotateCw className="h-4 w-4" />
<Image
alt="Orbit Right"
className="h-[30px] w-[30px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
height={30}
src="/icons/rotate.png"
width={30}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
@@ -62,12 +74,18 @@ export function CameraActions() {
<Tooltip>
<TooltipTrigger asChild>
<Button
className="h-8 w-8 text-zinc-400 transition-all hover:text-sky-400"
className="group h-9 w-9 text-muted-foreground transition-all hover:bg-white/5"
onClick={goToTopView}
size="icon"
variant="ghost"
>
<Rotate3D className="h-4 w-4 -rotate-90" />
<Image
alt="Top View"
className="h-[30px] w-[30px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
height={30}
src="/icons/topview.png"
width={30}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
@@ -1,19 +1,21 @@
"use client";
import Image from "next/image";
import { Button } from "@/components/ui/primitives/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/primitives/tooltip";
import { Hammer, MousePointer2, Pencil, Trash2 } from "lucide-react";
import { Pencil, Trash2, type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import useEditor, { Mode, Phase } from "@/store/use-editor";
type ModeConfig = {
id: Mode;
icon: typeof MousePointer2;
icon?: LucideIcon;
imageSrc?: string;
label: string;
shortcut: string;
color: string;
@@ -24,7 +26,7 @@ type ModeConfig = {
const allModes: ModeConfig[] = [
{
id: "select",
icon: MousePointer2,
imageSrc: "/icons/select.png",
label: "Select",
shortcut: "V",
color: "hover:bg-blue-500/20 hover:text-blue-400",
@@ -40,7 +42,7 @@ const allModes: ModeConfig[] = [
},
{
id: "build",
icon: Hammer,
imageSrc: "/icons/build.png",
label: "Build",
shortcut: "B",
color: "hover:bg-green-500/20 hover:text-green-400",
@@ -98,22 +100,39 @@ export function ControlModes() {
{availableModes.map((m) => {
const Icon = m.icon;
const isActive = mode === m.id;
const isImageMode = Boolean(m.imageSrc);
return (
<Tooltip key={m.id}>
<TooltipTrigger asChild>
<Button
className={cn(
"h-8 w-8 transition-all",
"text-zinc-400",
!isActive && m.color,
isActive && m.activeColor
"h-9 w-9 transition-all",
"text-muted-foreground",
!isImageMode && !isActive && m.color,
!isImageMode && isActive && m.activeColor,
isImageMode && isActive && "bg-white/10 hover:bg-white/10",
isImageMode && !isActive && "hover:bg-white/5"
)}
onClick={() => handleModeClick(m.id)}
size="icon"
variant="ghost"
>
<Icon className="h-4 w-4" />
{m.imageSrc ? (
<Image
alt={m.label}
className={cn(
"h-[26px] w-[26px] object-contain transition-[opacity,filter] duration-200",
!isActive && "opacity-60 grayscale",
isActive && "opacity-100 grayscale-0"
)}
height={26}
src={m.imageSrc}
width={26}
/>
) : (
Icon && <Icon className="h-5 w-5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
@@ -30,7 +30,7 @@ export function ActionMenu({ className }: { className?: string }) {
transition={transition}
className={cn(
"-translate-x-1/2 fixed bottom-6 left-1/2 z-50",
"rounded-2xl border border-zinc-800 bg-zinc-950/90 shadow-2xl backdrop-blur-md",
"rounded-2xl border border-border bg-background/90 shadow-2xl backdrop-blur-md",
"transition-colors duration-200 ease-out",
className,
)}
@@ -40,7 +40,7 @@ export function ActionMenu({ className }: { className?: string }) {
{mode === "build" && tool === "item" && catalogCategory && (
<motion.div
className={cn(
"overflow-hidden border-zinc-800 border-b px-2 py-2",
"overflow-hidden border-border border-b px-2 py-2",
)}
initial={{
opacity: 0,
@@ -74,7 +74,7 @@ export function ActionMenu({ className }: { className?: string }) {
{phase === "furnish" && mode === "build" && (
<motion.div
className={cn(
"overflow-hidden border-zinc-800",
"overflow-hidden border-border",
"max-h-20 border-b px-2 py-2 opacity-100",
)}
initial={{
@@ -112,7 +112,7 @@ export function ActionMenu({ className }: { className?: string }) {
{phase === "structure" && mode === "build" && (
<motion.div
className={cn(
"overflow-hidden border-zinc-800 max-h-20 border-b px-2 py-2",
"overflow-hidden border-border max-h-20 border-b px-2 py-2",
)}
initial={{
opacity: 0,
@@ -146,9 +146,9 @@ export function ActionMenu({ className }: { className?: string }) {
{/* Control Mode Row - Always visible, centered */}
<div className="flex items-center justify-center gap-1 px-2 py-1.5">
<ControlModes />
<div className="mx-1 h-5 w-px bg-zinc-700" />
<div className="mx-1 h-5 w-px bg-border" />
<ViewToggles />
<div className="mx-1 h-5 w-px bg-zinc-700" />
<div className="mx-1 h-5 w-px bg-border" />
<CameraActions />
</div>
</motion.div>
@@ -85,7 +85,7 @@ export function ViewToggles() {
<TooltipTrigger asChild>
<Button
className={cn(
'h-8 w-8 text-zinc-400 transition-all',
'h-9 w-9 text-muted-foreground transition-all',
cameraMode === 'orthographic'
? 'bg-violet-500/20 text-violet-400'
: 'hover:text-violet-400',
@@ -94,7 +94,7 @@ export function ViewToggles() {
size="icon"
variant="ghost"
>
<Camera className="h-4 w-4" />
<Camera className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>
@@ -107,7 +107,7 @@ export function ViewToggles() {
<TooltipTrigger asChild>
<Button
className={cn(
'h-8 w-8 text-zinc-400 transition-all',
'h-9 w-9 text-muted-foreground transition-all',
levelMode !== 'stacked'
? 'bg-amber-500/20 text-amber-400'
: 'hover:text-amber-400',
@@ -116,9 +116,9 @@ export function ViewToggles() {
size="icon"
variant="ghost"
>
{levelMode === 'solo' && <Diamond className="h-4 w-4" />}
{levelMode === 'exploded' && <Layers2 className="h-4 w-4" />}
{(levelMode === 'stacked' || levelMode === 'manual') && <Layers className="h-4 w-4" />}
{levelMode === 'solo' && <Diamond className="h-5 w-5" />}
{levelMode === 'exploded' && <Layers2 className="h-5 w-5" />}
{(levelMode === 'stacked' || levelMode === 'manual') && <Layers className="h-5 w-5" />}
</Button>
</TooltipTrigger>
<TooltipContent>
@@ -131,7 +131,7 @@ export function ViewToggles() {
<TooltipTrigger asChild>
<Button
className={cn(
'h-8 w-8 text-zinc-400 transition-all p-0',
'h-9 w-9 text-muted-foreground transition-all p-0',
wallMode !== 'cutaway'
? 'bg-white/10'
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
@@ -142,7 +142,7 @@ export function ViewToggles() {
>
{(() => {
const Icon = wallModeConfig[wallMode].icon
return <Icon className="h-5 w-5" />
return <Icon className="h-[26px] w-[26px]" />
})()}
</Button>
</TooltipTrigger>
@@ -156,7 +156,7 @@ export function ViewToggles() {
<TooltipTrigger asChild>
<Button
className={cn(
'h-8 w-8 text-zinc-400 transition-all p-0',
'h-9 w-9 text-muted-foreground transition-all p-0',
showScans
? 'bg-white/10'
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
@@ -165,7 +165,7 @@ export function ViewToggles() {
size="icon"
variant="ghost"
>
<img alt="Scans" className="h-5 w-5 object-contain" src="/icons/mesh.png" />
<img alt="Scans" className="h-[26px] w-[26px] object-contain" src="/icons/mesh.png" />
</Button>
</TooltipTrigger>
<TooltipContent>
@@ -178,7 +178,7 @@ export function ViewToggles() {
<TooltipTrigger asChild>
<Button
className={cn(
'h-8 w-8 text-zinc-400 transition-all p-0',
'h-9 w-9 text-muted-foreground transition-all p-0',
showGuides
? 'bg-white/10'
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
@@ -187,7 +187,7 @@ export function ViewToggles() {
size="icon"
variant="ghost"
>
<img alt="Guides" className="h-5 w-5 object-contain" src="/icons/floorplan.png" />
<img alt="Guides" className="h-[26px] w-[26px] object-contain" src="/icons/floorplan.png" />
</Button>
</TooltipTrigger>
<TooltipContent>
@@ -4,6 +4,8 @@ import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import * as React from "react";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { Button } from "@/components/ui/primitives/button";
import { Input } from "@/components/ui/primitives/input";
import { Separator } from "@/components/ui/primitives/separator";
@@ -31,6 +33,28 @@ const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarStore = {
width: number;
setWidth: (width: number) => void;
isDragging: boolean;
setIsDragging: (isDragging: boolean) => void;
};
export const useSidebarStore = create<SidebarStore>()(
persist(
(set) => ({
width: 288, // 18rem = 288px
setWidth: (width) => set({ width: Math.max(288, Math.min(width, 800)) }),
isDragging: false,
setIsDragging: (isDragging) => set({ isDragging }),
}),
{
name: "sidebar-preferences",
partialize: (state) => ({ width: state.width }), // Only persist width
}
)
);
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
@@ -67,6 +91,8 @@ function SidebarProvider({
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
const sidebarWidth = useSidebarStore((state) => state.width);
const isDragging = useSidebarStore((state) => state.isDragging);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
@@ -136,9 +162,10 @@ function SidebarProvider({
className,
)}
data-slot="sidebar-wrapper"
data-dragging={isDragging}
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width": `${sidebarWidth}px`,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
@@ -152,6 +179,52 @@ function SidebarProvider({
);
}
function SidebarResizer({ side }: { side: "left" | "right" }) {
const setWidth = useSidebarStore((state) => state.setWidth);
const setIsDragging = useSidebarStore((state) => state.setIsDragging);
const isResizing = React.useRef(false);
const handlePointerDown = (e: React.PointerEvent) => {
isResizing.current = true;
setIsDragging(true);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
};
React.useEffect(() => {
const handlePointerMove = (e: PointerEvent) => {
if (!isResizing.current) return;
const newWidth = side === "left" ? e.clientX : window.innerWidth - e.clientX;
setWidth(Math.max(288, Math.min(newWidth, 800)));
};
const handlePointerUp = () => {
isResizing.current = false;
setIsDragging(false);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
return () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
};
}, [setWidth, side]);
return (
<div
onPointerDown={handlePointerDown}
className={cn(
"absolute top-0 bottom-0 w-2 cursor-col-resize z-50 hover:bg-primary/50 transition-colors",
side === "left" ? "-right-1" : "-left-1"
)}
/>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
@@ -219,6 +292,7 @@ function Sidebar({
<div
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[dragging=true]/sidebar-wrapper:transition-none",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
@@ -230,6 +304,7 @@ function Sidebar({
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex pointer-events-auto",
"group-data-[dragging=true]/sidebar-wrapper:transition-none",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
@@ -243,11 +318,12 @@ function Sidebar({
{...props}
>
<div
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm pointer-events-auto"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm pointer-events-auto relative"
data-sidebar="sidebar"
data-slot="sidebar-inner"
>
{children}
<SidebarResizer side={side} />
</div>
</div>
</div>
@@ -0,0 +1,40 @@
'use client'
import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
const LOADERS = [
'pascal-loader-1',
'pascal-loader-2',
'pascal-loader-3',
'pascal-loader-4',
'pascal-loader-5',
]
interface SceneLoaderProps {
className?: string
fullScreen?: boolean
}
export function SceneLoader({ className, fullScreen = false }: SceneLoaderProps) {
const [loaderClass, setLoaderClass] = useState<string | null>(null)
useEffect(() => {
// Pick a random loader on mount
setLoaderClass(LOADERS[Math.floor(Math.random() * LOADERS.length)] ?? LOADERS[0]!)
}, [])
if (!loaderClass) return null
return (
<div
className={cn(
"z-100 flex items-center justify-center bg-background/80 backdrop-blur-md transition-opacity duration-300",
fullScreen ? "fixed inset-0" : "absolute inset-0",
className
)}
>
<div className={cn(loaderClass, "text-foreground opacity-80")} />
</div>
)
}
@@ -13,9 +13,10 @@ import {
interface BuildingTreeNodeProps {
node: BuildingNode;
depth: number;
isLast?: boolean;
}
export function BuildingTreeNode({ node, depth }: BuildingTreeNodeProps) {
export function BuildingTreeNode({ node, depth, isLast }: BuildingTreeNodeProps) {
const [expanded, setExpanded] = useState(true);
const createNode = useScene((state) => state.createNode);
const isSelected = useViewer((state) => state.selection.buildingId === node.id);
@@ -47,6 +48,7 @@ export function BuildingTreeNode({ node, depth }: BuildingTreeNodeProps) {
onClick={handleClick}
isSelected={isSelected}
isHovered={isHovered}
isLast={isLast}
actions={
<div className="flex items-center gap-0.5">
<TreeNodeActions node={node} />
@@ -64,8 +66,8 @@ export function BuildingTreeNode({ node, depth }: BuildingTreeNodeProps) {
</div>
}
>
{node.children.map((childId) => (
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
{node.children.map((childId, index) => (
<TreeNode key={childId} nodeId={childId} depth={depth + 1} isLast={index === node.children.length - 1} />
))}
</TreeNodeWrapper>
);
@@ -2,16 +2,18 @@ import { type AnyNodeId, CeilingNode, useScene } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import Image from "next/image";
import { useState, useEffect } from "react";
import useEditor from "@/store/use-editor";
import { InlineRenameInput } from "./inline-rename-input";
import { TreeNode, TreeNodeWrapper } from "./tree-node";
import { TreeNode, TreeNodeWrapper, handleTreeSelection } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions";
interface CeilingTreeNodeProps {
node: CeilingNode;
depth: number;
isLast?: boolean;
}
export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
export function CeilingTreeNode({ node, depth, isLast }: CeilingTreeNodeProps) {
const [expanded, setExpanded] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const selectedIds = useViewer((state) => state.selection.selectedIds);
@@ -40,8 +42,12 @@ export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
}
}, [selectedIds, node.id]);
const handleClick = () => {
setSelection({ selectedIds: [node.id] });
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection);
if (!handled && useEditor.getState().phase === "furnish") {
useEditor.getState().setPhase("structure");
}
};
const handleDoubleClick = () => {
@@ -62,6 +68,7 @@ export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
return (
<TreeNodeWrapper
nodeId={node.id}
icon={<Image src="/icons/ceiling.png" alt="" width={14} height={14} className="object-contain" />}
label={
<InlineRenameInput
@@ -83,10 +90,11 @@ export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
isSelected={isSelected}
isHovered={isHovered}
isVisible={node.visible !== false}
isLast={isLast}
actions={<TreeNodeActions node={node} />}
>
{node.children.map((childId) => (
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
{node.children.map((childId, index) => (
<TreeNode key={childId} nodeId={childId} depth={depth + 1} isLast={index === node.children.length - 1} />
))}
</TreeNodeWrapper>
);
@@ -4,18 +4,21 @@ import { DoorNode } from "@pascal-app/core"
import { useViewer } from "@pascal-app/viewer"
import Image from "next/image"
import { useState } from "react"
import useEditor from "@/store/use-editor"
import { InlineRenameInput } from "./inline-rename-input"
import { TreeNodeWrapper } from "./tree-node"
import { TreeNodeWrapper, handleTreeSelection } from "./tree-node"
import { TreeNodeActions } from "./tree-node-actions"
interface DoorTreeNodeProps {
node: DoorNode
depth: number
isLast?: boolean
}
export function DoorTreeNode({ node, depth }: DoorTreeNodeProps) {
export function DoorTreeNode({ node, depth, isLast }: DoorTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false)
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id))
const selectedIds = useViewer((state) => state.selection.selectedIds)
const isSelected = selectedIds.includes(node.id)
const isHovered = useViewer((state) => state.hoveredId === node.id)
const setSelection = useViewer((state) => state.setSelection)
const setHoveredId = useViewer((state) => state.setHoveredId)
@@ -24,6 +27,7 @@ export function DoorTreeNode({ node, depth }: DoorTreeNodeProps) {
return (
<TreeNodeWrapper
nodeId={node.id}
icon={<Image src="/icons/door.png" alt="" width={14} height={14} className="object-contain" />}
label={
<InlineRenameInput
@@ -38,13 +42,20 @@ export function DoorTreeNode({ node, depth }: DoorTreeNodeProps) {
hasChildren={false}
expanded={false}
onToggle={() => {}}
onClick={() => setSelection({ selectedIds: [node.id] })}
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
if (!handled && useEditor.getState().phase === "furnish") {
useEditor.getState().setPhase("structure")
}
}}
onDoubleClick={() => setIsEditing(true)}
onMouseEnter={() => setHoveredId(node.id)}
onMouseLeave={() => setHoveredId(null)}
isSelected={isSelected}
isHovered={isHovered}
isVisible={node.visible !== false}
isLast={isLast}
actions={<TreeNodeActions node={node} />}
/>
)
@@ -7,30 +7,40 @@ import {
type SiteNode,
useScene,
type ZoneNode,
type ScanNode,
type GuideNode,
ScanNode as ScanNodeSchema,
GuideNode as GuideNodeSchema,
} from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import {
Box,
Building2,
Camera,
ChevronDown,
Image as ImageIcon,
Layers,
Loader2,
Pentagon,
MoreHorizontal,
Pencil,
Plus,
Trash2,
X,
} from "lucide-react";
import { useState, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import useEditor from "@/store/use-editor";
import { TreeNode } from "./tree-node";
import { ReferencesDialog } from "./references-dialog";
import { InlineRenameInput } from "./inline-rename-input";
import { useProjectStore } from '@/features/community/lib/projects/store';
import { deleteProjectAssetByUrl, uploadProjectAsset } from '@/features/community/lib/assets/actions';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/primitives/popover";
import { motion, AnimatePresence, LayoutGroup } from "motion/react";
// Preset colors for zones
const PRESET_COLORS = [
@@ -315,11 +325,188 @@ function CameraPopover({
}
function ReferenceItem({ refNode, isLastRow, setSelectedReferenceId, handleDelete }: {
refNode: ScanNode | GuideNode;
isLastRow: boolean;
setSelectedReferenceId: (id: string) => void;
handleDelete: (id: string, e: React.MouseEvent) => void;
}) {
const [isEditing, setIsEditing] = useState(false);
return (
<div className="relative group/ref flex items-center border-b border-border/50 text-xs pr-2 transition-colors hover:bg-accent/30 h-8 select-none">
<div className={cn("absolute w-px bg-border/50 pointer-events-none z-10", isLastRow ? "top-0 bottom-1/2" : "top-0 bottom-0")} style={{ left: 45 }} />
<div className="absolute top-1/2 h-px bg-border/50 pointer-events-none z-10" style={{ left: 45, width: 8 }} />
<div
className="flex-1 flex items-center gap-2 pl-[60px] py-0 h-8 text-muted-foreground group-hover/ref:text-foreground cursor-pointer min-w-0"
onClick={() => setSelectedReferenceId(refNode.id)}
onDoubleClick={() => setIsEditing(true)}
>
{refNode.type === 'scan' ? <img src="/icons/mesh.png" alt="Scan" className="w-3.5 h-3.5 shrink-0 object-contain opacity-70 group-hover/ref:opacity-100 transition-opacity" /> : <img src="/icons/floorplan.png" alt="Guide" className="w-3.5 h-3.5 shrink-0 object-contain opacity-70 group-hover/ref:opacity-100 transition-opacity" />}
<InlineRenameInput
node={refNode}
isEditing={isEditing}
onStopEditing={() => setIsEditing(false)}
onStartEditing={() => setIsEditing(true)}
defaultName={refNode.type === 'scan' ? '3D Scan' : 'Guide Image'}
/>
</div>
<button
className="opacity-0 group-hover/ref:opacity-100 w-5 h-5 flex items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10 text-muted-foreground hover:text-foreground transition-colors cursor-pointer shrink-0 z-20"
onClick={(e) => handleDelete(refNode.id, e)}
title="Delete"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
);
}
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
function LevelReferences({ levelId, isLastLevel }: { levelId: string, isLastLevel?: boolean }) {
const nodes = useScene((s) => s.nodes);
const createNode = useScene((s) => s.createNode);
const deleteNode = useScene((s) => s.deleteNode);
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId);
const activeProject = useProjectStore((s) => s.activeProject);
const [uploadError, setUploadError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadingType, setUploadingType] = useState<'scan'|'guide'|null>(null);
const scanInputRef = useRef<HTMLInputElement>(null);
const references = Object.values(nodes).filter(
(node): node is ScanNode | GuideNode =>
(node.type === 'scan' || node.type === 'guide') && node.parentId === levelId,
);
const handleAddAsset = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = '';
const projectId = activeProject?.id;
if (!projectId) {
setUploadError('No active project. Please open a project first.');
return;
}
if (file.size > MAX_FILE_SIZE) {
setUploadError(`File is too large (${(file.size / 1024 / 1024).toFixed(0)} MB). Maximum size is 100 MB.`);
return;
}
// Auto-detect type based on file extension/mime type
const isScan = file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf');
const isImage = file.type.startsWith('image/');
if (!isScan && !isImage) {
setUploadError('Invalid file type. Please upload a .glb/.gltf scan or an image.');
return;
}
const type = isScan ? 'scan' : 'guide';
setUploadError(null);
setUploading(true);
setUploadingType(type);
const result = await uploadProjectAsset(projectId, file, type);
setUploading(false);
setUploadingType(null);
if (!result.success) {
setUploadError(result.error);
return;
}
const Schema = type === 'scan' ? ScanNodeSchema : GuideNodeSchema;
const node = Schema.parse({
url: result.url,
name: file.name,
parentId: levelId,
});
createNode(node, levelId as AnyNodeId);
setSelectedReferenceId(node.id);
};
const handleDelete = async (nodeId: string, e: React.MouseEvent) => {
e.stopPropagation();
const refNode = nodes[nodeId as AnyNodeId] as ScanNode | GuideNode | undefined;
const projectId = activeProject?.id;
if (
projectId &&
refNode?.url &&
(refNode.url.startsWith('http://') || refNode.url.startsWith('https://'))
) {
deleteProjectAssetByUrl(projectId, refNode.url);
}
deleteNode(nodeId as AnyNodeId);
};
const rows = [
{ type: 'upload' as const },
...references.map(ref => ({ type: 'ref' as const, data: ref }))
];
return (
<div className="flex flex-col relative">
{!isLastLevel && (
<div className="absolute top-0 bottom-0 w-px bg-border/50 pointer-events-none z-10" style={{ left: 21 }} />
)}
{rows.map((row, i) => {
const isLastRow = i === rows.length - 1;
if (row.type === 'upload') {
return (
<div key="upload" className="relative group/ref border-b border-border/50">
<div className={cn("absolute w-px bg-border/50 pointer-events-none z-10", isLastRow ? "top-0 bottom-1/2" : "top-0 bottom-0")} style={{ left: 45 }} />
<div className="absolute top-1/2 h-px bg-border/50 pointer-events-none z-10" style={{ left: 45, width: 8 }} />
<button
className="flex items-center gap-2 w-full pl-[60px] pr-2 py-0 h-8 text-xs text-muted-foreground hover:bg-accent/30 hover:text-foreground cursor-pointer transition-colors text-left disabled:opacity-50 disabled:cursor-not-allowed select-none"
disabled={uploading}
onClick={() => scanInputRef.current?.click()}
>
{uploading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Plus className="w-3.5 h-3.5" />}
{uploading ? `Uploading ${uploadingType}...` : "Upload scan/floorplan"}
</button>
<input ref={scanInputRef} type="file" accept=".glb,.gltf,image/jpeg,image/png,image/webp,image/gif" className="hidden" onChange={handleAddAsset} />
</div>
);
}
const ref = row.data as ScanNode | GuideNode;
return (
<ReferenceItem
key={ref.id}
refNode={ref}
isLastRow={isLastRow}
setSelectedReferenceId={setSelectedReferenceId}
handleDelete={handleDelete}
/>
);
})}
{uploadError && (
<div className="relative pl-[60px] pr-2 py-1 text-[10px] text-destructive border-b border-border/50 bg-destructive/5 select-none min-h-8 flex items-center">
<div className="absolute top-0 bottom-0 w-px bg-border/50 pointer-events-none z-10" style={{ left: 45 }} />
{uploadError}
</div>
)}
</div>
);
}
function LevelItem({
level,
selectedLevelId,
setSelection,
setReferencesLevelId,
deleteNode,
updateNode,
isLast,
@@ -327,7 +514,6 @@ function LevelItem({
level: LevelNode;
selectedLevelId: string | null;
setSelection: (selection: any) => void;
setReferencesLevelId: (id: string | null) => void;
deleteNode: (id: AnyNodeId) => void;
updateNode: (id: AnyNodeId, updates: Partial<AnyNode>) => void;
isLast?: boolean;
@@ -336,6 +522,11 @@ function LevelItem({
const [isEditing, setIsEditing] = useState(false);
const itemRef = useRef<HTMLDivElement>(null);
const isSelected = selectedLevelId === level.id;
const [isExpanded, setIsExpanded] = useState(isSelected);
useEffect(() => {
setIsExpanded(isSelected);
}, [isSelected]);
useEffect(() => {
if (isSelected && itemRef.current) {
@@ -344,37 +535,66 @@ function LevelItem({
}, [isSelected]);
return (
<div
ref={itemRef}
className={cn(
"flex items-center group/level border-b border-border/50 pr-2 transition-all duration-200 relative",
isSelected
? "bg-accent/50 text-foreground"
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground"
)}
>
{/* Vertical tree line */}
<div className={cn("absolute left-[21px] top-0 w-px bg-border/50 pointer-events-none", isLast ? "bottom-1/2" : "bottom-0")} />
{/* Horizontal branch line */}
<div className="absolute left-[21px] top-1/2 w-4 h-px bg-border/50 pointer-events-none" />
<div className="flex flex-col relative">
<div
className="flex-1 flex items-center gap-2 pl-10 py-2 text-sm cursor-pointer min-w-0"
onClick={() => setSelection({ levelId: level.id })}
onDoubleClick={() => setIsEditing(true)}
ref={itemRef}
className={cn(
"flex items-center group/level border-b border-border/50 pr-2 transition-all duration-200 relative h-8 select-none",
isSelected
? "bg-accent/50 text-foreground"
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground"
)}
>
<Layers className={cn(
"w-3.5 h-3.5 shrink-0 transition-all duration-200",
!isSelected && "opacity-60 grayscale"
{/* Vertical tree line */}
<div className={cn("absolute left-[21px] w-px bg-border/50 pointer-events-none z-10", isLast && !isExpanded ? "top-0 bottom-1/2" : "top-0 bottom-0")} />
{/* Horizontal branch line */}
<div className="absolute left-[21px] top-1/2 w-[11px] h-px bg-border/50 pointer-events-none z-10" />
<div className={cn(
"absolute left-[32px] top-[10px] w-4 h-[12px] pointer-events-none z-10 transition-colors duration-200",
isSelected ? "bg-accent/50" : "bg-background group-hover/level:bg-accent/30"
)} />
<InlineRenameInput
node={level}
isEditing={isEditing}
onStopEditing={() => setIsEditing(false)}
onStartEditing={() => setIsEditing(true)}
defaultName={`Level ${level.level}`}
/>
</div>
{/* Line down to children */}
{isExpanded && (
<div className="absolute left-[45px] top-[16px] bottom-0 w-px bg-border/50 pointer-events-none z-10" />
)}
<div className="flex items-center pl-[28px] pr-1 z-20 relative h-8">
<button
className="w-4 h-4 flex items-center justify-center shrink-0 z-20 bg-inherit cursor-pointer"
onClick={(e) => {
e.stopPropagation();
if (!isSelected) {
setSelection({ levelId: level.id });
} else {
setIsExpanded(!isExpanded);
}
}}
>
{isExpanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronDown className="w-3 h-3 -rotate-90 text-muted-foreground" />
)}
</button>
</div>
<div className="flex-1 flex items-center gap-2 py-0 text-sm cursor-pointer min-w-0 h-8 pl-0.5"
onClick={() => setSelection({ levelId: level.id })}
onDoubleClick={() => setIsEditing(true)}
>
<img
src="/icons/level.png"
className={cn("w-4 h-4 object-contain shrink-0 transition-all duration-200", !isSelected && "opacity-60 grayscale")}
alt="Level"
/>
<InlineRenameInput
node={level}
isEditing={isEditing}
onStopEditing={() => setIsEditing(false)}
onStartEditing={() => setIsEditing(true)}
defaultName={`Level ${level.level}`}
/>
</div>
{/* Camera snapshot button */}
<Popover open={cameraPopoverOpen} onOpenChange={setCameraPopoverOpen}>
<PopoverTrigger asChild>
@@ -456,12 +676,6 @@ function LevelItem({
</button>
</PopoverTrigger>
<PopoverContent align="start" side="right" className="w-40 p-1">
<button
className="flex items-center gap-2 w-full px-3 py-1.5 rounded text-sm hover:bg-accent cursor-pointer"
onClick={() => setReferencesLevelId(level.id)}
>
References
</button>
{level.level !== 0 && (
<button
className="flex items-center gap-2 w-full px-3 py-1.5 rounded text-sm hover:bg-accent hover:text-red-600 cursor-pointer"
@@ -474,6 +688,20 @@ function LevelItem({
</PopoverContent>
</Popover>
</div>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ type: "spring", bounce: 0, duration: 0.3 }}
className="overflow-hidden"
>
<LevelReferences levelId={level.id} isLastLevel={isLast} />
</motion.div>
)}
</AnimatePresence>
</div>
);
}
@@ -486,8 +714,6 @@ function LevelsSection() {
const selectedLevelId = useViewer((state) => state.selection.levelId);
const setSelection = useViewer((state) => state.setSelection);
const [referencesLevelId, setReferencesLevelId] = useState<string | null>(null);
const building = selectedBuildingId
? (nodes[selectedBuildingId] as BuildingNode)
: null;
@@ -511,24 +737,27 @@ function LevelsSection() {
return (
<div className="flex flex-col relative">
{/* Level buttons */}
<div className="flex flex-col">
<div className="flex flex-col flex-1 min-h-0">
<button
className="flex items-center gap-2 pl-10 py-2 text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground cursor-pointer transition-all duration-200 border-b border-border/50 relative"
className="flex items-center gap-2 pl-0 py-0 text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground cursor-pointer transition-all duration-200 border-b border-border/50 relative h-8 select-none"
onClick={handleAddLevel}
>
{/* Vertical tree line */}
<div className="absolute left-[21px] top-0 bottom-0 w-px bg-border/50 pointer-events-none" />
{/* Horizontal branch line */}
<div className="absolute left-[21px] top-1/2 w-4 h-px bg-border/50 pointer-events-none" />
<Plus className="w-3.5 h-3.5" />
Add level
<div className="absolute left-[21px] top-1/2 w-[11px] h-px bg-border/50 pointer-events-none z-10" />
<div className="flex items-center pl-[38px] pr-1 z-10 relative">
<Plus className="w-3.5 h-3.5" />
</div>
<span className="truncate">Add level</span>
</button>
{levels.length === 0 && (
<div className="text-xs text-muted-foreground pl-10 pr-2 py-2 relative border-b border-border/50">
<div className="text-xs text-muted-foreground pl-[38px] pr-2 py-0 relative border-b border-border/50 h-8 flex items-center select-none">
{/* Vertical tree line */}
<div className="absolute left-[21px] top-0 bottom-1/2 w-px bg-border/50 pointer-events-none" />
{/* Horizontal branch line */}
<div className="absolute left-[21px] top-1/2 w-4 h-px bg-border/50 pointer-events-none" />
<div className="absolute left-[21px] top-1/2 w-[11px] h-px bg-border/50 pointer-events-none" />
No levels yet
</div>
)}
@@ -538,24 +767,12 @@ function LevelsSection() {
level={level}
selectedLevelId={selectedLevelId}
setSelection={setSelection}
setReferencesLevelId={setReferencesLevelId}
deleteNode={deleteNode}
updateNode={updateNode}
isLast={index === levels.length - 1}
/>
))}
</div>
{/* References dialog */}
{referencesLevelId && (
<ReferencesDialog
levelId={referencesLevelId}
open={!!referencesLevelId}
onOpenChange={(open) => {
if (!open) setReferencesLevelId(null);
}}
/>
)}
</div>
);
}
@@ -566,13 +783,18 @@ function LayerToggle() {
const phase = useEditor((state) => state.phase);
const setPhase = useEditor((state) => state.setPhase);
const activeTab =
phase === "structure" && structureLayer === "elements" ? "structure" :
phase === "furnish" ? "furnish" :
phase === "structure" && structureLayer === "zones" ? "zones" : "none";
return (
<div className="flex items-center p-1 bg-accent/20 gap-1 border-b border-border/50">
<div className="flex items-center p-1 bg-accent/20 gap-1 border-b border-border/50 relative">
<button
className={cn(
"flex-1 flex flex-col items-center justify-center py-2 rounded-md text-[10px] font-medium transition-all duration-200 cursor-pointer",
phase === "structure" && structureLayer === "elements"
? "bg-white dark:bg-background shadow-sm ring-1 ring-black/5 dark:ring-white/10 text-foreground"
"relative flex-1 flex flex-col items-center justify-center py-2 rounded-md text-[10px] font-medium transition-all duration-200 cursor-pointer",
activeTab === "structure"
? "text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-white/50 dark:hover:bg-accent/50"
)}
onClick={() => {
@@ -580,36 +802,56 @@ function LayerToggle() {
setStructureLayer("elements");
}}
>
<img
src="/icons/room.png"
alt="Structure"
className={cn("w-6 h-6 mb-1", !(phase === "structure" && structureLayer === "elements") && "opacity-50 grayscale")}
/>
Structure
{activeTab === "structure" && (
<motion.div
layoutId="layerToggleActiveBg"
className="absolute inset-0 bg-white dark:bg-background shadow-sm ring-1 ring-black/5 dark:ring-white/10 rounded-md"
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
/>
)}
<div className="relative z-10 flex flex-col items-center">
<img
src="/icons/room.png"
alt="Structure"
className={cn("w-6 h-6 mb-1 transition-all", activeTab !== "structure" && "opacity-50 grayscale")}
/>
Structure
</div>
</button>
<button
className={cn(
"flex-1 flex flex-col items-center justify-center py-2 rounded-md text-[10px] font-medium transition-all duration-200 cursor-pointer",
phase === "furnish"
? "bg-white dark:bg-background shadow-sm ring-1 ring-black/5 dark:ring-white/10 text-foreground"
"relative flex-1 flex flex-col items-center justify-center py-2 rounded-md text-[10px] font-medium transition-all duration-200 cursor-pointer",
activeTab === "furnish"
? "text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-white/50 dark:hover:bg-accent/50"
)}
onClick={() => {
setPhase("furnish");
}}
>
<img
src="/icons/couch.png"
alt="Furnish"
className={cn("w-6 h-6 mb-1", phase !== "furnish" && "opacity-50 grayscale")}
/>
Furnish
{activeTab === "furnish" && (
<motion.div
layoutId="layerToggleActiveBg"
className="absolute inset-0 bg-white dark:bg-background shadow-sm ring-1 ring-black/5 dark:ring-white/10 rounded-md"
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
/>
)}
<div className="relative z-10 flex flex-col items-center">
<img
src="/icons/couch.png"
alt="Furnish"
className={cn("w-6 h-6 mb-1 transition-all", activeTab !== "furnish" && "opacity-50 grayscale")}
/>
Furnish
</div>
</button>
<button
className={cn(
"flex-1 flex flex-col items-center justify-center py-2 rounded-md text-[10px] font-medium transition-all duration-200 cursor-pointer",
phase === "structure" && structureLayer === "zones"
? "bg-white dark:bg-background shadow-sm ring-1 ring-black/5 dark:ring-white/10 text-foreground"
"relative flex-1 flex flex-col items-center justify-center py-2 rounded-md text-[10px] font-medium transition-all duration-200 cursor-pointer",
activeTab === "zones"
? "text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-white/50 dark:hover:bg-accent/50"
)}
onClick={() => {
@@ -617,18 +859,27 @@ function LayerToggle() {
setStructureLayer("zones");
}}
>
<img
src="/icons/kitchen.png"
alt="Zones"
className={cn("w-6 h-6 mb-1", !(phase === "structure" && structureLayer === "zones") && "opacity-50 grayscale")}
/>
Zones
{activeTab === "zones" && (
<motion.div
layoutId="layerToggleActiveBg"
className="absolute inset-0 bg-white dark:bg-background shadow-sm ring-1 ring-black/5 dark:ring-white/10 rounded-md"
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
/>
)}
<div className="relative z-10 flex flex-col items-center">
<img
src="/icons/kitchen.png"
alt="Zones"
className={cn("w-6 h-6 mb-1 transition-all", activeTab !== "zones" && "opacity-50 grayscale")}
/>
Zones
</div>
</button>
</div>
);
}
function ZoneItem({ zone }: { zone: ZoneNode }) {
function ZoneItem({ zone, isLast }: { zone: ZoneNode, isLast?: boolean }) {
const [isEditing, setIsEditing] = useState(false);
const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false);
const deleteNode = useScene((state) => state.deleteNode);
@@ -680,7 +931,7 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
<div
ref={itemRef}
className={cn(
"flex items-center h-8 cursor-pointer group/row text-sm px-3 select-none border-b border-border/50 transition-all duration-200",
"relative flex items-center h-8 cursor-pointer group/row text-sm px-3 select-none border-b border-border/50 transition-all duration-200",
isSelected
? "bg-accent/50 text-foreground"
: isHovered
@@ -692,6 +943,11 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
onMouseEnter={() => setHoveredId(zone.id)}
onMouseLeave={() => setHoveredId(null)}
>
{/* Vertical tree line */}
<div className={cn("absolute w-px bg-border/50 pointer-events-none", isLast ? "top-0 bottom-1/2" : "top-0 bottom-0")} style={{ left: 8 }} />
{/* Horizontal branch line */}
<div className="absolute top-1/2 h-px bg-border/50 pointer-events-none" style={{ left: 8, width: 4 }} />
<Popover>
<PopoverTrigger asChild>
<button
@@ -805,6 +1061,28 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
);
}
function MultiSelectionBadge() {
const selectedIds = useViewer((state) => state.selection.selectedIds);
const setSelection = useViewer((state) => state.setSelection);
if (selectedIds.length <= 1) return null;
return (
<div className="sticky top-4 z-50 pointer-events-none flex justify-center w-full h-0 overflow-visible">
<div className="pointer-events-auto flex items-center gap-2.5 px-0.5 pl-2 py-4 bg-primary text-primary-foreground text-xs font-medium rounded-full shadow-lg shadow-black/10 border border-primary/20 backdrop-blur-md">
<span>{selectedIds.length} objects selected</span>
<button
onClick={() => setSelection({ selectedIds: [] })}
className="hover:bg-primary-foreground/20 p-1.5 rounded-full transition-colors cursor-pointer"
title="Clear selection"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
}
function ContentSection() {
const nodes = useScene((state) => state.nodes);
const selectedLevelId = useViewer((state) => state.selection.levelId);
@@ -853,8 +1131,8 @@ function ContentSection() {
return (
<div className="flex flex-col">
{levelZones.map((zone) => (
<ZoneItem key={zone.id} zone={zone} />
{levelZones.map((zone, index) => (
<ZoneItem key={zone.id} zone={zone} isLast={index === levelZones.length - 1} />
))}
</div>
);
@@ -865,28 +1143,9 @@ function ContentSection() {
const childNode = nodes[childId];
if (!childNode || childNode.type === "zone") return false;
// In structure mode, show structural elements (walls, slabs, etc.) and doors/windows
if (phase === "structure") {
if (childNode.type === "item") {
const category = childNode.asset?.category?.toLowerCase() || "";
// Only show doors and windows in structure mode
return category === "door" || category === "window";
}
// Show all other structural elements (walls, slabs, ceiling, roof)
return true;
}
// In furnish mode, only show items that are NOT doors or windows
if (phase === "furnish") {
if (childNode.type === "item") {
const category = childNode.asset?.category?.toLowerCase() || "";
// Hide doors and windows in furnish mode
return category !== "door" && category !== "window";
}
// Hide structural elements in furnish mode
return false;
}
// We no longer filter out structural nodes in furnish mode or furnish nodes in structure mode
// This allows nested items (like lights in a ceiling or cabinetry on a wall) to remain visible
// and selectable in both modes, ensuring seamless transition in the tree view.
return true;
});
@@ -900,8 +1159,8 @@ function ContentSection() {
return (
<div className="flex flex-col">
{elementChildren.map((childId) => (
<TreeNode key={childId} nodeId={childId} depth={0} />
{elementChildren.map((childId, index) => (
<TreeNode key={childId} nodeId={childId} depth={0} isLast={index === elementChildren.length - 1} />
))}
</div>
);
@@ -931,8 +1190,13 @@ function BuildingItem({
}, [isBuildingActive]);
return (
<div className={cn("flex flex-col", isBuildingActive && "flex-1 min-h-0")}>
<div
<motion.div
layout
transition={{ type: "spring", bounce: 0, duration: 0.4 }}
className={cn("flex flex-col shrink-0 overflow-hidden", isBuildingActive && "flex-1 min-h-0")}
>
<motion.div
layout="position"
ref={itemRef}
className={cn(
"group/building flex items-center h-10 border-b border-border/50 pr-2 transition-all duration-200 shrink-0",
@@ -1025,21 +1289,32 @@ function BuildingItem({
</div>
</PopoverContent>
</Popover>
</div>
</motion.div>
{/* Tools and content for the active building */}
{isBuildingActive && (
<div className="flex flex-col flex-1 min-h-0 animate-in fade-in slide-in-from-top-2 duration-200">
<div className="shrink-0 flex flex-col">
<LevelsSection />
<LayerToggle />
</div>
<div className="flex-1 overflow-y-auto overflow-x-hidden min-h-0">
<ContentSection />
</div>
</div>
)}
</div>
<AnimatePresence initial={false}>
{isBuildingActive && (
<motion.div
initial={{ opacity: 0, flex: 0 }}
animate={{ opacity: 1, flex: "1 1 0%" }}
exit={{ opacity: 0, flex: "0 0 0px" }}
transition={{ type: "spring", bounce: 0, duration: 0.4 }}
className="flex flex-col w-full overflow-hidden"
>
<div className="flex flex-col flex-1 min-h-0 w-full">
<div className="shrink-0 flex flex-col">
<LevelsSection />
<LayerToggle />
</div>
<div className="flex-1 overflow-y-auto overflow-x-hidden min-h-0 relative">
<MultiSelectionBadge />
<ContentSection />
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}
@@ -1064,61 +1339,77 @@ export function SitePanel() {
.filter((node): node is BuildingNode => node?.type === "building");
return (
<div className="flex flex-col h-full">
{/* Site Header */}
{siteNode && (
<div
className={cn(
"flex items-center justify-between px-3 py-3 border-b border-border/50 cursor-pointer transition-colors shrink-0",
phase === "site" ? "bg-accent/50 text-foreground" : "hover:bg-accent/30 text-muted-foreground hover:text-foreground"
)}
onClick={() => setPhase("site")}
>
<div className="flex items-center gap-2">
<img
src="/icons/site.png"
className={cn("w-5 h-5 object-contain transition-all", phase !== "site" && "opacity-60 grayscale")}
alt="Site"
<LayoutGroup>
<div className="flex flex-col h-full">
{/* Site Header */}
{siteNode && (
<motion.div
layout="position"
className={cn(
"flex items-center justify-between px-3 py-3 border-b border-border/50 cursor-pointer transition-colors shrink-0",
phase === "site" ? "bg-accent/50 text-foreground" : "hover:bg-accent/30 text-muted-foreground hover:text-foreground"
)}
onClick={() => setPhase("site")}
>
<div className="flex items-center gap-2">
<img
src="/icons/site.png"
className={cn("w-5 h-5 object-contain transition-all", phase !== "site" && "opacity-60 grayscale")}
alt="Site"
/>
<span className="text-sm font-medium">{siteNode.name || "Site"}</span>
</div>
<CameraPopover
nodeId={siteNode.id as AnyNodeId}
hasCamera={!!siteNode.camera}
open={siteCameraOpen}
onOpenChange={setSiteCameraOpen}
buttonClassName={cn("transition-colors", phase === "site" ? "hover:bg-black/5 dark:hover:bg-white/10" : "hover:bg-accent")}
/>
<span className="text-sm font-medium">{siteNode.name || "Site"}</span>
</div>
<CameraPopover
nodeId={siteNode.id as AnyNodeId}
hasCamera={!!siteNode.camera}
open={siteCameraOpen}
onOpenChange={setSiteCameraOpen}
buttonClassName={cn("transition-colors", phase === "site" ? "hover:bg-black/5 dark:hover:bg-white/10" : "hover:bg-accent")}
/>
</div>
)}
<div className={cn("flex-1 flex flex-col min-h-0", phase === "site" && "overflow-y-auto")}>
{/* When phase is site, show property line immediately under site header */}
{phase === "site" && <div className="shrink-0"><PropertyLineSection /></div>}
{/* Buildings List */}
{buildings.length === 0 ? (
<div className="px-3 py-4 text-sm text-muted-foreground">
No buildings yet
</div>
) : (
<div className="flex flex-col flex-1 min-h-0">
{buildings.map((building) => {
const isBuildingActive = (phase === "structure" || phase === "furnish") && selectedBuildingId === building.id;
return (
<BuildingItem
key={building.id}
building={building}
isBuildingActive={isBuildingActive}
buildingCameraOpen={buildingCameraOpen}
setBuildingCameraOpen={setBuildingCameraOpen}
/>
);
})}
</div>
</motion.div>
)}
<motion.div layout className={cn("flex-1 flex flex-col min-h-0", phase === "site" && "overflow-y-auto")}>
{/* When phase is site, show property line immediately under site header */}
<AnimatePresence initial={false}>
{phase === "site" && (
<motion.div
layout="position"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ type: "spring", bounce: 0, duration: 0.4 }}
className="shrink-0 overflow-hidden"
>
<PropertyLineSection />
</motion.div>
)}
</AnimatePresence>
{/* Buildings List */}
{buildings.length === 0 ? (
<motion.div layout="position" className="px-3 py-4 text-sm text-muted-foreground">
No buildings yet
</motion.div>
) : (
<motion.div layout className="flex flex-col flex-1 min-h-0">
{buildings.map((building) => {
const isBuildingActive = (phase === "structure" || phase === "furnish") && selectedBuildingId === building.id;
return (
<BuildingItem
key={building.id}
building={building}
isBuildingActive={isBuildingActive}
buildingCameraOpen={buildingCameraOpen}
setBuildingCameraOpen={setBuildingCameraOpen}
/>
);
})}
</motion.div>
)}
</motion.div>
</div>
</div>
</LayoutGroup>
);
}
@@ -2,8 +2,9 @@ import { type AnyNodeId, ItemNode, useScene } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import Image from "next/image";
import { useState, useEffect } from "react";
import useEditor from "@/store/use-editor";
import { InlineRenameInput } from "./inline-rename-input";
import { TreeNode, TreeNodeWrapper } from "./tree-node";
import { TreeNode, TreeNodeWrapper, handleTreeSelection } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions";
const CATEGORY_ICONS: Record<string, string> = {
@@ -19,9 +20,10 @@ const CATEGORY_ICONS: Record<string, string> = {
interface ItemTreeNodeProps {
node: ItemNode;
depth: number;
isLast?: boolean;
}
export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
export function ItemTreeNode({ node, depth, isLast }: ItemTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false);
const [expanded, setExpanded] = useState(true);
const iconSrc = CATEGORY_ICONS[node.asset.category] || "/icons/couch.png";
@@ -51,8 +53,12 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
}
}, [selectedIds, node.id]);
const handleClick = () => {
setSelection({ selectedIds: [node.id] });
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection);
if (!handled && useEditor.getState().phase === "structure") {
useEditor.getState().setPhase("furnish");
}
};
const handleDoubleClick = () => {
@@ -72,6 +78,7 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
return (
<TreeNodeWrapper
nodeId={node.id}
icon={<Image src={iconSrc} alt="" width={14} height={14} className="object-contain" />}
label={
<InlineRenameInput
@@ -93,10 +100,11 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
isSelected={isSelected}
isHovered={isHovered}
isVisible={node.visible !== false}
isLast={isLast}
actions={<TreeNodeActions node={node} />}
>
{hasChildren && node.children.map((childId) => (
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
{hasChildren && node.children.map((childId, index) => (
<TreeNode key={childId} nodeId={childId} depth={depth + 1} isLast={index === node.children.length - 1} />
))}
</TreeNodeWrapper>
);
@@ -9,9 +9,10 @@ import { TreeNodeActions } from "./tree-node-actions";
interface LevelTreeNodeProps {
node: LevelNode;
depth: number;
isLast?: boolean;
}
export function LevelTreeNode({ node, depth }: LevelTreeNodeProps) {
export function LevelTreeNode({ node, depth, isLast }: LevelTreeNodeProps) {
const [expanded, setExpanded] = useState(true);
const [isEditing, setIsEditing] = useState(false);
const isSelected = useViewer((state) => state.selection.levelId === node.id);
@@ -48,10 +49,11 @@ export function LevelTreeNode({ node, depth }: LevelTreeNodeProps) {
onDoubleClick={handleDoubleClick}
isSelected={isSelected}
isHovered={isHovered}
isLast={isLast}
actions={<TreeNodeActions node={node} />}
>
{node.children.map((childId) => (
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
{node.children.map((childId, index) => (
<TreeNode key={childId} nodeId={childId} depth={depth + 1} isLast={index === node.children.length - 1} />
))}
</TreeNodeWrapper>
);
@@ -1,265 +0,0 @@
'use client'
import {
type AnyNodeId,
type GuideNode,
GuideNode as GuideNodeSchema,
type LevelNode,
type ScanNode,
ScanNode as ScanNodeSchema,
useScene,
} from '@pascal-app/core'
import { Box, Image, Pencil, Plus, Trash2 } from 'lucide-react'
import { useCallback, useRef, useState } from 'react'
import useEditor from '@/store/use-editor'
import { deleteProjectAssetByUrl, uploadProjectAsset } from '@/features/community/lib/assets/actions'
import { useProjectStore } from '@/features/community/lib/projects/store'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/primitives/dialog'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/primitives/popover'
const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB — matches server action bodySizeLimit
interface ReferencesDialogProps {
levelId: string
open: boolean
onOpenChange: (open: boolean) => void
}
export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDialogProps) {
const nodes = useScene((s) => s.nodes)
const createNode = useScene((s) => s.createNode)
const deleteNode = useScene((s) => s.deleteNode)
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
const activeProject = useProjectStore((s) => s.activeProject)
const [uploadError, setUploadError] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const scanInputRef = useRef<HTMLInputElement>(null)
const guideInputRef = useRef<HTMLInputElement>(null)
const handleAddScan = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
e.target.value = ''
const projectId = activeProject?.id
if (!projectId) {
setUploadError('No active project. Please open a project first.')
return
}
if (file.size > MAX_FILE_SIZE) {
setUploadError(`File is too large (${(file.size / 1024 / 1024).toFixed(0)} MB). Maximum size is 100 MB.`)
return
}
setUploadError(null)
setUploading(true)
const result = await uploadProjectAsset(projectId, file, 'scan')
setUploading(false)
if (!result.success) {
setUploadError(result.error)
return
}
const node = ScanNodeSchema.parse({
url: result.url,
name: file.name,
parentId: levelId,
})
createNode(node, levelId as AnyNodeId)
// Auto-select and close dialog
setSelectedReferenceId(node.id)
onOpenChange(false)
},
[levelId, createNode, setSelectedReferenceId, onOpenChange, activeProject],
)
const handleAddGuide = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
e.target.value = ''
const projectId = activeProject?.id
if (!projectId) {
setUploadError('No active project. Please open a project first.')
return
}
if (file.size > MAX_FILE_SIZE) {
setUploadError(`File is too large (${(file.size / 1024 / 1024).toFixed(0)} MB). Maximum size is 100 MB.`)
return
}
setUploadError(null)
setUploading(true)
const result = await uploadProjectAsset(projectId, file, 'guide')
setUploading(false)
if (!result.success) {
setUploadError(result.error)
return
}
const node = GuideNodeSchema.parse({
url: result.url,
name: file.name,
parentId: levelId,
})
createNode(node, levelId as AnyNodeId)
// Auto-select and close dialog
setSelectedReferenceId(node.id)
onOpenChange(false)
},
[levelId, createNode, setSelectedReferenceId, onOpenChange, activeProject],
)
const handleEdit = useCallback(
(nodeId: string) => {
setSelectedReferenceId(nodeId)
onOpenChange(false)
},
[setSelectedReferenceId, onOpenChange],
)
const handleDelete = useCallback(
async (nodeId: string) => {
const refNode = nodes[nodeId as AnyNodeId] as ScanNode | GuideNode | undefined
const projectId = activeProject?.id
// Delete storage asset first (before removing from scene)
if (
projectId &&
refNode?.url &&
(refNode.url.startsWith('http://') || refNode.url.startsWith('https://'))
) {
const result = await deleteProjectAssetByUrl(projectId, refNode.url)
if (!result.success) {
setUploadError(`Failed to delete asset: ${result.error}`)
return
}
}
deleteNode(nodeId as AnyNodeId)
},
[deleteNode, nodes, activeProject],
)
const level = nodes[levelId as AnyNodeId] as LevelNode | undefined
if (!level) return null
// Find all scan and guide children of this level
const references = Object.values(nodes).filter(
(node): node is ScanNode | GuideNode =>
(node.type === 'scan' || node.type === 'guide') && node.parentId === levelId,
)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>References {level.name || `Level ${level.level}`}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-1 max-h-64 overflow-y-auto">
{references.length === 0 && (
<p className="text-sm text-muted-foreground py-4 text-center">
No references yet. Add a 3D scan or guide image.
</p>
)}
{references.map((ref) => (
<div
key={ref.id}
className="flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-accent/50 group"
>
{ref.type === 'scan' ? (
<Box className="w-4 h-4 shrink-0 text-muted-foreground" />
) : (
<Image className="w-4 h-4 shrink-0 text-muted-foreground" />
)}
<span className="flex-1 truncate">
{ref.name || (ref.type === 'scan' ? '3D Scan' : 'Guide Image')}
</span>
<button
className="opacity-0 group-hover:opacity-100 w-6 h-6 flex items-center justify-center rounded hover:bg-accent cursor-pointer"
onClick={() => handleEdit(ref.id)}
title="Edit"
>
<Pencil className="w-3 h-3" />
</button>
<button
className="opacity-0 group-hover:opacity-100 w-6 h-6 flex items-center justify-center rounded hover:bg-destructive/10 text-destructive cursor-pointer"
onClick={() => handleDelete(ref.id)}
title="Delete"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
</div>
{uploadError && (
<p className="text-xs text-destructive px-1 pb-1">{uploadError}</p>
)}
<div className="flex justify-end pt-2 border-t border-border/50">
<input
ref={scanInputRef}
type="file"
accept=".glb,.gltf"
className="hidden"
onChange={handleAddScan}
/>
<input
ref={guideInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleAddGuide}
/>
<Popover>
<PopoverTrigger asChild>
<button
disabled={uploading}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm bg-primary text-primary-foreground hover:bg-primary/90 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="w-3.5 h-3.5" />
{uploading ? 'Uploading…' : 'Add'}
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-1">
<button
className="flex items-center gap-2 w-full px-3 py-2 rounded-md text-sm hover:bg-accent cursor-pointer"
onClick={() => scanInputRef.current?.click()}
>
<Box className="w-4 h-4" />
3D Scan
</button>
<button
className="flex items-center gap-2 w-full px-3 py-2 rounded-md text-sm hover:bg-accent cursor-pointer"
onClick={() => guideInputRef.current?.click()}
>
<Image className="w-4 h-4" />
Guide Image
</button>
</PopoverContent>
</Popover>
</div>
</DialogContent>
</Dialog>
)
}
@@ -2,24 +2,31 @@ import { RoofNode } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import Image from "next/image";
import { useState } from "react";
import useEditor from "@/store/use-editor";
import { InlineRenameInput } from "./inline-rename-input";
import { TreeNodeWrapper } from "./tree-node";
import { TreeNodeWrapper, handleTreeSelection } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions";
interface RoofTreeNodeProps {
node: RoofNode;
depth: number;
isLast?: boolean;
}
export function RoofTreeNode({ node, depth }: RoofTreeNodeProps) {
export function RoofTreeNode({ node, depth, isLast }: RoofTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false);
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
const selectedIds = useViewer((state) => state.selection.selectedIds);
const isSelected = selectedIds.includes(node.id);
const isHovered = useViewer((state) => state.hoveredId === node.id);
const setSelection = useViewer((state) => state.setSelection);
const setHoveredId = useViewer((state) => state.setHoveredId);
const handleClick = () => {
setSelection({ selectedIds: [node.id] });
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection);
if (!handled && useEditor.getState().phase === "furnish") {
useEditor.getState().setPhase("structure");
}
};
const handleDoubleClick = () => {
@@ -41,6 +48,7 @@ export function RoofTreeNode({ node, depth }: RoofTreeNodeProps) {
return (
<TreeNodeWrapper
nodeId={node.id}
icon={<Image src="/icons/roof.png" alt="" width={14} height={14} className="object-contain" />}
label={
<InlineRenameInput
@@ -62,6 +70,7 @@ export function RoofTreeNode({ node, depth }: RoofTreeNodeProps) {
isSelected={isSelected}
isHovered={isHovered}
isVisible={node.visible !== false}
isLast={isLast}
actions={<TreeNodeActions node={node} />}
/>
);
@@ -2,24 +2,31 @@ import { SlabNode } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import Image from "next/image";
import { useState } from "react";
import useEditor from "@/store/use-editor";
import { InlineRenameInput } from "./inline-rename-input";
import { TreeNodeWrapper } from "./tree-node";
import { TreeNodeWrapper, handleTreeSelection } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions";
interface SlabTreeNodeProps {
node: SlabNode;
depth: number;
isLast?: boolean;
}
export function SlabTreeNode({ node, depth }: SlabTreeNodeProps) {
export function SlabTreeNode({ node, depth, isLast }: SlabTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false);
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
const selectedIds = useViewer((state) => state.selection.selectedIds);
const isSelected = selectedIds.includes(node.id);
const isHovered = useViewer((state) => state.hoveredId === node.id);
const setSelection = useViewer((state) => state.setSelection);
const setHoveredId = useViewer((state) => state.setHoveredId);
const handleClick = () => {
setSelection({ selectedIds: [node.id] });
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection);
if (!handled && useEditor.getState().phase === "furnish") {
useEditor.getState().setPhase("structure");
}
};
const handleDoubleClick = () => {
@@ -40,6 +47,7 @@ export function SlabTreeNode({ node, depth }: SlabTreeNodeProps) {
return (
<TreeNodeWrapper
nodeId={node.id}
icon={<Image src="/icons/floor.png" alt="" width={14} height={14} className="object-contain" />}
label={
<InlineRenameInput
@@ -61,6 +69,7 @@ export function SlabTreeNode({ node, depth }: SlabTreeNodeProps) {
isSelected={isSelected}
isHovered={isHovered}
isVisible={node.visible !== false}
isLast={isLast}
actions={<TreeNodeActions node={node} />}
/>
);
@@ -2,6 +2,53 @@ import { AnyNodeId, useScene } from "@pascal-app/core";
import { ChevronDown, ChevronRight } from "lucide-react";
import { forwardRef, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { motion, AnimatePresence } from "motion/react";
export function handleTreeSelection(
e: React.MouseEvent,
nodeId: string,
selectedIds: string[],
setSelection: (s: any) => void
) {
if (e.metaKey || e.ctrlKey) {
if (selectedIds.includes(nodeId)) {
setSelection({ selectedIds: selectedIds.filter((id) => id !== nodeId) });
} else {
setSelection({ selectedIds: [...selectedIds, nodeId] });
}
return true;
}
if (e.shiftKey && selectedIds.length > 0) {
const lastSelectedId = selectedIds[selectedIds.length - 1];
if (lastSelectedId) {
const nodes = Array.from(document.querySelectorAll('[data-treenode-id]'));
const nodeIds = nodes.map(n => n.getAttribute('data-treenode-id') as string);
const startIndex = nodeIds.indexOf(lastSelectedId);
const endIndex = nodeIds.indexOf(nodeId);
if (startIndex !== -1 && endIndex !== -1) {
const start = Math.min(startIndex, endIndex);
const end = Math.max(startIndex, endIndex);
const range = nodeIds.slice(start, end + 1);
// We can keep the previous selections that were outside the range if we want,
// but standard file system shift-click replaces the selection with the range.
setSelection({ selectedIds: range });
return true;
}
}
// Fallback: if range selection fails (e.g. node not visible in tree), just add to selection
if (!selectedIds.includes(nodeId)) {
setSelection({ selectedIds: [...selectedIds, nodeId] });
return true;
}
}
setSelection({ selectedIds: [nodeId] });
return false;
}
import { BuildingTreeNode } from "./building-tree-node";
import { CeilingTreeNode } from "./ceiling-tree-node";
import { DoorTreeNode } from "./door-tree-node";
@@ -16,47 +63,49 @@ import { ZoneTreeNode } from "./zone-tree-node";
interface TreeNodeProps {
nodeId: AnyNodeId;
depth?: number;
isLast?: boolean;
}
export function TreeNode({ nodeId, depth = 0 }: TreeNodeProps) {
export function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
const node = useScene((state) => state.nodes[nodeId]);
if (!node) return null;
switch (node.type) {
case "building":
return <BuildingTreeNode node={node} depth={depth} />;
return <BuildingTreeNode node={node as any} depth={depth} isLast={isLast} />;
case "ceiling":
return <CeilingTreeNode node={node} depth={depth} />;
return <CeilingTreeNode node={node as any} depth={depth} isLast={isLast} />;
case "level":
return <LevelTreeNode node={node} depth={depth} />;
return <LevelTreeNode node={node as any} depth={depth} isLast={isLast} />;
case "slab":
return <SlabTreeNode node={node} depth={depth} />;
return <SlabTreeNode node={node as any} depth={depth} isLast={isLast} />;
case "wall":
return <WallTreeNode node={node} depth={depth} />;
return <WallTreeNode node={node as any} depth={depth} isLast={isLast} />;
case "roof":
return <RoofTreeNode node={node} depth={depth} />;
return <RoofTreeNode node={node as any} depth={depth} isLast={isLast} />;
case "item":
return <ItemTreeNode node={node} depth={depth} />;
return <ItemTreeNode node={node as any} depth={depth} isLast={isLast} />;
case "door":
return <DoorTreeNode node={node} depth={depth} />;
return <DoorTreeNode node={node as any} depth={depth} isLast={isLast} />;
case "window":
return <WindowTreeNode node={node} depth={depth} />;
return <WindowTreeNode node={node as any} depth={depth} isLast={isLast} />;
case "zone":
return <ZoneTreeNode node={node} depth={depth} />;
return <ZoneTreeNode node={node as any} depth={depth} isLast={isLast} />;
default:
return null;
}
}
interface TreeNodeWrapperProps {
nodeId?: string;
icon: React.ReactNode;
label: React.ReactNode;
depth: number;
hasChildren: boolean;
expanded: boolean;
onToggle: () => void;
onClick: () => void;
onClick: (e: React.MouseEvent) => void;
onDoubleClick?: () => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
@@ -65,56 +114,80 @@ interface TreeNodeWrapperProps {
isSelected?: boolean;
isHovered?: boolean;
isVisible?: boolean;
isLast?: boolean;
}
export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
function TreeNodeWrapper(
{
icon,
label,
depth,
hasChildren,
expanded,
onToggle,
onClick,
onDoubleClick,
onMouseEnter,
onMouseLeave,
actions,
children,
isSelected,
isHovered,
isVisible = true,
},
ref
) {
const rowRef = useRef<HTMLDivElement>(null);
function TreeNodeWrapper(
{
nodeId,
icon,
label,
depth,
hasChildren,
expanded,
onToggle,
onClick,
onDoubleClick,
onMouseEnter,
onMouseLeave,
actions,
children,
isSelected,
isHovered,
isVisible = true,
isLast,
},
ref
) {
const rowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isSelected && rowRef.current) {
rowRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, [isSelected]);
useEffect(() => {
if (isSelected && rowRef.current) {
rowRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, [isSelected]);
return (
<div ref={ref}>
<div
ref={rowRef}
className={cn(
"flex items-center h-8 cursor-pointer group/row text-sm select-none border-b border-border/50 transition-all duration-200",
isSelected
? "bg-accent/50 text-foreground"
: isHovered
? "bg-accent/30 text-foreground"
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground",
!isVisible && "opacity-50"
)}
style={{ paddingLeft: depth * 12 + 12, paddingRight: 12 }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<button
className="w-4 h-4 flex items-center justify-center shrink-0"
return (
<div ref={ref} data-treenode-id={nodeId}>
<div
ref={rowRef}
className={cn(
"relative flex items-center h-8 cursor-pointer group/row text-sm select-none border-b border-r border-border/50 border-r-transparent transition-all duration-200",
isSelected
? "bg-accent/50 text-foreground border-r-white border-r-3"
: isHovered
? "bg-accent/30 text-foreground"
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground",
!isVisible && "opacity-50"
)}
style={{ paddingLeft: depth * 12 + 12, paddingRight: 12 }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{/* Vertical tree line */}
<div
className={cn(
"absolute w-px bg-border/50 pointer-events-none",
isLast ? "top-0 bottom-1/2" : "top-0 bottom-0"
)}
style={{ left: (depth - 1) * 12 + 20 }}
/>
{/* Horizontal branch line */}
<div
className="absolute top-1/2 h-px bg-border/50 pointer-events-none"
style={{ left: (depth - 1) * 12 + 20, width: 4 }}
/>
{/* Line down to children */}
{hasChildren && expanded && (
<div
className="absolute top-1/2 bottom-0 w-px bg-border/50 pointer-events-none"
style={{ left: depth * 12 + 20 }}
/>
)}
<button
className="w-4 h-4 flex items-center justify-center shrink-0 z-10 bg-inherit"
onClick={(e) => {
e.stopPropagation();
onToggle();
@@ -149,7 +222,19 @@ export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
</div>
)}
</div>
{expanded && children}
<AnimatePresence initial={false}>
{expanded && children && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ type: "spring", bounce: 0, duration: 0.3 }}
className="overflow-hidden"
>
{children}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
@@ -2,16 +2,18 @@ import { type AnyNodeId, WallNode, useScene } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import Image from "next/image";
import { useState, useEffect } from "react";
import useEditor from "@/store/use-editor";
import { InlineRenameInput } from "./inline-rename-input";
import { TreeNode, TreeNodeWrapper } from "./tree-node";
import { TreeNode, TreeNodeWrapper, handleTreeSelection } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions";
interface WallTreeNodeProps {
node: WallNode;
depth: number;
isLast?: boolean;
}
export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
export function WallTreeNode({ node, depth, isLast }: WallTreeNodeProps) {
const [expanded, setExpanded] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const selectedIds = useViewer((state) => state.selection.selectedIds);
@@ -40,8 +42,12 @@ export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
}
}, [selectedIds, node.id]);
const handleClick = () => {
setSelection({ selectedIds: [node.id] });
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection);
if (!handled && useEditor.getState().phase === "furnish") {
useEditor.getState().setPhase("structure");
}
};
const handleDoubleClick = () => {
@@ -60,6 +66,7 @@ export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
return (
<TreeNodeWrapper
nodeId={node.id}
icon={<Image src="/icons/wall.png" alt="" width={14} height={14} className="object-contain" />}
label={
<InlineRenameInput
@@ -81,10 +88,11 @@ export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
isSelected={isSelected}
isHovered={isHovered}
isVisible={node.visible !== false}
isLast={isLast}
actions={<TreeNodeActions node={node} />}
>
{node.children.map((childId) => (
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
{node.children.map((childId, index) => (
<TreeNode key={childId} nodeId={childId} depth={depth + 1} isLast={index === node.children.length - 1} />
))}
</TreeNodeWrapper>
);
@@ -4,18 +4,21 @@ import { WindowNode } from "@pascal-app/core"
import { useViewer } from "@pascal-app/viewer"
import Image from "next/image"
import { useState } from "react"
import useEditor from "@/store/use-editor"
import { InlineRenameInput } from "./inline-rename-input"
import { TreeNodeWrapper } from "./tree-node"
import { TreeNodeWrapper, handleTreeSelection } from "./tree-node"
import { TreeNodeActions } from "./tree-node-actions"
interface WindowTreeNodeProps {
node: WindowNode
depth: number
isLast?: boolean
}
export function WindowTreeNode({ node, depth }: WindowTreeNodeProps) {
export function WindowTreeNode({ node, depth, isLast }: WindowTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false)
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id))
const selectedIds = useViewer((state) => state.selection.selectedIds)
const isSelected = selectedIds.includes(node.id)
const isHovered = useViewer((state) => state.hoveredId === node.id)
const setSelection = useViewer((state) => state.setSelection)
const setHoveredId = useViewer((state) => state.setHoveredId)
@@ -24,6 +27,7 @@ export function WindowTreeNode({ node, depth }: WindowTreeNodeProps) {
return (
<TreeNodeWrapper
nodeId={node.id}
icon={<Image src="/icons/window.png" alt="" width={14} height={14} className="object-contain" />}
label={
<InlineRenameInput
@@ -38,13 +42,20 @@ export function WindowTreeNode({ node, depth }: WindowTreeNodeProps) {
hasChildren={false}
expanded={false}
onToggle={() => {}}
onClick={() => setSelection({ selectedIds: [node.id] })}
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
if (!handled && useEditor.getState().phase === "furnish") {
useEditor.getState().setPhase("structure")
}
}}
onDoubleClick={() => setIsEditing(true)}
onMouseEnter={() => setHoveredId(node.id)}
onMouseLeave={() => setHoveredId(null)}
isSelected={isSelected}
isHovered={isHovered}
isVisible={node.visible !== false}
isLast={isLast}
actions={<TreeNodeActions node={node} />}
/>
)
@@ -8,9 +8,10 @@ import { TreeNodeActions } from "./tree-node-actions";
interface ZoneTreeNodeProps {
node: ZoneNode;
depth: number;
isLast?: boolean;
}
export function ZoneTreeNode({ node, depth }: ZoneTreeNodeProps) {
export function ZoneTreeNode({ node, depth, isLast }: ZoneTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false);
const isSelected = useViewer((state) => state.selection.zoneId === node.id);
const isHovered = useViewer((state) => state.hoveredId === node.id);
@@ -64,6 +65,7 @@ export function ZoneTreeNode({ node, depth }: ZoneTreeNodeProps) {
onMouseLeave={handleMouseLeave}
isSelected={isSelected}
isHovered={isHovered}
isLast={isLast}
actions={<TreeNodeActions node={node} />}
/>
);
@@ -46,11 +46,7 @@ export default function CommunityHub() {
}
}, [isAuthenticated, authLoading])
const handleProjectCreated = async (projectId: string) => {
const result = await getUserProjects()
if (result.success) {
setUserProjects(result.data || [])
}
const handleProjectCreated = (projectId: string) => {
router.push(`/editor/${projectId}`)
}
@@ -4,6 +4,7 @@ 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 {
@@ -30,7 +31,12 @@ export function NewProjectDialog({ open, onOpenChange, onSuccess }: NewProjectDi
setIsCreating(true)
try {
const result = await createProject({ name, isPrivate })
// 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)
@@ -61,44 +61,48 @@ export function useProjectScene() {
async function loadScene() {
// Suppress auto-save for the store update caused by setScene/clearScene
isLoadingSceneRef.current = true
useProjectStore.getState().setIsSceneLoading(true)
try {
useScene.getState().clearScene()
const result = await getProjectModel(projectId || '')
if (result.success && result.data?.scene_graph) {
// Load the scene graph into the store
const { nodes, rootNodeIds } = result.data.scene_graph
useScene.getState().setScene(nodes, rootNodeIds)
// Auto-select the first building + level after store is updated
const sceneNodes = useScene.getState().nodes as Record<string, any>
const sceneRootIds = useScene.getState().rootNodeIds
const siteNode = sceneRootIds[0] ? sceneNodes[sceneRootIds[0]] : null
const resolve = (child: any) =>
typeof child === 'string' ? sceneNodes[child] : child
const firstBuilding = siteNode?.children?.map(resolve).find((n: any) => n?.type === 'building')
const firstLevel = firstBuilding?.children?.map(resolve).find((n: any) => n?.type === 'level')
if (firstBuilding && firstLevel) {
useViewer.getState().setSelection({
buildingId: firstBuilding.id,
levelId: firstLevel.id,
selectedIds: [],
zoneId: null,
})
useEditor.getState().setPhase('structure')
} else {
useEditor.getState().setPhase('site')
useViewer.getState().setSelection({
buildingId: null,
levelId: null,
selectedIds: [],
zoneId: null,
})
}
} else {
// No scene found - clear the scene
useScene.getState().clearScene()
}
// Auto-select the first building + level after store is updated
const sceneNodes = useScene.getState().nodes as Record<string, any>
const sceneRootIds = useScene.getState().rootNodeIds
const siteNode = sceneRootIds[0] ? sceneNodes[sceneRootIds[0]] : null
const resolve = (child: any) =>
typeof child === 'string' ? sceneNodes[child] : child
const firstBuilding = siteNode?.children?.map(resolve).find((n: any) => n?.type === 'building')
const firstLevel = firstBuilding?.children?.map(resolve).find((n: any) => n?.type === 'level')
if (firstBuilding && firstLevel) {
useViewer.getState().setSelection({
buildingId: firstBuilding.id,
levelId: firstLevel.id,
selectedIds: [],
zoneId: null,
})
useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('elements')
// Auto-select the wall tool if the level is empty (e.g., brand new project)
if (!firstLevel.children || firstLevel.children.length === 0) {
useEditor.getState().setMode('build')
useEditor.getState().setTool('wall')
}
} else {
useEditor.getState().setPhase('site')
useViewer.getState().setSelection({
buildingId: null,
@@ -110,13 +114,42 @@ export function useProjectScene() {
} catch (error) {
// Fall back to clear scene
useScene.getState().clearScene()
useEditor.getState().setPhase('site')
useViewer.getState().setSelection({
buildingId: null,
levelId: null,
selectedIds: [],
zoneId: null,
})
// Auto-select the first building + level from the cleared scene
const sceneNodes = useScene.getState().nodes as Record<string, any>
const sceneRootIds = useScene.getState().rootNodeIds
const siteNode = sceneRootIds[0] ? sceneNodes[sceneRootIds[0]] : null
const resolve = (child: any) =>
typeof child === 'string' ? sceneNodes[child] : child
const firstBuilding = siteNode?.children?.map(resolve).find((n: any) => n?.type === 'building')
const firstLevel = firstBuilding?.children?.map(resolve).find((n: any) => n?.type === 'level')
if (firstBuilding && firstLevel) {
useViewer.getState().setSelection({
buildingId: firstBuilding.id,
levelId: firstLevel.id,
selectedIds: [],
zoneId: null,
})
useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('elements')
// Auto-select the wall tool if the level is empty (e.g., brand new project)
if (!firstLevel.children || firstLevel.children.length === 0) {
useEditor.getState().setMode('build')
useEditor.getState().setTool('wall')
}
} else {
useEditor.getState().setPhase('site')
useViewer.getState().setSelection({
buildingId: null,
levelId: null,
selectedIds: [],
zoneId: null,
})
}
} finally {
useProjectStore.getState().setIsSceneLoading(false)
}
// Allow auto-save again after a tick (let the store update propagate)
@@ -15,12 +15,14 @@ interface ProjectStore {
activeProject: Project | null
projects: Project[]
isLoading: boolean
isSceneLoading: boolean
error: string | null
// Actions
fetchProjects: () => Promise<void>
fetchActiveProject: () => Promise<void>
setActiveProject: (projectId: string) => Promise<void>
setIsSceneLoading: (loading: boolean) => void
initialize: () => Promise<void>
updateActiveThumbnail: (thumbnailUrl: string) => void
}
@@ -30,6 +32,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
activeProject: null,
projects: [],
isLoading: true,
isSceneLoading: false,
error: null,
// Fetch all projects
@@ -79,6 +82,10 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
}
},
setIsSceneLoading: (loading: boolean) => {
set({ isSceneLoading: loading })
},
// Patch the active project's thumbnail URL in place (no refetch)
updateActiveThumbnail: (thumbnailUrl: string) => {
set((state) => ({
+34
View File
@@ -16,6 +16,10 @@ export const useKeyboard = () => {
e.preventDefault()
emitter.emit('tool:cancel')
// Clear selections to close UI panels, but KEEP the active building and level context
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
useEditor.getState().setSelectedReferenceId(null)
// If in build mode, switch back to select mode
const { mode } = useEditor.getState()
if (mode === 'build') {
@@ -46,6 +50,36 @@ export const useKeyboard = () => {
} else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
useScene.temporal.getState().redo()
} else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
const { buildingId, levelId } = useViewer.getState().selection
if (buildingId) {
const building = useScene.getState().nodes[buildingId]
if (building && building.type === 'building' && building.children.length > 0) {
const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1
const nextIdx = currentIdx < building.children.length - 1 ? currentIdx + 1 : currentIdx
if (nextIdx !== -1 && nextIdx !== currentIdx) {
useViewer.getState().setSelection({ levelId: building.children[nextIdx] as any })
} else if (currentIdx === -1) {
useViewer.getState().setSelection({ levelId: building.children[0] as any })
}
}
}
} else if (e.key === 'ArrowDown' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
const { buildingId, levelId } = useViewer.getState().selection
if (buildingId) {
const building = useScene.getState().nodes[buildingId]
if (building && building.type === 'building' && building.children.length > 0) {
const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1
const prevIdx = currentIdx > 0 ? currentIdx - 1 : currentIdx
if (prevIdx !== -1 && prevIdx !== currentIdx) {
useViewer.getState().setSelection({ levelId: building.children[prevIdx] as any })
} else if (currentIdx === -1) {
useViewer.getState().setSelection({ levelId: building.children[building.children.length - 1] as any })
}
}
}
} else if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault()
+18 -14
View File
@@ -29,38 +29,42 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-google-maps/api": "^2.20.8",
"@react-three/uikit-lucide": "^1.0.60",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@react-three/uikit-lucide": "^1.0.62",
"@repo/ui": "*",
"@supabase/supabase-js": "^2.95.3",
"@supabase/supabase-js": "^2.98.0",
"@t3-oss/env-nextjs": "^0.13.10",
"@tailwindcss/postcss": "^4.1.18",
"@types/three": "^0.183.0",
"@tailwindcss/postcss": "^4.2.1",
"@types/three": "^0.183.1",
"@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^1.3.1",
"@vercel/toolbar": "^0.2.2",
"@visual-json/react": "latest",
"better-auth": "^1.4.18",
"better-auth": "^1.4.19",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"howler": "^2.2.4",
"lucide-react": "^0.562.0",
"motion": "^12.26.2",
"motion": "^12.34.3",
"nanoid": "^5.1.6",
"next": "16.1.0",
"next": "16.1.6",
"postcss": "^8.5.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"zod": "^4.3.6"
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"three": "^0.183.1",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
"devDependencies": {
"@repo/typescript-config": "*",
"@types/howler": "^2.2.12",
"@types/node": "^22.15.3",
"@types/node": "^22.19.12",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"tw-animate-css": "^1.4.0",
"typescript": "5.9.2"
"typescript": "5.9.3"
}
}
+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.5056 10.7754C21.1225 10.5355 21.431 10.4155 21.5176 10.2459C21.5926 10.099 21.5903 9.92446 21.5115 9.77954C21.4205 9.61226 21.109 9.50044 20.486 9.2768L4.59629 3.5728C4.0866 3.38983 3.83175 3.29835 3.66514 3.35605C3.52029 3.40621 3.40645 3.52004 3.35629 3.6649C3.29859 3.8315 3.39008 4.08635 3.57304 4.59605L9.277 20.4858C9.50064 21.1088 9.61246 21.4203 9.77973 21.5113C9.92465 21.5901 10.0991 21.5924 10.2461 21.5174C10.4157 21.4308 10.5356 21.1223 10.7756 20.5054L13.3724 13.8278C13.4194 13.707 13.4429 13.6466 13.4792 13.5957C13.5114 13.5506 13.5508 13.5112 13.5959 13.479C13.6468 13.4427 13.7072 13.4192 13.828 13.3722L20.5056 10.7754Z" stroke="#D3DAF0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" fill="#202022"/>
</svg>

After

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

+151 -149
View File
@@ -5,20 +5,14 @@
"": {
"name": "editor",
"dependencies": {
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"portless": "^0.4.2",
"three": "^0.183.0",
"three-bvh-csg": "^0.0.17",
"three-mesh-bvh": "^0.9.8",
"zustand": "^5.0.11",
},
"devDependencies": {
"@biomejs/biome": "^2.4.2",
"supabase": "2.75.3",
"turbo": "^2.8.10",
"typescript": "5.9.2",
"ultracite": "^7.2.3",
"@biomejs/biome": "^2.4.4",
"supabase": "2.76.15",
"turbo": "^2.8.11",
"typescript": "5.9.3",
"ultracite": "^7.2.4",
},
},
"apps/editor": {
@@ -43,39 +37,43 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-google-maps/api": "^2.20.8",
"@react-three/uikit-lucide": "^1.0.60",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@react-three/uikit-lucide": "^1.0.62",
"@repo/ui": "*",
"@supabase/supabase-js": "^2.95.3",
"@supabase/supabase-js": "^2.98.0",
"@t3-oss/env-nextjs": "^0.13.10",
"@tailwindcss/postcss": "^4.1.18",
"@types/three": "^0.183.0",
"@tailwindcss/postcss": "^4.2.1",
"@types/three": "^0.183.1",
"@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^1.3.1",
"@vercel/toolbar": "^0.2.2",
"@visual-json/react": "latest",
"better-auth": "^1.4.18",
"better-auth": "^1.4.19",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"howler": "^2.2.4",
"lucide-react": "^0.562.0",
"motion": "^12.26.2",
"motion": "^12.34.3",
"nanoid": "^5.1.6",
"next": "16.1.0",
"next": "16.1.6",
"postcss": "^8.5.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"three": "^0.183.1",
"zod": "^4.3.6",
"zustand": "^5.0.11",
},
"devDependencies": {
"@repo/typescript-config": "*",
"@types/howler": "^2.2.12",
"@types/node": "^22.15.3",
"@types/node": "^22.19.12",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"tw-animate-css": "^1.4.0",
"typescript": "5.9.2",
"typescript": "5.9.3",
},
},
"packages/auth": {
@@ -88,7 +86,7 @@
},
"devDependencies": {
"@repo/typescript-config": "*",
"typescript": "5.9.2",
"typescript": "5.9.3",
},
},
"packages/core": {
@@ -99,7 +97,7 @@
"idb-keyval": "^6.2.2",
"mitt": "^3.0.1",
"nanoid": "^5.1.6",
"three-bvh-csg": "^0.0.17",
"three-bvh-csg": "^0.0.18",
"three-mesh-bvh": "^0.9.8",
"zod": "^4.3.5",
"zundo": "^2.3.0",
@@ -109,7 +107,7 @@
"@repo/typescript-config": "*",
"@types/react": "^19.2.2",
"@types/three": "^0.183.0",
"typescript": "5.9.2",
"typescript": "5.9.3",
},
"peerDependencies": {
"@react-three/drei": "^10",
@@ -123,15 +121,15 @@
"version": "0.0.0",
"dependencies": {
"@supabase/supabase-js": "^2.95.3",
"drizzle-orm": "^0.39.0",
"drizzle-zod": "^0.5.1",
"drizzle-orm": "^0.45.1",
"drizzle-zod": "^0.8.3",
"nanoid": "^5.0.9",
"postgres": "^3.4.5",
},
"devDependencies": {
"@repo/typescript-config": "*",
"drizzle-kit": "^0.30.0",
"typescript": "5.9.2",
"drizzle-kit": "^0.31.9",
"typescript": "5.9.3",
},
},
"packages/eslint-config": {
@@ -169,7 +167,7 @@
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"eslint": "^9.39.1",
"typescript": "5.9.2",
"typescript": "5.9.3",
},
},
"packages/viewer": {
@@ -182,7 +180,7 @@
"@repo/typescript-config": "*",
"@types/react": "^19.2.2",
"@types/three": "^0.183.0",
"typescript": "5.9.2",
"typescript": "5.9.3",
},
"peerDependencies": {
"@pascal-app/core": "^0.1.4",
@@ -198,31 +196,31 @@
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
"@better-auth/core": ["@better-auth/core@1.4.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.3.5" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.8", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg=="],
"@better-auth/core": ["@better-auth/core@1.4.19", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.3.5" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.8", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-uADLHG1jc5BnEJi7f6ijUN5DmPPRSj++7m/G19z3UqA3MVCo4Y4t1MMa4IIxLCqGDFv22drdfxescgW+HnIowA=="],
"@better-auth/telemetry": ["@better-auth/telemetry@1.4.18", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.18" } }, "sha512-e5rDF8S4j3Um/0LIVATL2in9dL4lfO2fr2v1Wio4qTMRbfxqnUDTa+6SZtwdeJrbc4O+a3c+IyIpjG9Q/6GpfQ=="],
"@better-auth/telemetry": ["@better-auth/telemetry@1.4.19", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.19" } }, "sha512-ApGNS7olCTtDpKF8Ow3Z+jvFAirOj7c4RyFUpu8axklh3mH57ndpfUAUjhgA8UVoaaH/mnm/Tl884BlqiewLyw=="],
"@better-auth/utils": ["@better-auth/utils@0.3.0", "", {}, "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw=="],
"@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="],
"@biomejs/biome": ["@biomejs/biome@2.4.2", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.2", "@biomejs/cli-darwin-x64": "2.4.2", "@biomejs/cli-linux-arm64": "2.4.2", "@biomejs/cli-linux-arm64-musl": "2.4.2", "@biomejs/cli-linux-x64": "2.4.2", "@biomejs/cli-linux-x64-musl": "2.4.2", "@biomejs/cli-win32-arm64": "2.4.2", "@biomejs/cli-win32-x64": "2.4.2" }, "bin": { "biome": "bin/biome" } }, "sha512-vVE/FqLxNLbvYnFDYg3Xfrh1UdFhmPT5i+yPT9GE2nTUgI4rkqo5krw5wK19YHBd7aE7J6r91RRmb8RWwkjy6w=="],
"@biomejs/biome": ["@biomejs/biome@2.4.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.4", "@biomejs/cli-darwin-x64": "2.4.4", "@biomejs/cli-linux-arm64": "2.4.4", "@biomejs/cli-linux-arm64-musl": "2.4.4", "@biomejs/cli-linux-x64": "2.4.4", "@biomejs/cli-linux-x64-musl": "2.4.4", "@biomejs/cli-win32-arm64": "2.4.4", "@biomejs/cli-win32-x64": "2.4.4" }, "bin": { "biome": "bin/biome" } }, "sha512-tigwWS5KfJf0cABVd52NVaXyAVv4qpUXOWJ1rxFL8xF1RVoeS2q/LK+FHgYoKMclJCuRoCWAPy1IXaN9/mS61Q=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3pEcKCP/1POKyaZZhXcxFl3+d9njmeAihZ17k8lL/1vk+6e0Cbf0yPzKItFiT+5Yh6TQA4uKvnlqe0oVZwRxCA=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-P7hK1jLVny+0R9UwyGcECxO6sjETxfPyBm/1dmFjnDOHgdDPjPqozByunrwh4xPKld8sxOr5eAsSqal5uKgeBg=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Dh1a/+W+SUCXhEdL7TiX3ArPTFCQKJTI1mGncZNWfO+6suk+gYA4lNyJcBB+pwvF49uw0pEbUS49BgYOY4hzUg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-DI3Mi7GT2zYNgUTDEbSjl3e1KhoP76OjQdm8JpvZYZWtVDRyLd3w8llSr2TWk1z+U3P44kUBWY3X7H9MD1/DGQ=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-/x04YK9+7erw6tYEcJv9WXoBHcULI/wMOvNdAyE9S3JStZZ9yJyV67sWAI+90UHuDo/BDhq0d96LDqGlSVv7WA=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+sPAXq3bxmFwhVFJnSwkSF5Rw2ZAJMH3MF6C9IveAEOdSpgajPhoQhbbAK12SehN9j2QrHpk4J/cHsa/HqWaYQ=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-GK2ErnrKpWFigYP68cXiCHK4RTL4IUWhK92AFS3U28X/nuAL5+hTuy6hyobc8JZRSt+upXt1nXChK+tuHHx4mA=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-wbBmTkeAoAYbOQ33f6sfKG7pcRSydQiF+dTYOBjJsnXO2mWEOQHllKlC2YVnedqZFERp2WZhFUoO7TNRwnwEHQ=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-k2uqwLYrNNxnaoiW3RJxoMGnbKda8FuCmtYG3cOtVljs3CzWxaTR+AoXwKGHscC9thax9R4kOrtWqWN0+KdPTw=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.2", "", { "os": "win32", "cpu": "x64" }, "sha512-9ma7C4g8Sq3cBlRJD2yrsHXB1mnnEBdpy7PhvFrylQWQb4PoyCmPucdX7frvsSBQuFtIiKCrolPl/8tCZrKvgQ=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.4", "", { "os": "win32", "cpu": "x64" }, "sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A=="],
"@clack/core": ["@clack/core@1.0.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-WKeyK3NOBwDOzagPR5H08rFk9D/WuN705yEbuZvKqlkmoLM2woKtXb10OO2k1NoSU4SFG947i2/SCYh+2u5e4g=="],
@@ -238,51 +236,57 @@
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.19.12", "", { "os": "android", "cpu": "arm64" }, "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.19.12", "", { "os": "android", "cpu": "x64" }, "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.19.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.19.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.19.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.19.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.19.12", "", { "os": "linux", "cpu": "arm" }, "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.19.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.19.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.19.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.19.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.19.12", "", { "os": "linux", "cpu": "x64" }, "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.19.12", "", { "os": "none", "cpu": "x64" }, "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.19.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.19.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.19.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.19.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.19.12", "", { "os": "win32", "cpu": "x64" }, "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
@@ -390,25 +394,25 @@
"@monogrid/gainmap-js": ["@monogrid/gainmap-js@3.4.0", "", { "dependencies": { "promise-worker-transferable": "^1.0.4" }, "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg=="],
"@next/env": ["@next/env@16.1.0", "", {}, "sha512-Dd23XQeFHmhf3KBW76leYVkejHlCdB7erakC2At2apL1N08Bm+dLYNP+nNHh0tzUXfPQcNcXiQyacw0PG4Fcpw=="],
"@next/env": ["@next/env@16.1.6", "", {}, "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="],
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.5.9", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-kUzXx0iFiXw27cQAViE1yKWnz/nF8JzRmwgMRTMh8qMY90crNsdXJRh2e+R0vBpFR3kk1yvAR7wev7+fCCb79Q=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-onHq8dl8KjDb8taANQdzs3XmIqQWV3fYdslkGENuvVInFQzZnuBYYOG2HGHqqtvgmEU7xWzhgndXXxnhk4Z3fQ=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Am6VJTp8KhLuAH13tPrAoVIXzuComlZlMwGr++o2KDjWiKPe3VwpxYhgV6I4gKls2EnsIMggL4y7GdXyDdJcFA=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-fVicfaJT6QfghNyg8JErZ+EMNQ812IS0lmKfbmC01LF1nFBcKfcs4Q75Yy8IqnsCqH/hZwGhqzj3IGVfWV6vpA=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-TojQnDRoX7wJWXEEwdfuJtakMDW64Q7NrxQPviUnfYJvAx5/5wcGE+1vZzQ9F17m+SdpFeeXuOr6v3jbyusYMQ=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-quhNFVySW4QwXiZkZ34SbfzNBm27vLrxZ2HwTfFFO1BBP0OY1+pI0nbyewKeq1FriqU+LZrob/cm26lwsiAi8Q=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-6JW0z2FZUK5iOVhUIWqE4RblAhUj1EwhZ/MwteGb//SpFTOHydnhbp3868gxalwea+mbOLWO6xgxj9wA9wNvNw=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.1.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-+DK/akkAvvXn5RdYN84IOmLkSy87SCmpofJPdB8vbLmf01BzntPBSYXnMvnEEv/Vcf3HYJwt24QZ/s6sWAwOMQ=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.1.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Tr0j94MphimCCks+1rtYPzQFK+faJuhHWCegU9S9gDlgyOk8Y3kPmO64UcjyzZAlligeBtYZ/2bEyrKq0d2wqQ=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A=="],
"@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="],
@@ -432,13 +436,15 @@
"@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
"@pmndrs/msdfonts": ["@pmndrs/msdfonts@1.0.61", "", {}, "sha512-Nxk+3dAdsXFdx5pq1IHNe1j4zxogkAfhmBKUIHS+aJUs45q/XMtlADVrHLooiQ/Amlp9tXAvbvFYUOGd897POw=="],
"@pmndrs/msdfonts": ["@pmndrs/msdfonts@1.0.62", "", {}, "sha512-TGmAo0mFbdzPwwNCDzJoy6Khr3T/Z613ApwegYsRs6S+P+AeOSIS4+4aB5bL4G7hY6uNt5cyZVClESuEjYXt9g=="],
"@pmndrs/uikit": ["@pmndrs/uikit@1.0.61", "", { "dependencies": { "@pmndrs/msdfonts": "^1.0.61", "@pmndrs/uikit-pub-sub": "^1.0.61", "@preact/signals-core": "^1.5.1", "@zappar/msdf-generator": "^1.2.4", "yoga-layout": "^3.2.1" }, "peerDependencies": { "three": ">=0.162" } }, "sha512-xoNp2jKRoHa7INC3QaAL2ddpGmKy+eORMh4zoNds17EndqyBrqYzfWJK+PVmei/5MQNBTi0KrY2qxBGj6hB0cg=="],
"@pmndrs/pointer-events": ["@pmndrs/pointer-events@6.6.29", "", {}, "sha512-o4YD6VfJgDYjFgde/YyAw2X5KY454tdmOXrHGOvKTWJBHzkL90B5vH4rqmexwRVvaDfT3YLvVh/Dm5cBbgZXMg=="],
"@pmndrs/uikit-lucide": ["@pmndrs/uikit-lucide@1.0.61", "", { "dependencies": { "@pmndrs/uikit": "^1.0.61" } }, "sha512-pToRVLnOIuxs0RUm6UTa5HWgcDfhLATq1FtgKmzxiUuHVvYf4h8PIrFJswP8e/KeotzHvr5sTg/HIYvbvSo0/A=="],
"@pmndrs/uikit": ["@pmndrs/uikit@1.0.62", "", { "dependencies": { "@pmndrs/msdfonts": "^1.0.62", "@pmndrs/uikit-pub-sub": "^1.0.62", "@preact/signals-core": "^1.5.1", "@zappar/msdf-generator": "^1.2.4", "yoga-layout": "^3.2.1" }, "peerDependencies": { "three": ">=0.162" } }, "sha512-TRV7Q1hKmGIFEzhJYF4xaXowdgqGUQd+XKcpEfyvwShOWEWqiDAYoI5OYjDQPhVVshI2frb5XVPr6acGf2laFQ=="],
"@pmndrs/uikit-pub-sub": ["@pmndrs/uikit-pub-sub@1.0.61", "", { "dependencies": { "@preact/signals-core": "^1.8.0" } }, "sha512-J1r5faMzwoWgAsDTW/0B+M96BuZgCyrvpM91/+QENnBfFCRR6xM2LIqI3KB/HlaO/1L8zQQHQE5g8HA2tVT4xw=="],
"@pmndrs/uikit-lucide": ["@pmndrs/uikit-lucide@1.0.62", "", { "dependencies": { "@pmndrs/uikit": "^1.0.62" } }, "sha512-aGgFJixWnYf+Gb3anQr04G3MaDYf82AQSZZFYO/Xzo1Y8QxaAjeiQdIgZtq7rrEWWxzJbVepHN9YgHyfLmBPMg=="],
"@pmndrs/uikit-pub-sub": ["@pmndrs/uikit-pub-sub@1.0.62", "", { "dependencies": { "@preact/signals-core": "^1.8.0" } }, "sha512-E/8z5ZnqGfHgsgyUtMujJwRM11JSsZpxYnSxDDOZ+Q5FcK7aLSBCfT80Nm32EdytpN+xQ76mDI8ROvWYg3abxg=="],
"@preact/signals-core": ["@preact/signals-core@1.12.1", "", {}, "sha512-BwbTXpj+9QutoZLQvbttRg5x3l5468qaV2kufh+51yha1c53ep5dY4kTuZR35+3pAZxpfQerGJiQqg34ZNZ6uA=="],
@@ -532,9 +538,9 @@
"@react-three/fiber": ["@react-three/fiber@9.5.0", "", { "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", "base64-js": "^1.5.1", "buffer": "^6.0.3", "its-fine": "^2.0.0", "react-use-measure": "^2.1.7", "scheduler": "^0.27.0", "suspend-react": "^0.1.3", "use-sync-external-store": "^1.4.0", "zustand": "^5.0.3" }, "peerDependencies": { "expo": ">=43.0", "expo-asset": ">=8.4", "expo-file-system": ">=11.0", "expo-gl": ">=11.0", "react": ">=19 <19.3", "react-dom": ">=19 <19.3", "react-native": ">=0.78", "three": ">=0.156" }, "optionalPeers": ["expo", "expo-asset", "expo-file-system", "expo-gl", "react-dom", "react-native"] }, "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA=="],
"@react-three/uikit": ["@react-three/uikit@1.0.61", "", { "dependencies": { "@pmndrs/uikit": "^1.0.61", "@preact/signals-core": "^1.5.1", "suspend-react": "^0.1.3", "zustand": "^5.0.6" }, "peerDependencies": { "@react-three/fiber": ">=8", "react": ">=18" } }, "sha512-rZrgev90X0UwXQ9CvBVYvoiMDW2UrIlQEoBERDG7BO3pdvXGENuMG9hmodGBGe9c8IC0REO9u7xerMWPBz8zyQ=="],
"@react-three/uikit": ["@react-three/uikit@1.0.62", "", { "dependencies": { "@pmndrs/pointer-events": "^6.6.29", "@pmndrs/uikit": "^1.0.62", "@preact/signals-core": "^1.5.1", "suspend-react": "^0.1.3", "zustand": "^5.0.6" }, "peerDependencies": { "@react-three/fiber": ">=8", "react": ">=18" } }, "sha512-2kvZSsIp/S6aTkEl9RT1rDV+p2S4fmIoUZTvHIWwpiENorQfARC/FZEC4tFzIRKty8+7Pa8J7h8twvkF3vWZ4A=="],
"@react-three/uikit-lucide": ["@react-three/uikit-lucide@1.0.61", "", { "dependencies": { "@pmndrs/uikit-lucide": "^1.0.61", "@react-three/uikit": "^1.0.61" } }, "sha512-iZc71VDl90sL14JjL6DqX6a0vJ0HKotFRHU4plIfauMcyLxmjhgQ9jQHKXUNwyvJtJhzNm+We7DUz5hCPODntQ=="],
"@react-three/uikit-lucide": ["@react-three/uikit-lucide@1.0.62", "", { "dependencies": { "@pmndrs/uikit-lucide": "^1.0.62", "@react-three/uikit": "^1.0.62" } }, "sha512-/iC9yjUwEHNFRPvTIIXiMIvek39XA2QpFZjVsr3XOwF3jmZ3IFlEOtjxZz0OYpxVZQSMAIqolPE78Ndim7pSZg=="],
"@repo/eslint-config": ["@repo/eslint-config@workspace:packages/eslint-config"],
@@ -546,17 +552,17 @@
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@supabase/auth-js": ["@supabase/auth-js@2.95.3", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-vD2YoS8E2iKIX0F7EwXTmqhUpaNsmbU6X2R0/NdFcs02oEfnHyNP/3M716f3wVJ2E5XHGiTFXki6lRckhJ0Thg=="],
"@supabase/auth-js": ["@supabase/auth-js@2.98.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-GBH361T0peHU91AQNzOlIrjUZw9TZbB9YDRiyFgk/3Kvr3/Z1NWUZ2athWTfHhwNNi8IrW00foyFxQD9IO/Trg=="],
"@supabase/functions-js": ["@supabase/functions-js@2.95.3", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-uTuOAKzs9R/IovW1krO0ZbUHSJnsnyJElTXIRhjJTqymIVGcHzkAYnBCJqd7468Fs/Foz1BQ7Dv6DCl05lr7ig=="],
"@supabase/functions-js": ["@supabase/functions-js@2.98.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-N/xEyiNU5Org+d+PNCpv+TWniAXRzxIURxDYsS/m2I/sfAB/HcM9aM2Dmf5edj5oWb9GxID1OBaZ8NMmPXL+Lg=="],
"@supabase/postgrest-js": ["@supabase/postgrest-js@2.95.3", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-LTrRBqU1gOovxRm1vRXPItSMPBmEFqrfTqdPTRtzOILV4jPSueFz6pES5hpb4LRlkFwCPRmv3nQJ5N625V2Xrg=="],
"@supabase/postgrest-js": ["@supabase/postgrest-js@2.98.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-v6e9WeZuJijzUut8HyXu6gMqWFepIbaeaMIm1uKzei4yLg9bC9OtEW9O14LE/9ezqNbSAnSLO5GtOLFdm7Bpkg=="],
"@supabase/realtime-js": ["@supabase/realtime-js@2.95.3", "", { "dependencies": { "@types/phoenix": "^1.6.6", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-D7EAtfU3w6BEUxDACjowWNJo/ZRo7sDIuhuOGKHIm9FHieGeoJV5R6GKTLtga/5l/6fDr2u+WcW/m8I9SYmaIw=="],
"@supabase/realtime-js": ["@supabase/realtime-js@2.98.0", "", { "dependencies": { "@types/phoenix": "^1.6.6", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-rOWt28uGyFipWOSd+n0WVMr9kUXiWaa7J4hvyLCIHjRFqWm1z9CaaKAoYyfYMC1Exn3WT8WePCgiVhlAtWC2yw=="],
"@supabase/storage-js": ["@supabase/storage-js@2.95.3", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-4GxkJiXI3HHWjxpC3sDx1BVrV87O0hfX+wvJdqGv67KeCu+g44SPnII8y0LL/Wr677jB7tpjAxKdtVWf+xhc9A=="],
"@supabase/storage-js": ["@supabase/storage-js@2.98.0", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-tzr2mG+v7ILSAZSfZMSL9OPyIH4z1ikgQ8EcQTKfMRz4EwmlFt3UnJaGzSOxyvF5b+fc9So7qdSUWTqGgeLokQ=="],
"@supabase/supabase-js": ["@supabase/supabase-js@2.95.3", "", { "dependencies": { "@supabase/auth-js": "2.95.3", "@supabase/functions-js": "2.95.3", "@supabase/postgrest-js": "2.95.3", "@supabase/realtime-js": "2.95.3", "@supabase/storage-js": "2.95.3" } }, "sha512-Fukw1cUTQ6xdLiHDJhKKPu6svEPaCEDvThqCne3OaQyZvuq2qjhJAd91kJu3PXLG18aooCgYBaB6qQz35hhABg=="],
"@supabase/supabase-js": ["@supabase/supabase-js@2.98.0", "", { "dependencies": { "@supabase/auth-js": "2.98.0", "@supabase/functions-js": "2.98.0", "@supabase/postgrest-js": "2.98.0", "@supabase/realtime-js": "2.98.0", "@supabase/storage-js": "2.98.0" } }, "sha512-Ohc97CtInLwZyiSASz7tT9/Abm/vqnIbO9REp+PivVUII8UZsuI3bngRQnYgJdFoOIwvaEII1fX1qy8x0CyNiw=="],
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
@@ -564,35 +570,35 @@
"@t3-oss/env-nextjs": ["@t3-oss/env-nextjs@0.13.10", "", { "dependencies": { "@t3-oss/env-core": "0.13.10" }, "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0" }, "optionalPeers": ["arktype", "typescript", "valibot", "zod"] }, "sha512-JfSA2WXOnvcc/uMdp31paMsfbYhhdvLLRxlwvrnlPE9bwM/n0Z+Qb9xRv48nPpvfMhOrkrTYw1I5Yc06WIKBJQ=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
"@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="],
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="],
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.1", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "postcss": "^8.5.6", "tailwindcss": "4.2.1" } }, "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw=="],
"@tinyhttp/accepts": ["@tinyhttp/accepts@1.3.0", "", { "dependencies": { "es-mime-types": "^0.0.16", "negotiator": "^0.6.2" } }, "sha512-YaJ4EMgVUI6JHzWO14lr6vn/BLJEoFN4Sqd20l0/oBcLLENkP8gnPtX1jB7OhIu0AE40VCweAqvSP+0/pgzB1g=="],
@@ -638,7 +644,7 @@
"@types/md5": ["@types/md5@2.3.6", "", {}, "sha512-WD69gNXtRBnpknfZcb4TRQ0XJQbUPZcai/Qdhmka3sxUR3Et8NrXoeAoknG/LghYHTf4ve795rInVYHBTQdNVA=="],
"@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="],
"@types/node": ["@types/node@22.19.12", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-0QEp0aPJYSyf6RrTjDB7HlKgNMTY+V2C7ESTaVt6G9gQ0rPLzTGz7OF2NXTLR5vcy7HJEtIUsyWLsfX0kTqJBA=="],
"@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="],
@@ -740,7 +746,7 @@
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.14", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg=="],
"better-auth": ["better-auth@1.4.18", "", { "dependencies": { "@better-auth/core": "1.4.18", "@better-auth/telemetry": "1.4.18", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.8", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.3.5" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg=="],
"better-auth": ["better-auth@1.4.19", "", { "dependencies": { "@better-auth/core": "1.4.19", "@better-auth/telemetry": "1.4.19", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.8", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.3.5" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-3RlZJcA0+NH25wYD85vpIGwW9oSTuEmLIaGbT8zg41w/Pa2hVWHKedjoUHHJtnzkBXzDb+CShkLnSw7IThDdqQ=="],
"better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="],
@@ -850,15 +856,15 @@
"draco3d": ["draco3d@1.5.7", "", {}, "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ=="],
"drizzle-kit": ["drizzle-kit@0.30.6", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0", "gel": "^2.0.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g=="],
"drizzle-kit": ["drizzle-kit@0.31.9", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg=="],
"drizzle-orm": ["drizzle-orm@0.39.3", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-EZ8ZpYvDIvKU9C56JYLOmUskazhad+uXZCTCRN4OnRMsL+xAJ05dv1eCpAG5xzhsm1hqiuC5kAZUCS924u2DTw=="],
"drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="],
"drizzle-zod": ["drizzle-zod@0.5.1", "", { "peerDependencies": { "drizzle-orm": ">=0.23.13", "zod": "*" } }, "sha512-C/8bvzUH/zSnVfwdSibOgFjLhtDtbKYmkbPbUCq46QZyZCH6kODIMSOgZ8R7rVjoI+tCj3k06MRJMDqsIeoS4A=="],
"drizzle-zod": ["drizzle-zod@0.8.3", "", { "peerDependencies": { "drizzle-orm": ">=0.36.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-66yVOuvGhKJnTdiqj1/Xaaz9/qzOdRJADpDa68enqS6g3t0kpNkwNYjUuaeXgZfO/UWuIM9HIhSlJ6C5ZraMww=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
@@ -888,7 +894,7 @@
"es-vary": ["es-vary@0.0.8", "", {}, "sha512-fiERjQiCHrXUAToNRT/sh7MtXnfei9n7cF9oVQRUEp9L5BGXsTKSPaXq8L+4v0c/ezfvuTWd/f0JSl5IBRUvSg=="],
"esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
@@ -962,7 +968,7 @@
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
"framer-motion": ["framer-motion@12.26.2", "", { "dependencies": { "motion-dom": "^12.26.2", "motion-utils": "^12.24.10", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-lflOQEdjquUi9sCg5Y1LrsZDlsjrHw7m0T9Yedvnk7Bnhqfkc89/Uha10J3CFhkL+TCZVCRw9eUGyM/lyYhXQA=="],
"framer-motion": ["framer-motion@12.34.3", "", { "dependencies": { "motion-dom": "^12.34.3", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
@@ -1150,29 +1156,29 @@
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
@@ -1214,11 +1220,11 @@
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
"motion": ["motion@12.26.2", "", { "dependencies": { "framer-motion": "^12.26.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-2Q6g0zK1gUJKhGT742DAe42LgietcdiJ3L3OcYAHCQaC1UkLnn6aC8S/obe4CxYTLAgid2asS1QdQ/blYfo5dw=="],
"motion": ["motion@12.34.3", "", { "dependencies": { "framer-motion": "^12.34.3", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-xZIkBGO7v/Uvm+EyaqYd+9IpXu0sZqLywVlGdCFrrMiaO9JI4Kx51mO9KlHSWwll+gZUVY5OJsWgYI5FywJ/tw=="],
"motion-dom": ["motion-dom@12.26.2", "", { "dependencies": { "motion-utils": "^12.24.10" } }, "sha512-KLMT1BroY8oKNeliA3JMNJ+nbCIsTKg6hJpDb4jtRAJ7nCKnnpg/LTq/NGqG90Limitz3kdAnAVXecdFVGlWTw=="],
"motion-dom": ["motion-dom@12.34.3", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ=="],
"motion-utils": ["motion-utils@12.24.10", "", {}, "sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww=="],
"motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@@ -1230,7 +1236,7 @@
"negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="],
"next": ["next@16.1.0", "", { "dependencies": { "@next/env": "16.1.0", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.0", "@next/swc-darwin-x64": "16.1.0", "@next/swc-linux-arm64-gnu": "16.1.0", "@next/swc-linux-arm64-musl": "16.1.0", "@next/swc-linux-x64-gnu": "16.1.0", "@next/swc-linux-x64-musl": "16.1.0", "@next/swc-win32-arm64-msvc": "16.1.0", "@next/swc-win32-x64-msvc": "16.1.0", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-Y+KbmDbefYtHDDQKLNrmzE/YYzG2msqo2VXhzh5yrJ54tx/6TmGdkR5+kP9ma7i7LwZpZMfoY3m/AoPPPKxtVw=="],
"next": ["next@16.1.6", "", { "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.6", "@next/swc-darwin-x64": "16.1.6", "@next/swc-linux-arm64-gnu": "16.1.6", "@next/swc-linux-arm64-musl": "16.1.6", "@next/swc-linux-x64-gnu": "16.1.6", "@next/swc-linux-x64-musl": "16.1.6", "@next/swc-win32-arm64-msvc": "16.1.6", "@next/swc-win32-x64-msvc": "16.1.6", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
@@ -1342,9 +1348,9 @@
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
@@ -1458,7 +1464,7 @@
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
"supabase": ["supabase@2.75.3", "", { "dependencies": { "bin-links": "^6.0.0", "https-proxy-agent": "^7.0.2", "node-fetch": "^3.3.2", "tar": "7.5.7" }, "bin": { "supabase": "bin/supabase" } }, "sha512-j2uHJfK8TE7SxL6cd+kQixwkl8H5ASBkQfOEVjf3d3XS1Wixy4UO8WudDYNVriu1KuKzsNnkkSC/jTyHNXNrcQ=="],
"supabase": ["supabase@2.76.15", "", { "dependencies": { "bin-links": "^6.0.0", "https-proxy-agent": "^7.0.2", "node-fetch": "^3.3.2", "tar": "7.5.9" }, "bin": { "supabase": "bin/supabase" } }, "sha512-m69o1XPAzZaIWfQiEeT+KY/Ci3OSA663RyoH9xECbXSxhr7dsipLCpCqT1E4MCob0mMhHh/7A+Eltx4y1qSwiQ=="],
"supercluster": ["supercluster@8.0.1", "", { "dependencies": { "kdbush": "^4.0.2" } }, "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ=="],
@@ -1468,17 +1474,17 @@
"suspend-react": ["suspend-react@0.1.3", "", { "peerDependencies": { "react": ">=17.0" } }, "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ=="],
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
"tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tar": ["tar@7.5.7", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ=="],
"tar": ["tar@7.5.9", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg=="],
"three": ["three@0.183.1", "", {}, "sha512-Psv6bbd3d/M/01MT2zZ+VmD0Vj2dbWTNhfe4CuSg7w5TuW96M3NOyCVuh9SZQ05CpGmD7NEcJhZw4GVjhCYxfQ=="],
"three-bvh-csg": ["three-bvh-csg@0.0.17", "", { "peerDependencies": { "three": ">=0.151.0", "three-mesh-bvh": ">=0.6.6" } }, "sha512-iEkHDF8GRfGM6593Cuw8SnF1vfENCp46gIAtRzuL4nGXGWPcR1sbTBwM9ptDONb5twqlZp5WkAKya5aBKe2qcA=="],
"three-bvh-csg": ["three-bvh-csg@0.0.18", "", { "peerDependencies": { "three": ">=0.179.0", "three-mesh-bvh": ">=0.9.7" } }, "sha512-M3GCZMmGFgASGuDf+YMamM83nVlD/vdwzVHcYbFxgW+g1S7/nKPiuY00YVHOMbjmJPh8mLevGZL65ItHUuGt2w=="],
"three-mesh-bvh": ["three-mesh-bvh@0.9.8", "", { "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-YphYvdXEZSXdz6iNdWJo1RB6qvSCRyiXPEVSvNU6xVWbLDOdSrfEIsJOpgFOnefdmVEvZ6M+sY0cjh9gl7MvdA=="],
@@ -1502,19 +1508,19 @@
"tunnel-rat": ["tunnel-rat@0.1.2", "", { "dependencies": { "zustand": "^4.3.2" } }, "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ=="],
"turbo": ["turbo@2.8.10", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.10", "turbo-darwin-arm64": "2.8.10", "turbo-linux-64": "2.8.10", "turbo-linux-arm64": "2.8.10", "turbo-windows-64": "2.8.10", "turbo-windows-arm64": "2.8.10" }, "bin": { "turbo": "bin/turbo" } }, "sha512-OxbzDES66+x7nnKGg2MwBA1ypVsZoDTLHpeaP4giyiHSixbsiTaMyeJqbEyvBdp5Cm28fc+8GG6RdQtic0ijwQ=="],
"turbo": ["turbo@2.8.11", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.11", "turbo-darwin-arm64": "2.8.11", "turbo-linux-64": "2.8.11", "turbo-linux-arm64": "2.8.11", "turbo-windows-64": "2.8.11", "turbo-windows-arm64": "2.8.11" }, "bin": { "turbo": "bin/turbo" } }, "sha512-H+rwSHHPLoyPOSoHdmI1zY0zy0GGj1Dmr7SeJW+nZiWLz2nex8EJ+fkdVabxXFMNEux+aywI4Sae8EqhmnOv4A=="],
"turbo-darwin-64": ["turbo-darwin-64@2.8.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-A03fXh+B7S8mL3PbdhTd+0UsaGrhfyPkODvzBDpKRY7bbeac4MDFpJ7I+Slf2oSkCEeSvHKR7Z4U71uKRUfX7g=="],
"turbo-darwin-64": ["turbo-darwin-64@2.8.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-XKaCWaz4OCt77oYYvGCIRpvYD4c/aNaKjRkUpv+e8rN3RZb+5Xsyew4yRO+gaHdMIUhQznXNXfHlhs+/p7lIhA=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-sidzowgWL3s5xCHLeqwC9M3s9M0i16W1nuQF3Mc7fPHpZ+YPohvcbVFBB2uoRRHYZg6yBnwD4gyUHKTeXfwtXA=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VvynLHGUNvQ9k7GZjRPSsRcK4VkioTfFb7O7liAk4nHKjEcMdls7GqxzjVWgJiKz3hWmQGaP9hRa9UUnhVWCxA=="],
"turbo-linux-64": ["turbo-linux-64@2.8.10", "", { "os": "linux", "cpu": "x64" }, "sha512-YK9vcpL3TVtqonB021XwgaQhY9hJJbKKUhLv16osxV0HkcQASQWUqR56yMge7puh6nxU67rQlTq1b7ksR1T3KA=="],
"turbo-linux-64": ["turbo-linux-64@2.8.11", "", { "os": "linux", "cpu": "x64" }, "sha512-cbSn37dcm+EmkQ7DD0euy7xV7o2el4GAOr1XujvkAyKjjNvQ+6QIUeDgQcwAx3D17zPpDvfDMJY2dLQadWnkmQ=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.8.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-3+j2tL0sG95iBJTm+6J8/45JsETQABPqtFyYjVjBbi6eVGdtNTiBmHNKrbvXRlQ3ZbUG75bKLaSSDHSEEN+btQ=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.8.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-+trymp2s2aBrhS04l6qFxcExzZ8ffndevuUB9c5RCeqsVpZeiWuGQlWNm5XjOmzoMayxRARZ5ma7yiWbGMiLqQ=="],
"turbo-windows-64": ["turbo-windows-64@2.8.10", "", { "os": "win32", "cpu": "x64" }, "sha512-hdeF5qmVY/NFgiucf8FW0CWJWtyT2QPm5mIsX0W1DXAVzqKVXGq+Zf+dg4EUngAFKjDzoBeN6ec2Fhajwfztkw=="],
"turbo-windows-64": ["turbo-windows-64@2.8.11", "", { "os": "win32", "cpu": "x64" }, "sha512-3kJjFSM4yw1n9Uzmi+XkAUgCae19l/bH6RJ442xo7mnZm0tpOjo33F+FYHoSVpIWVMd0HG0LDccyafPSdylQbA=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.8.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-QGdr/Q8LWmj+ITMkSvfiz2glf0d7JG0oXVzGL3jxkGqiBI1zXFj20oqVY0qWi+112LO9SVrYdpHS0E/oGFrMbQ=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.8.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-JOM4uF2vuLsJUvibdR6X9QqdZr6BhC6Nhlrw4LKFPsXZZI/9HHLoqAiYRpE4MuzIwldCH/jVySnWXrI1SKto0g=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
@@ -1528,11 +1534,11 @@
"typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="],
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"typescript-eslint": ["typescript-eslint@8.53.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.53.0", "@typescript-eslint/parser": "8.53.0", "@typescript-eslint/typescript-estree": "8.53.0", "@typescript-eslint/utils": "8.53.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-xHURCQNxZ1dsWn0sdOaOfCSQG0HKeqSj9OexIxrz6ypU6wHYOdX2I3D2b8s8wFSsSOYJb+6q283cLiLlkEsBYw=="],
"ultracite": ["ultracite@7.2.3", "", { "dependencies": { "@clack/prompts": "^1.0.1", "commander": "^14.0.3", "deepmerge": "^4.3.1", "glob": "^13.0.3", "jsonc-parser": "^3.3.1", "nypm": "^0.6.5" }, "peerDependencies": { "oxlint": "^1.0.0" }, "optionalPeers": ["oxlint"], "bin": { "ultracite": "dist/index.js" } }, "sha512-WKNS2sKAZe4BHu+JGbZebXvy/A1QagDaBnndrK/zwOJAze/mQ8jeHfdG2bPlv3qcJ5fdS3w2Kd7c/eIcH78HvA=="],
"ultracite": ["ultracite@7.2.4", "", { "dependencies": { "@clack/prompts": "^1.0.1", "commander": "^14.0.3", "deepmerge": "^4.3.1", "glob": "^13.0.3", "jsonc-parser": "^3.3.1", "nypm": "^0.6.5" }, "peerDependencies": { "oxlint": "^1.0.0" }, "optionalPeers": ["oxlint"], "bin": { "ultracite": "dist/index.js" } }, "sha512-3b2g2pwZMDSd+PBK8pxYCjEoYaqVSgXD22HS22BxR8GfbmRXJ3VpNu5Z5lNywIxZXsJleWdpGPHibOPYr/xmKg=="],
"unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
@@ -1586,8 +1592,6 @@
"zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="],
"@better-auth/core/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
@@ -1618,8 +1622,6 @@
"@react-three/fiber/zustand": ["zustand@5.0.10", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg=="],
"@react-three/uikit/zustand": ["zustand@5.0.10", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg=="],
"@repo/ui/@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
@@ -1634,6 +1636,8 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@types/ws/@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
@@ -1654,8 +1658,6 @@
"@vercel/toolbar/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"better-auth/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
+6 -12
View File
@@ -27,20 +27,14 @@
"release:major": "gh workflow run release.yml -f package=both -f bump=major"
},
"dependencies": {
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"portless": "^0.4.2",
"three": "^0.183.0",
"three-bvh-csg": "^0.0.17",
"three-mesh-bvh": "^0.9.8",
"zustand": "^5.0.11"
"portless": "^0.4.2"
},
"devDependencies": {
"@biomejs/biome": "^2.4.2",
"supabase": "2.75.3",
"turbo": "^2.8.10",
"typescript": "5.9.2",
"ultracite": "^7.2.3"
"@biomejs/biome": "^2.4.4",
"supabase": "2.76.15",
"turbo": "^2.8.11",
"typescript": "5.9.3",
"ultracite": "^7.2.4"
},
"engines": {
"node": ">=18"
+1 -1
View File
@@ -19,6 +19,6 @@
},
"devDependencies": {
"@repo/typescript-config": "*",
"typescript": "5.9.2"
"typescript": "5.9.3"
}
}
+2 -2
View File
@@ -32,7 +32,7 @@
"idb-keyval": "^6.2.2",
"mitt": "^3.0.1",
"nanoid": "^5.1.6",
"three-bvh-csg": "^0.0.17",
"three-bvh-csg": "^0.0.18",
"three-mesh-bvh": "^0.9.8",
"zod": "^4.3.5",
"zundo": "^2.3.0",
@@ -41,7 +41,7 @@
"devDependencies": {
"@repo/typescript-config": "*",
"@types/react": "^19.2.2",
"typescript": "5.9.2",
"typescript": "5.9.3",
"@types/three": "^0.183.0"
},
"keywords": [
+4 -4
View File
@@ -20,14 +20,14 @@
},
"dependencies": {
"@supabase/supabase-js": "^2.95.3",
"drizzle-orm": "^0.39.0",
"drizzle-zod": "^0.5.1",
"drizzle-orm": "^0.45.1",
"drizzle-zod": "^0.8.3",
"nanoid": "^5.0.9",
"postgres": "^3.4.5"
},
"devDependencies": {
"@repo/typescript-config": "*",
"drizzle-kit": "^0.30.0",
"typescript": "5.9.2"
"drizzle-kit": "^0.31.9",
"typescript": "5.9.3"
}
}
+1 -1
View File
@@ -17,7 +17,7 @@
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"eslint": "^9.39.1",
"typescript": "5.9.2"
"typescript": "5.9.3"
},
"dependencies": {
"react": "^19.2.0",
+1 -1
View File
@@ -34,7 +34,7 @@
"devDependencies": {
"@repo/typescript-config": "*",
"@types/react": "^19.2.2",
"typescript": "5.9.2",
"typescript": "5.9.3",
"@types/three": "^0.183.0"
},
"keywords": [
@@ -75,7 +75,7 @@ const pointInPolygonWithTolerance = (
interface SelectionStrategy {
types: SelectableNodeType[]
handleClick: (node: AnyNode) => void
handleClick: (node: AnyNode, nativeEvent?: MouseEvent) => void
handleDeselect: () => void
isValid: (node: AnyNode) => boolean
}
@@ -159,6 +159,21 @@ const isNodeInZone = (node: AnyNode, levelId: string, zoneId: string): boolean =
const getStrategy = (): SelectionStrategy | null => {
const { buildingId, levelId, zoneId } = useViewer.getState().selection
const computeNextIds = (node: AnyNode, selectedIds: string[], event?: any): string[] => {
const isMeta = event?.metaKey || event?.nativeEvent?.metaKey;
const isCtrl = event?.ctrlKey || event?.nativeEvent?.ctrlKey;
if (isMeta || isCtrl) {
if (selectedIds.includes(node.id)) {
return selectedIds.filter((id) => id !== node.id);
} else {
return [...selectedIds, node.id];
}
}
return [node.id];
};
// No building selected -> can select buildings
if (!buildingId) {
return {
@@ -204,16 +219,9 @@ const getStrategy = (): SelectionStrategy | null => {
// Zone selected -> can select/hover contents (walls, items, slabs, ceilings, roofs, windows, doors)
return {
types: ['wall', 'item', 'slab', 'ceiling', 'roof', 'window', 'door'],
handleClick: (node) => {
handleClick: (node, nativeEvent) => {
const { selectedIds } = useViewer.getState().selection
// Toggle selection - if already selected, deselect; otherwise select
if (selectedIds.includes(node.id)) {
useViewer
.getState()
.setSelection({ selectedIds: selectedIds.filter((id) => id !== node.id) })
} else {
useViewer.getState().setSelection({ selectedIds: [node.id] })
}
useViewer.getState().setSelection({ selectedIds: computeNextIds(node, selectedIds, nativeEvent) })
},
handleDeselect: () => {
const { selectedIds } = useViewer.getState().selection
@@ -262,7 +270,7 @@ export const SelectionManager = () => {
event.stopPropagation()
clickHandledRef.current = true
strategy.handleClick(event.node)
strategy.handleClick(event.node, event.nativeEvent as unknown as MouseEvent)
// Clear hover immediately after clicking on building/level/zone
useViewer.setState({ hoveredId: null })
}
+5 -3
View File
@@ -69,11 +69,13 @@ export function useNodeEvents<T extends NodeType>(node: NodeConfig[T]['node'], t
if (useViewer.getState().cameraDragging) return
if (e.button !== 0) return
emit('pointerup', e)
// Synthesize a click event on pointer up to be more forgiving than R3F's default onClick
// which often fails if the mouse moves even 1 pixel.
emit('click', e)
},
onClick: (e: ThreeEvent<PointerEvent>) => {
if (useViewer.getState().cameraDragging) return
if (e.button !== 0) return
emit('click', e)
// Disable default R3F click since we synthesize it on pointerup
// This prevents double-clicks from firing twice.
},
onPointerEnter: (e: ThreeEvent<PointerEvent>) => {
if (useViewer.getState().cameraDragging) return