revamp sidebar
This commit is contained in:
@@ -1,89 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// import JsonView from '@uiw/react-json-view'
|
import { useState } from "react";
|
||||||
import { ChevronDown, ChevronRight, Plus, X } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useShallow } from "zustand/shallow";
|
|
||||||
import { IconRail, type PanelId } from "./icon-rail";
|
import { IconRail, type PanelId } from "./icon-rail";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/primitives/button";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/primitives/tooltip";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/primitives/dialog";
|
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
} from "@/components/ui/primitives/sidebar";
|
} from "@/components/ui/primitives/sidebar";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import useEditor from "@/store/use-editor";
|
|
||||||
import { useViewer } from "@pascal-app/viewer";
|
|
||||||
import { SettingsPanel } from "./panels/settings-panel";
|
import { SettingsPanel } from "./panels/settings-panel";
|
||||||
import { SitePanel } from "./panels/site-panel";
|
import { SitePanel } from "./panels/site-panel";
|
||||||
import { ZonePanel } from "./panels/zone-panel";
|
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
// const isHelpOpen = useEditor((state) => state.isHelpOpen);
|
|
||||||
// const setIsHelpOpen = useEditor((state) => state.setIsHelpOpen);
|
|
||||||
// const isJsonInspectorOpen = useEditor((state) => state.isJsonInspectorOpen);
|
|
||||||
// const setIsJsonInspectorOpen = useEditor(
|
|
||||||
// (state) => state.setIsJsonInspectorOpen,
|
|
||||||
// );
|
|
||||||
// const selectedNodeIds = useEditor((state) => state.selectedNodeIds);
|
|
||||||
// const handleDeleteSelected = useEditor((state) => state.handleDeleteSelected);
|
|
||||||
// const serializeLayout = useEditor((state) => state.serializeLayout);
|
|
||||||
const activeTool = useEditor((state) => state.tool);
|
|
||||||
const currentLevelId = useViewer((state) => state.selection.levelId);
|
|
||||||
const setPhase = useEditor((state) => state.setPhase);
|
|
||||||
const setMode = useEditor((state) => state.setMode);
|
|
||||||
const setActiveTool = useEditor((state) => state.setTool);
|
|
||||||
|
|
||||||
const [jsonCollapsed, setJsonCollapsed] = useState<boolean | number>(1);
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
const [activePanel, setActivePanel] = useState<PanelId>("site");
|
const [activePanel, setActivePanel] = useState<PanelId>("site");
|
||||||
|
|
||||||
// Wait for client-side hydration to complete before rendering store-dependent content
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle backspace key to delete selected elements
|
|
||||||
// useEffect(() => {
|
|
||||||
// const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
// if (event.key === "Backspace" && selectedNodeIds.length > 0) {
|
|
||||||
// event.preventDefault();
|
|
||||||
// handleDeleteSelected();
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
// document.addEventListener("keydown", handleKeyDown);
|
|
||||||
// return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
// }, [selectedNodeIds, handleDeleteSelected]);
|
|
||||||
|
|
||||||
// Auto-switch to zones panel when zone tool is active
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTool === "zone") {
|
|
||||||
setActivePanel("zones");
|
|
||||||
}
|
|
||||||
}, [activeTool]);
|
|
||||||
|
|
||||||
const renderPanelContent = () => {
|
const renderPanelContent = () => {
|
||||||
switch (activePanel) {
|
switch (activePanel) {
|
||||||
case "site":
|
case "site":
|
||||||
return <SitePanel />;
|
return <SitePanel />;
|
||||||
case "zones":
|
|
||||||
return <ZonePanel />;
|
|
||||||
// case "collections":
|
|
||||||
// return <CollectionsPanel />;
|
|
||||||
case "settings":
|
case "settings":
|
||||||
return <SettingsPanel />;
|
return <SettingsPanel />;
|
||||||
default:
|
default:
|
||||||
@@ -95,8 +30,6 @@ export function AppSidebar() {
|
|||||||
switch (activePanel) {
|
switch (activePanel) {
|
||||||
case "site":
|
case "site":
|
||||||
return "Site";
|
return "Site";
|
||||||
case "zones":
|
|
||||||
return "Zones";
|
|
||||||
case "collections":
|
case "collections":
|
||||||
return "Collections";
|
return "Collections";
|
||||||
case "settings":
|
case "settings":
|
||||||
@@ -106,84 +39,6 @@ export function AppSidebar() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddZone = () => {
|
|
||||||
if (currentLevelId) {
|
|
||||||
setPhase("structure");
|
|
||||||
setMode("build");
|
|
||||||
setActiveTool("zone");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// const handleAddCollection = () => {
|
|
||||||
// const existingNames = collections.map((c) => c.name);
|
|
||||||
// let counter = 1;
|
|
||||||
// let newName = `Collection ${counter}`;
|
|
||||||
// while (existingNames.includes(newName)) {
|
|
||||||
// counter++;
|
|
||||||
// newName = `Collection ${counter}`;
|
|
||||||
// }
|
|
||||||
// addCollection(newName);
|
|
||||||
// };
|
|
||||||
|
|
||||||
const renderHeaderAction = () => {
|
|
||||||
switch (activePanel) {
|
|
||||||
case "zones":
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
className="size-6 p-0"
|
|
||||||
disabled={!currentLevelId}
|
|
||||||
onClick={handleAddZone}
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{currentLevelId ? "Add new zone" : "Select a level first"}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
// case "collections":
|
|
||||||
// if (addToCollectionState.isActive) {
|
|
||||||
// return (
|
|
||||||
// <Tooltip>
|
|
||||||
// <TooltipTrigger asChild>
|
|
||||||
// <Button
|
|
||||||
// className="size-6 p-0 text-destructive"
|
|
||||||
// onClick={cancelAddToCollection}
|
|
||||||
// size="sm"
|
|
||||||
// variant="ghost"
|
|
||||||
// >
|
|
||||||
// <X className="size-4" />
|
|
||||||
// </Button>
|
|
||||||
// </TooltipTrigger>
|
|
||||||
// <TooltipContent>Cancel</TooltipContent>
|
|
||||||
// </Tooltip>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
// return (
|
|
||||||
// <Tooltip>
|
|
||||||
// <TooltipTrigger asChild>
|
|
||||||
// <Button
|
|
||||||
// className="size-6 p-0"
|
|
||||||
// onClick={handleAddCollection}
|
|
||||||
// size="sm"
|
|
||||||
// variant="ghost"
|
|
||||||
// >
|
|
||||||
// <Plus className="size-4" />
|
|
||||||
// </Button>
|
|
||||||
// </TooltipTrigger>
|
|
||||||
// <TooltipContent>Add new collection</TooltipContent>
|
|
||||||
// </Tooltip>
|
|
||||||
// );
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar className={cn("dark text-white ")} variant="floating">
|
<Sidebar className={cn("dark text-white ")} variant="floating">
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
@@ -194,7 +49,6 @@ export function AppSidebar() {
|
|||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<SidebarHeader className="flex-row items-center justify-between px-3 py-2">
|
<SidebarHeader className="flex-row items-center justify-between px-3 py-2">
|
||||||
<h3 className="font-semibold text-base">{getPanelTitle()}</h3>
|
<h3 className="font-semibold text-base">{getPanelTitle()}</h3>
|
||||||
{renderHeaderAction()}
|
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
||||||
<SidebarContent
|
<SidebarContent
|
||||||
@@ -204,79 +58,6 @@ export function AppSidebar() {
|
|||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dialogs */}
|
|
||||||
{/*
|
|
||||||
<Dialog onOpenChange={setIsJsonInspectorOpen} open={isJsonInspectorOpen}>
|
|
||||||
<DialogContent className="max-h-[80vh] max-w-3xl overflow-auto">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center justify-between">
|
|
||||||
<span>Build Data Inspector</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
className="gap-1"
|
|
||||||
onClick={() => setJsonCollapsed(false)}
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<ChevronDown className="h-3 w-3" />
|
|
||||||
Expand All
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="gap-1"
|
|
||||||
onClick={() => setJsonCollapsed(true)}
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-3 w-3" />
|
|
||||||
Collapse All
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
View the raw JSON structure of your current build
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="mt-4">
|
|
||||||
<JsonView
|
|
||||||
collapsed={jsonCollapsed}
|
|
||||||
style={{
|
|
||||||
fontSize: '12px',
|
|
||||||
}}
|
|
||||||
value={serializeLayout()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog onOpenChange={setIsHelpOpen} open={isHelpOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>House Builder Controls</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
- Click on grid intersections to place walls using the building
|
|
||||||
tools.
|
|
||||||
<br />- Hold spacebar to enable camera controls (orbit, pan,
|
|
||||||
zoom).
|
|
||||||
<br />- Use control modes (Select/Delete/Building) to switch
|
|
||||||
between different interactions.
|
|
||||||
<br />- Create multiple levels and organize your 3D objects and
|
|
||||||
guides within each level.
|
|
||||||
<br />- Click on level names to select them and expand/collapse
|
|
||||||
with the chevron icons.
|
|
||||||
<br />- Upload PNG/JPEG reference images as guides within each
|
|
||||||
level.
|
|
||||||
<br />- Save your build as JSON file for later use or database
|
|
||||||
storage.
|
|
||||||
<br />- Load previously saved builds from JSON files.
|
|
||||||
<br />- Inspect data to view the raw JSON structure of your
|
|
||||||
current build.
|
|
||||||
<br />- Export your 3D model as GLB file.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
*/}
|
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Building2, Layers, Map as MapIcon, Settings } from "lucide-react";
|
import { Building2, Layers, Settings } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "@/components/ui/primitives/tooltip";
|
} from "@/components/ui/primitives/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type PanelId = "site" | "zones" | "collections" | "settings";
|
export type PanelId = "site" | "collections" | "settings";
|
||||||
|
|
||||||
interface IconRailProps {
|
interface IconRailProps {
|
||||||
activePanel: PanelId;
|
activePanel: PanelId;
|
||||||
@@ -18,7 +18,6 @@ interface IconRailProps {
|
|||||||
|
|
||||||
const panels: { id: PanelId; icon: typeof Building2; label: string }[] = [
|
const panels: { id: PanelId; icon: typeof Building2; label: string }[] = [
|
||||||
{ id: "site", icon: Building2, label: "Site" },
|
{ id: "site", icon: Building2, label: "Site" },
|
||||||
{ id: "zones", icon: MapIcon, label: "Zones" },
|
|
||||||
{ id: "collections", icon: Layers, label: "Collections" },
|
{ id: "collections", icon: Layers, label: "Collections" },
|
||||||
{ id: "settings", icon: Settings, label: "Settings" },
|
{ id: "settings", icon: Settings, label: "Settings" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,14 +1,428 @@
|
|||||||
import { useScene } from "@pascal-app/core";
|
import {
|
||||||
|
type BuildingNode,
|
||||||
|
LevelNode,
|
||||||
|
useScene,
|
||||||
|
type ZoneNode,
|
||||||
|
} from "@pascal-app/core";
|
||||||
|
import { useViewer } from "@pascal-app/viewer";
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
ChevronDown,
|
||||||
|
Hexagon,
|
||||||
|
Layers,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import useEditor from "@/store/use-editor";
|
||||||
import { TreeNode } from "./tree-node";
|
import { TreeNode } from "./tree-node";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/primitives/popover";
|
||||||
|
|
||||||
export function SitePanel() {
|
// Preset colors for zones
|
||||||
|
const PRESET_COLORS = [
|
||||||
|
"#3b82f6", // blue
|
||||||
|
"#22c55e", // green
|
||||||
|
"#eab308", // yellow
|
||||||
|
"#f97316", // orange
|
||||||
|
"#ef4444", // red
|
||||||
|
"#a855f7", // purple
|
||||||
|
"#ec4899", // pink
|
||||||
|
"#06b6d4", // cyan
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SITE PHASE VIEW - Simple building buttons
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function SitePhaseView() {
|
||||||
|
const nodes = useScene((state) => state.nodes);
|
||||||
const rootNodeIds = useScene((state) => state.rootNodeIds);
|
const rootNodeIds = useScene((state) => state.rootNodeIds);
|
||||||
|
const selectedBuildingId = useViewer((state) => state.selection.buildingId);
|
||||||
|
const setSelection = useViewer((state) => state.setSelection);
|
||||||
|
|
||||||
|
const buildings = rootNodeIds
|
||||||
|
.map((id) => nodes[id])
|
||||||
|
.filter((node): node is BuildingNode => node?.type === "building");
|
||||||
|
|
||||||
|
if (buildings.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="px-3 py-4 text-sm text-muted-foreground">
|
||||||
|
No buildings yet
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-1">
|
<div className="flex flex-col gap-1 p-2">
|
||||||
{rootNodeIds.map((nodeId) => (
|
{buildings.map((building) => (
|
||||||
<TreeNode key={nodeId} nodeId={nodeId} />
|
<button
|
||||||
|
key={building.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors cursor-pointer",
|
||||||
|
selectedBuildingId === building.id
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-accent/50 hover:bg-accent text-foreground"
|
||||||
|
)}
|
||||||
|
onClick={() => setSelection({ buildingId: building.id })}
|
||||||
|
>
|
||||||
|
<Building2 className="w-4 h-4 shrink-0" />
|
||||||
|
<span className="truncate">{building.name || "Building"}</span>
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// STRUCTURE/FURNISH PHASE VIEW - Building dropdown + Levels + Content
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function BuildingSelector() {
|
||||||
|
const nodes = useScene((state) => state.nodes);
|
||||||
|
const rootNodeIds = useScene((state) => state.rootNodeIds);
|
||||||
|
const selectedBuildingId = useViewer((state) => state.selection.buildingId);
|
||||||
|
const setSelection = useViewer((state) => state.setSelection);
|
||||||
|
|
||||||
|
const buildings = rootNodeIds
|
||||||
|
.map((id) => nodes[id])
|
||||||
|
.filter((node): node is BuildingNode => node?.type === "building");
|
||||||
|
|
||||||
|
const selectedBuilding = selectedBuildingId
|
||||||
|
? (nodes[selectedBuildingId] as BuildingNode)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (buildings.length === 0) return null;
|
||||||
|
|
||||||
|
// If only one building, just show it as a header
|
||||||
|
if (buildings.length === 1) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/50">
|
||||||
|
<Building2 className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium truncate">
|
||||||
|
{buildings[0]?.name || "Building"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button className="flex items-center justify-between w-full px-3 py-2 border-b border-border/50 hover:bg-accent/50 cursor-pointer transition-colors">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium truncate">
|
||||||
|
{selectedBuilding?.name || "Select Building"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="start" className="w-56 p-1">
|
||||||
|
{buildings.map((building) => (
|
||||||
|
<button
|
||||||
|
key={building.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 w-full px-3 py-2 rounded-md text-sm transition-colors cursor-pointer",
|
||||||
|
selectedBuildingId === building.id
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "hover:bg-accent"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setSelection({ buildingId: building.id });
|
||||||
|
// Also select first level if available
|
||||||
|
if (building.children.length > 0) {
|
||||||
|
setSelection({ levelId: building.children[0] as LevelNode["id"] });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Building2 className="w-4 h-4 shrink-0" />
|
||||||
|
<span className="truncate">{building.name || "Building"}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LevelsSection() {
|
||||||
|
const nodes = useScene((state) => state.nodes);
|
||||||
|
const createNode = useScene((state) => state.createNode);
|
||||||
|
const selectedBuildingId = useViewer((state) => state.selection.buildingId);
|
||||||
|
const selectedLevelId = useViewer((state) => state.selection.levelId);
|
||||||
|
const setSelection = useViewer((state) => state.setSelection);
|
||||||
|
|
||||||
|
const building = selectedBuildingId
|
||||||
|
? (nodes[selectedBuildingId] as BuildingNode)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!building) return null;
|
||||||
|
|
||||||
|
const levels = building.children
|
||||||
|
.map((id) => nodes[id])
|
||||||
|
.filter((node): node is LevelNode => node?.type === "level");
|
||||||
|
|
||||||
|
const handleAddLevel = () => {
|
||||||
|
const newLevel = LevelNode.parse({
|
||||||
|
level: levels.length,
|
||||||
|
children: [],
|
||||||
|
parentId: building.id,
|
||||||
|
});
|
||||||
|
createNode(newLevel, building.id);
|
||||||
|
setSelection({ levelId: newLevel.id });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-border/50">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-1.5">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Levels
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="w-5 h-5 flex items-center justify-center rounded hover:bg-accent cursor-pointer"
|
||||||
|
onClick={handleAddLevel}
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Level buttons */}
|
||||||
|
<div className="flex flex-col gap-0.5 px-2 pb-2">
|
||||||
|
{levels.map((level) => (
|
||||||
|
<button
|
||||||
|
key={level.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-2 py-1.5 rounded text-sm transition-colors cursor-pointer",
|
||||||
|
selectedLevelId === level.id
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "hover:bg-accent/50 text-foreground"
|
||||||
|
)}
|
||||||
|
onClick={() => setSelection({ levelId: level.id })}
|
||||||
|
>
|
||||||
|
<Layers className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{level.name || `Level ${level.level}`}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{levels.length === 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground px-2 py-1">
|
||||||
|
No levels yet
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LayerToggle() {
|
||||||
|
const structureLayer = useEditor((state) => state.structureLayer);
|
||||||
|
const setStructureLayer = useEditor((state) => state.setStructureLayer);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 px-3 py-2 border-b border-border/50">
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"flex-1 px-3 py-1.5 rounded text-xs font-medium transition-colors cursor-pointer",
|
||||||
|
structureLayer === "elements"
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-accent/50 hover:bg-accent text-muted-foreground"
|
||||||
|
)}
|
||||||
|
onClick={() => setStructureLayer("elements")}
|
||||||
|
>
|
||||||
|
Elements
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"flex-1 px-3 py-1.5 rounded text-xs font-medium transition-colors cursor-pointer",
|
||||||
|
structureLayer === "zones"
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-accent/50 hover:bg-accent text-muted-foreground"
|
||||||
|
)}
|
||||||
|
onClick={() => setStructureLayer("zones")}
|
||||||
|
>
|
||||||
|
Zones
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ZoneItem({ zone }: { zone: ZoneNode }) {
|
||||||
|
const deleteNode = useScene((state) => state.deleteNode);
|
||||||
|
const updateNode = useScene((state) => state.updateNode);
|
||||||
|
const selectedZoneId = useViewer((state) => state.selection.zoneId);
|
||||||
|
const setSelection = useViewer((state) => state.setSelection);
|
||||||
|
const setPhase = useEditor((state) => state.setPhase);
|
||||||
|
const setMode = useEditor((state) => state.setMode);
|
||||||
|
|
||||||
|
const isSelected = selectedZoneId === zone.id;
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
setSelection({ zoneId: zone.id });
|
||||||
|
setPhase("structure");
|
||||||
|
setMode("select");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteNode(zone.id);
|
||||||
|
if (isSelected) {
|
||||||
|
setSelection({ zoneId: null });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleColorChange = (color: string) => {
|
||||||
|
updateNode(zone.id, { color });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center h-7 cursor-pointer group/row text-sm px-3",
|
||||||
|
isSelected
|
||||||
|
? "text-primary-foreground bg-primary/80 hover:bg-primary/90"
|
||||||
|
: "text-muted-foreground hover:bg-accent/50"
|
||||||
|
)}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
className="mr-2 size-3 shrink-0 rounded-sm border border-border/50 transition-transform hover:scale-110 cursor-pointer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ backgroundColor: zone.color }}
|
||||||
|
/>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="start"
|
||||||
|
className="w-auto p-2"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-4 gap-1">
|
||||||
|
{PRESET_COLORS.map((color) => (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"size-6 rounded-sm border transition-transform hover:scale-110 cursor-pointer",
|
||||||
|
color === zone.color ? "ring-2 ring-primary ring-offset-1" : ""
|
||||||
|
)}
|
||||||
|
key={color}
|
||||||
|
onClick={() => handleColorChange(color)}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<Hexagon className="w-3.5 h-3.5 mr-1.5 shrink-0" />
|
||||||
|
<span className="truncate flex-1">{zone.name}</span>
|
||||||
|
<button
|
||||||
|
className="opacity-0 group-hover/row:opacity-100 w-5 h-5 flex items-center justify-center rounded cursor-pointer hover:bg-primary-foreground/20"
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContentSection() {
|
||||||
|
const nodes = useScene((state) => state.nodes);
|
||||||
|
const selectedLevelId = useViewer((state) => state.selection.levelId);
|
||||||
|
const structureLayer = useEditor((state) => state.structureLayer);
|
||||||
|
const setPhase = useEditor((state) => state.setPhase);
|
||||||
|
const setMode = useEditor((state) => state.setMode);
|
||||||
|
const setTool = useEditor((state) => state.setTool);
|
||||||
|
|
||||||
|
const level = selectedLevelId ? (nodes[selectedLevelId] as LevelNode) : null;
|
||||||
|
|
||||||
|
if (!level) {
|
||||||
|
return (
|
||||||
|
<div className="px-3 py-4 text-sm text-muted-foreground">
|
||||||
|
Select a level to view content
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (structureLayer === "zones") {
|
||||||
|
// Show zones for this level
|
||||||
|
const levelZones = Object.values(nodes).filter(
|
||||||
|
(node): node is ZoneNode =>
|
||||||
|
node.type === "zone" && node.parentId === selectedLevelId
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddZone = () => {
|
||||||
|
setPhase("structure");
|
||||||
|
setMode("build");
|
||||||
|
setTool("zone");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (levelZones.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="px-3 py-4 text-sm text-muted-foreground">
|
||||||
|
No zones on this level.{" "}
|
||||||
|
<button
|
||||||
|
className="text-primary hover:underline cursor-pointer"
|
||||||
|
onClick={handleAddZone}
|
||||||
|
>
|
||||||
|
Add one
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-1">
|
||||||
|
{levelZones.map((zone) => (
|
||||||
|
<ZoneItem key={zone.id} zone={zone} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show elements (walls, items, etc.) for this level
|
||||||
|
if (level.children.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="px-3 py-4 text-sm text-muted-foreground">
|
||||||
|
No elements on this level
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-1">
|
||||||
|
{level.children.map((childId) => (
|
||||||
|
<TreeNode key={childId} nodeId={childId} depth={0} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StructurePhaseView() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<BuildingSelector />
|
||||||
|
<LevelsSection />
|
||||||
|
<LayerToggle />
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<ContentSection />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MAIN SITE PANEL
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function SitePanel() {
|
||||||
|
const phase = useEditor((state) => state.phase);
|
||||||
|
|
||||||
|
if (phase === "site") {
|
||||||
|
return <SitePhaseView />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <StructurePhaseView />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export type CatalogCategory =
|
|||||||
| 'window'
|
| 'window'
|
||||||
| 'door'
|
| 'door'
|
||||||
|
|
||||||
|
export type StructureLayer = 'zones' | 'elements'
|
||||||
|
|
||||||
// Combined tool type
|
// Combined tool type
|
||||||
export type Tool = SiteTool | StructureTool | FurnishTool
|
export type Tool = SiteTool | StructureTool | FurnishTool
|
||||||
|
|
||||||
@@ -48,6 +50,8 @@ type EditorState = {
|
|||||||
setMode: (mode: Mode) => void
|
setMode: (mode: Mode) => void
|
||||||
tool: Tool | null
|
tool: Tool | null
|
||||||
setTool: (tool: Tool | null) => void
|
setTool: (tool: Tool | null) => void
|
||||||
|
structureLayer: StructureLayer
|
||||||
|
setStructureLayer: (layer: StructureLayer) => void
|
||||||
catalogCategory: CatalogCategory | null
|
catalogCategory: CatalogCategory | null
|
||||||
setCatalogCategory: (category: CatalogCategory | null) => void
|
setCatalogCategory: (category: CatalogCategory | null) => void
|
||||||
selectedItem: Asset | null
|
selectedItem: Asset | null
|
||||||
@@ -104,6 +108,8 @@ const useEditor = create<EditorState>()((set, get) => ({
|
|||||||
setMode: (mode) => set({ mode }),
|
setMode: (mode) => set({ mode }),
|
||||||
tool: null,
|
tool: null,
|
||||||
setTool: (tool) => set({ tool }),
|
setTool: (tool) => set({ tool }),
|
||||||
|
structureLayer: 'elements',
|
||||||
|
setStructureLayer: (layer) => set({ structureLayer: layer }),
|
||||||
catalogCategory: null,
|
catalogCategory: null,
|
||||||
setCatalogCategory: (category) => set({ catalogCategory: category }),
|
setCatalogCategory: (category) => set({ catalogCategory: category }),
|
||||||
selectedItem: null,
|
selectedItem: null,
|
||||||
|
|||||||
Reference in New Issue
Block a user