From bc57afb8faa8b18539d6db340dad90314fd3ae8e Mon Sep 17 00:00:00 2001 From: wass08 Date: Thu, 5 Mar 2026 16:39:49 +0100 Subject: [PATCH] collections + unify color picker --- .../components/tools/zone/zone-tool.tsx | 15 +- .../collections/collections-popover.tsx | 295 ++++++++++++++++++ .../components/ui/panels/item-panel.tsx | 19 +- .../components/ui/primitives/color-dot.tsx | 58 ++++ .../ui/sidebar/panels/site-panel/index.tsx | 47 +-- .../panels/site-panel/zone-tree-node.tsx | 10 +- .../ui/sidebar/panels/zone-panel/index.tsx | 50 +-- packages/core/src/schema/collections.ts | 14 + packages/core/src/schema/index.ts | 2 + packages/core/src/schema/nodes/item.ts | 4 + .../core/src/store/actions/node-actions.ts | 16 +- packages/core/src/store/use-scene.ts | 112 ++++++- 12 files changed, 526 insertions(+), 116 deletions(-) create mode 100644 apps/editor/components/ui/panels/collections/collections-popover.tsx create mode 100644 apps/editor/components/ui/primitives/color-dot.tsx create mode 100644 packages/core/src/schema/collections.ts diff --git a/apps/editor/components/tools/zone/zone-tool.tsx b/apps/editor/components/tools/zone/zone-tool.tsx index 51344563..62ae22f1 100644 --- a/apps/editor/components/tools/zone/zone-tool.tsx +++ b/apps/editor/components/tools/zone/zone-tool.tsx @@ -5,18 +5,7 @@ import { BufferGeometry, DoubleSide, type Line, type Group, Shape, Vector3 } fro import { EDITOR_LAYER } from "@/lib/constants"; import useEditor from "@/store/use-editor"; import { CursorSphere } from "../shared/cursor-sphere"; - -// Zone colors for cycling through -const ZONE_COLORS = [ - "#3b82f6", // blue - "#ef4444", // red - "#22c55e", // green - "#f59e0b", // amber - "#8b5cf6", // violet - "#06b6d4", // cyan - "#ec4899", // pink - "#84cc16", // lime -]; +import { PALETTE_COLORS } from "@/components/ui/primitives/color-dot"; const Y_OFFSET = 0.02; @@ -73,7 +62,7 @@ const commitZoneDrawing = ( const name = `Zone ${zoneCount + 1}`; // Cycle through colors - const color = ZONE_COLORS[zoneCount % ZONE_COLORS.length]; + const color = PALETTE_COLORS[zoneCount % PALETTE_COLORS.length]; const zone = ZoneNode.parse({ name, diff --git a/apps/editor/components/ui/panels/collections/collections-popover.tsx b/apps/editor/components/ui/panels/collections/collections-popover.tsx new file mode 100644 index 00000000..6fe2b7ba --- /dev/null +++ b/apps/editor/components/ui/panels/collections/collections-popover.tsx @@ -0,0 +1,295 @@ +'use client' + +import type { AnyNodeId, Collection, CollectionId } from '@pascal-app/core' +import { useScene } from '@pascal-app/core' +import { Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Pencil, Plus, Trash2, X } from 'lucide-react' +import { useState } from 'react' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/primitives/dropdown-menu' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/primitives/popover' +import { ColorDot } from '@/components/ui/primitives/color-dot' +import { cn } from '@/lib/utils' + +interface CollectionsPopoverProps { + nodeId: AnyNodeId + collectionIds?: CollectionId[] + children: React.ReactNode +} + +export function CollectionsPopover({ nodeId, collectionIds, children }: CollectionsPopoverProps) { + const collections = useScene((s) => s.collections) + const nodes = useScene((s) => s.nodes) + const createCollection = useScene((s) => s.createCollection) + const deleteCollection = useScene((s) => s.deleteCollection) + const updateCollection = useScene((s) => s.updateCollection) + const addToCollection = useScene((s) => s.addToCollection) + const removeFromCollection = useScene((s) => s.removeFromCollection) + + const [open, setOpen] = useState(false) + const [showCreateInput, setShowCreateInput] = useState(false) + const [createName, setCreateName] = useState('') + + const [renamingId, setRenamingId] = useState(null) + const [renameValue, setRenameValue] = useState('') + const [renameColor, setRenameColor] = useState('') + + const [deletingId, setDeletingId] = useState(null) + const [expandedIds, setExpandedIds] = useState>(new Set()) + + const memberIds = collectionIds ?? [] + const allCollections = Object.values(collections) + + const handleCreate = () => { + if (!createName.trim()) return + createCollection(createName.trim(), [nodeId]) + setCreateName('') + setShowCreateInput(false) + } + + const handleRenameConfirm = (id: CollectionId) => { + if (!renameValue.trim()) return + updateCollection(id, { name: renameValue.trim(), color: renameColor || undefined }) + setRenamingId(null) + } + + const toggleMembership = (collectionId: CollectionId) => { + if (memberIds.includes(collectionId)) { + removeFromCollection(collectionId, nodeId) + } else { + addToCollection(collectionId, nodeId) + } + } + + const toggleExpand = (collectionId: CollectionId) => { + setExpandedIds((prev) => { + const next = new Set(prev) + if (next.has(collectionId)) next.delete(collectionId) + else next.add(collectionId) + return next + }) + } + + return ( + + {children} + + {/* Header */} +
+
+ + Collections +
+ +
+ + {/* Create input */} + {showCreateInput && ( +
+ setCreateName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleCreate() + if (e.key === 'Escape') { setShowCreateInput(false); setCreateName('') } + }} + placeholder="Collection name…" + className="flex-1 min-w-0 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground/60 outline-none focus:border-ring focus:ring-1 focus:ring-ring/30" + /> + + +
+ )} + + {/* Collections list */} +
+ {allCollections.length === 0 ? ( +
+ +

+ No collections yet. Create one to group items together. +

+
+ ) : ( +
    + {allCollections.map((collection) => { + const isIn = memberIds.includes(collection.id) + const isExpanded = expandedIds.has(collection.id) + const isRenaming = renamingId === collection.id + const isDeleting = deletingId === collection.id + + if (isDeleting) { + return ( +
  • + Delete "{collection.name}"? +
    + + +
    +
  • + ) + } + + if (isRenaming) { + return ( +
  • + + setRenameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleRenameConfirm(collection.id) + if (e.key === 'Escape') setRenamingId(null) + }} + className="flex-1 min-w-0 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-xs text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring/30" + /> + + +
  • + ) + } + + return ( +
  • +
    + {/* Color dot — click to pick color */} + updateCollection(collection.id, { color: c })} + /> + + {/* Name + count — clicking toggles membership */} + + + {/* Membership check */} +
    + {isIn && } +
    + + {/* Expand toggle (only if has members) */} + {collection.nodeIds.length > 0 && ( + + )} + + {/* More dropdown */} + + + + + + { setRenamingId(collection.id); setRenameValue(collection.name); setRenameColor(collection.color ?? '') }}> + + Rename + + setDeletingId(collection.id)}> + + Delete + + + +
    + + {/* Expanded member list */} + {isExpanded && ( +
      + {collection.nodeIds.map((nid) => { + const n = nodes[nid] + return ( +
    • + + + {n?.name ?? nid} + +
    • + ) + })} +
    + )} +
  • + ) + })} +
+ )} +
+
+
+ ) +} diff --git a/apps/editor/components/ui/panels/item-panel.tsx b/apps/editor/components/ui/panels/item-panel.tsx index 2bf67a76..8bbf4d03 100644 --- a/apps/editor/components/ui/panels/item-panel.tsx +++ b/apps/editor/components/ui/panels/item-panel.tsx @@ -11,8 +11,9 @@ import { cn } from '@/lib/utils' import { PanelWrapper } from './panel-wrapper' import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' -import { MetricControl } from '../controls/metric-control' + import { ActionButton, ActionGroup } from '../controls/action-button' +import { CollectionsPopover } from './collections/collections-popover' export function ItemPanel() { const selectedIds = useViewer((s) => s.selection.selectedIds) @@ -231,14 +232,22 @@ export function ItemPanel() { + + + + + + + + } label="Move" onClick={handleMove} /> } label="Duplicate" onClick={handleDuplicate} /> - } - label="Delete" - onClick={handleDelete} + } + label="Delete" + onClick={handleDelete} className="hover:bg-red-500/20" /> diff --git a/apps/editor/components/ui/primitives/color-dot.tsx b/apps/editor/components/ui/primitives/color-dot.tsx new file mode 100644 index 00000000..8d21fd59 --- /dev/null +++ b/apps/editor/components/ui/primitives/color-dot.tsx @@ -0,0 +1,58 @@ +'use client' + +import { useState } from 'react' +import { Popover, PopoverContent, PopoverTrigger } from './popover' +import { cn } from '@/lib/utils' + +export const PALETTE_COLORS = [ + '#ef4444', // Red 0° + '#f97316', // Orange 30° + '#f59e0b', // Amber 45° + '#84cc16', // Lime 85° + '#22c55e', // Green 142° + '#10b981', // Emerald 160° + '#06b6d4', // Cyan 190° + '#3b82f6', // Blue 217° + '#6366f1', // Indigo 239° + '#a855f7', // Violet 270° + '#64748b', // Dark gray + '#cccccc', // Light gray +] + +interface ColorDotProps { + color: string + onChange: (color: string) => void +} + +export function ColorDot({ color, onChange }: ColorDotProps) { + const [open, setOpen] = useState(false) + + return ( + + +