386 lines
11 KiB
TypeScript
386 lines
11 KiB
TypeScript
"use client"
|
|
|
|
import { useState } from "react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
import {
|
|
Eye,
|
|
EyeOff,
|
|
Lock,
|
|
Unlock,
|
|
Plus,
|
|
Trash2,
|
|
ChevronRight,
|
|
ChevronDown,
|
|
Folder,
|
|
Box,
|
|
Circle,
|
|
} from "lucide-react"
|
|
import { usePlannerStore } from "@/lib/store/planner-store"
|
|
import { cn } from "@/lib/utils"
|
|
import type { PlacedObject } from "@/lib/types/planner.types"
|
|
|
|
interface TreeNodeProps {
|
|
obj: PlacedObject
|
|
level: number
|
|
onSelect: (id: string) => void
|
|
isSelected: boolean
|
|
}
|
|
|
|
function TreeNode({ obj, level, onSelect, isSelected }: TreeNodeProps) {
|
|
const [isExpanded, setIsExpanded] = useState(true)
|
|
const {
|
|
getChildObjects,
|
|
toggleVisibility,
|
|
lockObject,
|
|
removeObject,
|
|
ungroupObject,
|
|
selectedElementId,
|
|
selectElement,
|
|
selectPart,
|
|
} = usePlannerStore()
|
|
|
|
const children = getChildObjects(obj.id)
|
|
const hasChildren = children.length > 0 || obj.elements.length > 0
|
|
|
|
return (
|
|
<div>
|
|
{/* Object row */}
|
|
<div
|
|
className={cn(
|
|
"group flex items-center gap-1 py-1 px-2 hover:bg-muted/50 rounded cursor-pointer transition-colors",
|
|
isSelected && "bg-brand-50 border-l-2 border-brand-600",
|
|
!obj.visible && "opacity-50"
|
|
)}
|
|
style={{ paddingLeft: `${level * 16 + 8}px` }}
|
|
onClick={() => onSelect(obj.id)}
|
|
>
|
|
{/* Expand/collapse */}
|
|
{hasChildren ? (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setIsExpanded(!isExpanded)
|
|
}}
|
|
className="p-0.5 hover:bg-muted rounded"
|
|
>
|
|
{isExpanded ? (
|
|
<ChevronDown className="w-3 h-3" />
|
|
) : (
|
|
<ChevronRight className="w-3 h-3" />
|
|
)}
|
|
</button>
|
|
) : (
|
|
<div className="w-4" />
|
|
)}
|
|
|
|
{/* Icon */}
|
|
{obj.isGroup ? (
|
|
<Folder className="w-3.5 h-3.5 text-amber-600" />
|
|
) : (
|
|
<Box className="w-3.5 h-3.5 text-blue-600" />
|
|
)}
|
|
|
|
{/* Name */}
|
|
<span className="flex-1 text-sm truncate">{obj.name}</span>
|
|
|
|
{/* Layer color indicator */}
|
|
<div
|
|
className="w-2 h-2 rounded-full"
|
|
style={{ backgroundColor: obj.layer }}
|
|
/>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
toggleVisibility(obj.id)
|
|
}}
|
|
className="p-1 hover:bg-muted rounded"
|
|
title={obj.visible ? "Hide" : "Show"}
|
|
>
|
|
{obj.visible ? (
|
|
<Eye className="w-3 h-3" />
|
|
) : (
|
|
<EyeOff className="w-3 h-3" />
|
|
)}
|
|
</button>
|
|
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
lockObject(obj.id, !obj.locked)
|
|
}}
|
|
className="p-1 hover:bg-muted rounded"
|
|
title={obj.locked ? "Unlock" : "Lock"}
|
|
>
|
|
{obj.locked ? (
|
|
<Lock className="w-3 h-3" />
|
|
) : (
|
|
<Unlock className="w-3 h-3" />
|
|
)}
|
|
</button>
|
|
|
|
{obj.isGroup && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
ungroupObject(obj.id)
|
|
}}
|
|
className="p-1 hover:bg-muted rounded"
|
|
title="Ungroup"
|
|
>
|
|
<Circle className="w-3 h-3" />
|
|
</button>
|
|
)}
|
|
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
removeObject(obj.id)
|
|
}}
|
|
className="p-1 hover:bg-destructive/10 rounded text-destructive"
|
|
title="Delete"
|
|
>
|
|
<Trash2 className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Children and Elements */}
|
|
{isExpanded && hasChildren && (
|
|
<div>
|
|
{/* Render elements */}
|
|
{obj.elements.map((element) => (
|
|
<div key={element.id}>
|
|
<div
|
|
className={cn(
|
|
"group flex items-center gap-1 py-1 px-2 hover:bg-muted/50 rounded cursor-pointer transition-colors",
|
|
selectedElementId === element.id && "bg-blue-50 border-l-2 border-blue-600",
|
|
!element.visible && "opacity-50"
|
|
)}
|
|
style={{ paddingLeft: `${(level + 1) * 16 + 8}px` }}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
selectElement(obj.id, element.id)
|
|
}}
|
|
>
|
|
<div className="w-4" />
|
|
<Circle className="w-3 h-3 text-blue-500" />
|
|
<span className="flex-1 text-xs truncate">{element.name}</span>
|
|
<span className="text-xs text-muted-foreground">Element</span>
|
|
</div>
|
|
|
|
{/* Render parts */}
|
|
{element.parts.map((part) => (
|
|
<div
|
|
key={part.id}
|
|
className={cn(
|
|
"group flex items-center gap-1 py-0.5 px-2 hover:bg-muted/50 rounded cursor-pointer transition-colors",
|
|
!part.visible && "opacity-50"
|
|
)}
|
|
style={{ paddingLeft: `${(level + 2) * 16 + 8}px` }}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
selectPart(obj.id, element.id, part.id)
|
|
}}
|
|
>
|
|
<div className="w-4" />
|
|
<Circle className="w-2.5 h-2.5 text-purple-500" />
|
|
<span className="flex-1 text-xs truncate">{part.name}</span>
|
|
<span className="text-xs text-muted-foreground">{part.type}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
))}
|
|
|
|
{/* Render child objects */}
|
|
{children.map((child) => (
|
|
<TreeNode
|
|
key={child.id}
|
|
obj={child}
|
|
level={level + 1}
|
|
onSelect={onSelect}
|
|
isSelected={false}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function LayersPanel() {
|
|
const [newLayerName, setNewLayerName] = useState("")
|
|
const [showNewLayerInput, setShowNewLayerInput] = useState(false)
|
|
|
|
const {
|
|
placedObjects,
|
|
layers,
|
|
activeLayerId,
|
|
selectedObjectId,
|
|
selectObject,
|
|
addLayer,
|
|
removeLayer,
|
|
setActiveLayer,
|
|
toggleLayerVisibility,
|
|
toggleLayerLock,
|
|
groupObjects,
|
|
selectedObjectIds,
|
|
} = usePlannerStore()
|
|
|
|
// Get root level objects (no parent)
|
|
const rootObjects = placedObjects.filter((obj) => !obj.parentId)
|
|
|
|
const handleAddLayer = () => {
|
|
if (newLayerName.trim()) {
|
|
addLayer(newLayerName.trim())
|
|
setNewLayerName("")
|
|
setShowNewLayerInput(false)
|
|
}
|
|
}
|
|
|
|
const handleGroupSelected = () => {
|
|
if (selectedObjectIds.length >= 2) {
|
|
groupObjects(selectedObjectIds)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
{/* Layers header */}
|
|
<div className="p-3 border-b space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="font-semibold text-sm">Layers</h3>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => setShowNewLayerInput(true)}
|
|
>
|
|
<Plus className="w-3 h-3" />
|
|
</Button>
|
|
</div>
|
|
|
|
{showNewLayerInput && (
|
|
<div className="flex gap-1">
|
|
<Input
|
|
placeholder="Layer name"
|
|
value={newLayerName}
|
|
onChange={(e) => setNewLayerName(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && handleAddLayer()}
|
|
className="h-7 text-xs"
|
|
autoFocus
|
|
/>
|
|
<Button size="sm" onClick={handleAddLayer} className="h-7">
|
|
Add
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{selectedObjectIds.length >= 2 && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={handleGroupSelected}
|
|
className="w-full"
|
|
>
|
|
<Folder className="w-3 h-3 mr-1" />
|
|
Group Selected ({selectedObjectIds.length})
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Layers list */}
|
|
<div className="border-b">
|
|
{layers.map((layer) => (
|
|
<div
|
|
key={layer.id}
|
|
className={cn(
|
|
"flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer transition-colors border-l-2",
|
|
activeLayerId === layer.id ? "border-brand-600 bg-brand-50" : "border-transparent"
|
|
)}
|
|
onClick={() => setActiveLayer(layer.id)}
|
|
>
|
|
<div
|
|
className="w-3 h-3 rounded"
|
|
style={{ backgroundColor: layer.color }}
|
|
/>
|
|
<span className="flex-1 text-sm font-medium">{layer.name}</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{layer.objectIds.length}
|
|
</span>
|
|
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
toggleLayerVisibility(layer.id)
|
|
}}
|
|
className="p-1 hover:bg-muted rounded"
|
|
>
|
|
{layer.visible ? (
|
|
<Eye className="w-3 h-3" />
|
|
) : (
|
|
<EyeOff className="w-3 h-3" />
|
|
)}
|
|
</button>
|
|
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
toggleLayerLock(layer.id)
|
|
}}
|
|
className="p-1 hover:bg-muted rounded"
|
|
>
|
|
{layer.locked ? (
|
|
<Lock className="w-3 h-3" />
|
|
) : (
|
|
<Unlock className="w-3 h-3" />
|
|
)}
|
|
</button>
|
|
|
|
{layer.id !== "default" && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
removeLayer(layer.id)
|
|
}}
|
|
className="p-1 hover:bg-destructive/10 rounded text-destructive"
|
|
>
|
|
<Trash2 className="w-3 h-3" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Objects tree */}
|
|
<div className="flex-1 overflow-hidden">
|
|
<ScrollArea className="h-full">
|
|
<div className="p-2 space-y-0.5">
|
|
{rootObjects.length === 0 ? (
|
|
<div className="text-center py-8 text-muted-foreground text-xs">
|
|
No objects in scene.
|
|
<br />
|
|
Drag products from the sidebar!
|
|
</div>
|
|
) : (
|
|
rootObjects.map((obj) => (
|
|
<TreeNode
|
|
key={obj.id}
|
|
obj={obj}
|
|
level={0}
|
|
onSelect={selectObject}
|
|
isSelected={selectedObjectId === obj.id}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|