import { emitter, useScene } from "@pascal-app/core"; import { useViewer } from "@pascal-app/viewer"; import { VisualJson, TreeView } from "@visual-json/react"; import { Camera, Download, Save, Trash2, Upload } from "lucide-react"; import { type KeyboardEvent, type SyntheticEvent, useCallback, useMemo, useRef, useState, } from "react"; import { Button } from "@/components/ui/primitives/button"; import { Dialog, DialogContent, DialogTitle, DialogTrigger, } from "@/components/ui/primitives/dialog"; import { Switch } from "@/components/ui/primitives/switch"; import useEditor from "@/store/use-editor"; import { AudioSettingsDialog } from "./audio-settings-dialog"; import { KeyboardShortcutsDialog } from "./keyboard-shortcuts-dialog"; import { useProjectStore } from "@/features/community/lib/projects/store"; import { updateProjectVisibility } from "@/features/community/lib/projects/actions"; type SceneNode = Record & { id?: unknown; type?: unknown; name?: unknown; parentId?: unknown; children?: unknown; }; type SceneGraphNode = { id: string; type: string; name: string | null; parentId: string | null; children: SceneGraphNode[]; missing?: true; cycle?: true; }; type SceneGraphValue = { roots: SceneGraphNode[]; detachedNodes?: SceneGraphNode[]; }; const isSceneNode = (value: unknown): value is SceneNode => { return ( typeof value === "object" && value !== null && "id" in value && typeof (value as { id: unknown }).id === "string" ); }; const getChildIdsFromNode = (node: SceneNode): string[] => { if (!Array.isArray(node.children)) { return []; } const childIds = new Set(); for (const child of node.children) { if (typeof child === "string") { childIds.add(child); continue; } if (isSceneNode(child)) { childIds.add(child.id as string); } } return Array.from(childIds); }; const buildSceneGraphValue = ( nodes: Record, rootNodeIds: string[], ): SceneGraphValue => { const childIdsByParent = new Map>(); for (const [id, node] of Object.entries(nodes)) { const childIds = getChildIdsFromNode(node); if (childIds.length > 0) { childIdsByParent.set(id, new Set(childIds)); } } for (const [id, node] of Object.entries(nodes)) { if (typeof node.parentId !== "string") { continue; } const siblings = childIdsByParent.get(node.parentId) ?? new Set(); siblings.add(id); childIdsByParent.set(node.parentId, siblings); } const visited = new Set(); const buildNode = (id: string, path: Set): SceneGraphNode => { const node = nodes[id]; if (!node) { return { id, type: "missing", name: null, parentId: null, missing: true, children: [], }; } const nodeType = typeof node.type === "string" ? node.type : "unknown"; const nodeName = typeof node.name === "string" ? node.name : null; const parentId = typeof node.parentId === "string" ? node.parentId : null; if (path.has(id)) { return { id, type: nodeType, name: nodeName, parentId, cycle: true, children: [], }; } visited.add(id); const nextPath = new Set(path); nextPath.add(id); const childIds = Array.from(childIdsByParent.get(id) ?? []); return { id, type: nodeType, name: nodeName, parentId, children: childIds.map((childId) => buildNode(childId, nextPath)), }; }; const roots = rootNodeIds.map((id) => buildNode(id, new Set())); const detachedNodeIds = Object.keys(nodes).filter((id) => !visited.has(id)); if (detachedNodeIds.length === 0) { return { roots }; } return { roots, detachedNodes: detachedNodeIds.map((id) => buildNode(id, new Set())), }; }; export function SettingsPanel() { const fileInputRef = useRef(null); const nodes = useScene((state) => state.nodes); const rootNodeIds = useScene((state) => state.rootNodeIds); const setScene = useScene((state) => state.setScene); const clearScene = useScene((state) => state.clearScene); const resetSelection = useViewer((state) => state.resetSelection); const exportScene = useViewer((state) => state.exportScene); const setPhase = useEditor((state) => state.setPhase); const activeProject = useProjectStore((state) => state.activeProject); const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false); const sceneGraphValue = useMemo( () => buildSceneGraphValue(nodes as Record, rootNodeIds), [nodes, rootNodeIds], ); const blockSceneGraphMutations = useCallback((event: SyntheticEvent) => { event.preventDefault(); event.stopPropagation(); }, []); const blockSceneGraphDeletion = useCallback( (event: KeyboardEvent) => { if (event.key === "Delete" || event.key === "Backspace") { event.preventDefault(); event.stopPropagation(); } }, [], ); const projectId = activeProject?.id; const isLocalProject = false; // Store only contains cloud projects const handleExport = async () => { if (exportScene) { await exportScene(); } }; const handleSaveBuild = () => { const sceneData = { nodes, rootNodeIds }; const json = JSON.stringify(sceneData, null, 2); const blob = new Blob([json], { type: "application/json" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; const date = new Date().toISOString().split("T")[0]; link.download = `layout_${date}.json`; link.click(); URL.revokeObjectURL(url); }; const handleFileLoad = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = (event) => { try { const data = JSON.parse(event.target?.result as string); if (data.nodes && data.rootNodeIds) { setScene(data.nodes, data.rootNodeIds); resetSelection(); setPhase("site"); } } catch (err) { console.error("Failed to load build:", err); } }; reader.readAsText(file); // Reset input so the same file can be loaded again e.target.value = ""; }; const handleResetToDefault = () => { clearScene(); resetSelection(); setPhase("site"); }; const handleGenerateThumbnail = () => { if (!projectId) return; setIsGeneratingThumbnail(true); emitter.emit('camera-controls:generate-thumbnail', { projectId }); setTimeout(() => setIsGeneratingThumbnail(false), 3000); }; const handleVisibilityChange = async ( field: 'isPrivate' | 'showScansPublic' | 'showGuidesPublic', value: boolean, ) => { if (!projectId) return; // Optimistic update useProjectStore.setState((state) => ({ activeProject: state.activeProject ? { ...state.activeProject, ...(field === 'isPrivate' && { is_private: value }), ...(field === 'showScansPublic' && { show_scans_public: value }), ...(field === 'showGuidesPublic' && { show_guides_public: value }), } : null, })); await updateProjectVisibility(projectId, { [field]: value }); }; return (
{/* Visibility Section (only for cloud projects) */} {projectId && !isLocalProject && (
Public
{activeProject?.is_private ? 'Only you' : 'Anyone'} can view
handleVisibilityChange('isPrivate', !checked)} />
Show 3D Scans
Visible to public viewers
handleVisibilityChange('showScansPublic', checked)} />
Show Floorplans
Visible to public viewers
handleVisibilityChange('showGuidesPublic', checked)} />
Show Grid
Visible only in the editor
state.showGrid)} onCheckedChange={(checked) => useViewer.getState().setShowGrid(checked)} />
)} {/* Export Section */}
{/* Thumbnail Section (only for cloud projects) */} {projectId && !isLocalProject && (
{activeProject?.thumbnail_url && (
{/* eslint-disable-next-line @next/next/no-img-element */} Project thumbnail
)}
)} {/* Save/Load Section */}
{/* Audio Section */}
{/* Keyboard Section */}
{/* Scene Graph */}
Scene Graph
{/* Danger Zone */}
); }