revamp sidebar

This commit is contained in:
wass08
2026-01-26 08:03:14 +09:00
parent 192a58c47e
commit b86537066f
4 changed files with 428 additions and 228 deletions
@@ -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 />;
}
+6
View File
@@ -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,