168 lines
6.3 KiB
TypeScript
168 lines
6.3 KiB
TypeScript
import { emitter, useScene, type ZoneNode } from "@pascal-app/core";
|
|
import { useViewer } from "@pascal-app/viewer";
|
|
import { Camera, Hexagon, Trash2 } from "lucide-react";
|
|
import { useState } from "react";
|
|
import { cn } from "@/lib/utils";
|
|
import useEditor from "@/store/use-editor";
|
|
import { ColorDot } from "@/components/ui/primitives/color-dot";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/primitives/popover";
|
|
|
|
function ZoneItem({ zone }: { zone: ZoneNode }) {
|
|
const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false);
|
|
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 isSelected = selectedZoneId === zone.id;
|
|
|
|
const handleClick = () => {
|
|
setSelection({ zoneId: zone.id });
|
|
};
|
|
|
|
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-8 cursor-pointer group/row text-sm px-2 mx-1 mb-0.5 select-none rounded-lg border transition-all duration-200",
|
|
isSelected
|
|
? "bg-white dark:bg-accent/50 border-neutral-200/60 dark:border-border/50 shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] ring-1 ring-white/50 dark:ring-white/10 ring-inset text-foreground"
|
|
: "border-transparent text-muted-foreground hover:bg-white/40 dark:hover:bg-accent/30 hover:border-neutral-200/50 dark:hover:border-border/40 hover:text-foreground"
|
|
)}
|
|
onClick={handleClick}
|
|
>
|
|
<span className="mr-2">
|
|
<ColorDot color={zone.color} onChange={handleColorChange} />
|
|
</span>
|
|
<Hexagon className="w-3.5 h-3.5 mr-1.5 shrink-0" />
|
|
<span className="truncate flex-1">{zone.name}</span>
|
|
{/* Camera snapshot button */}
|
|
<Popover open={cameraPopoverOpen} onOpenChange={setCameraPopoverOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
className="relative opacity-0 group-hover/row:opacity-100 w-6 h-6 flex items-center justify-center rounded-md cursor-pointer hover:bg-black/5 dark:hover:bg-white/10 text-muted-foreground hover:text-foreground transition-colors"
|
|
onClick={(e) => e.stopPropagation()}
|
|
title="Camera snapshot"
|
|
>
|
|
<Camera className="w-3 h-3" />
|
|
{zone.camera && (
|
|
<span className="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-primary" />
|
|
)}
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
side="right"
|
|
align="start"
|
|
className="w-auto p-1"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex flex-col gap-0.5">
|
|
{zone.camera && (
|
|
<button
|
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
emitter.emit("camera-controls:view", { nodeId: zone.id });
|
|
setCameraPopoverOpen(false);
|
|
}}
|
|
>
|
|
<Camera className="w-3.5 h-3.5" />
|
|
View snapshot
|
|
</button>
|
|
)}
|
|
<button
|
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
emitter.emit("camera-controls:capture", { nodeId: zone.id });
|
|
setCameraPopoverOpen(false);
|
|
}}
|
|
>
|
|
<Camera className="w-3.5 h-3.5" />
|
|
{zone.camera ? "Update snapshot" : "Take snapshot"}
|
|
</button>
|
|
{zone.camera && (
|
|
<button
|
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-destructive hover:text-destructive-foreground text-left w-full"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
updateNode(zone.id, { camera: undefined });
|
|
setCameraPopoverOpen(false);
|
|
}}
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
Clear snapshot
|
|
</button>
|
|
)}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
<button
|
|
className="opacity-0 group-hover/row:opacity-100 w-6 h-6 flex items-center justify-center rounded-md cursor-pointer hover:bg-black/5 dark:hover:bg-white/10 text-muted-foreground hover:text-foreground transition-colors"
|
|
onClick={handleDelete}
|
|
>
|
|
<Trash2 className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ZonePanel() {
|
|
const nodes = useScene((state) => state.nodes);
|
|
const currentLevelId = useViewer((state) => state.selection.levelId);
|
|
const setPhase = useEditor((state) => state.setPhase);
|
|
const setMode = useEditor((state) => state.setMode);
|
|
const setTool = useEditor((state) => state.setTool);
|
|
|
|
// Filter nodes to get zones for the current level
|
|
const levelZones = Object.values(nodes).filter(
|
|
(node): node is ZoneNode =>
|
|
node.type === "zone" && node.parentId === currentLevelId
|
|
);
|
|
|
|
const handleAddZone = () => {
|
|
if (currentLevelId) {
|
|
setPhase("structure");
|
|
setMode("build");
|
|
setTool("zone");
|
|
}
|
|
};
|
|
|
|
if (!currentLevelId) {
|
|
return (
|
|
<div className="px-3 py-4 text-sm text-muted-foreground">
|
|
Select a level to view and create zones
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="py-1">
|
|
{levelZones.length === 0 ? (
|
|
<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>
|
|
) : (
|
|
levelZones.map((zone) => <ZoneItem key={zone.id} zone={zone} />)
|
|
)}
|
|
</div>
|
|
);
|
|
}
|