collections + unify color picker

This commit is contained in:
wass08
2026-03-06 13:08:23 +01:00
parent 8f65daa853
commit bc57afb8fa
12 changed files with 526 additions and 116 deletions
@@ -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,
@@ -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<CollectionId | null>(null)
const [renameValue, setRenameValue] = useState('')
const [renameColor, setRenameColor] = useState('')
const [deletingId, setDeletingId] = useState<CollectionId | null>(null)
const [expandedIds, setExpandedIds] = useState<Set<CollectionId>>(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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent
side="left"
align="start"
sideOffset={8}
className="w-72 p-0 border-border/50 bg-sidebar/95 backdrop-blur-xl shadow-2xl rounded-xl overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2.5 border-b border-border/50">
<div className="flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-semibold text-foreground tracking-tight">Collections</span>
</div>
<button
type="button"
onClick={() => { setShowCreateInput((v) => !v); setCreateName('') }}
className="flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium text-muted-foreground hover:text-foreground hover:bg-white/10 transition-colors"
>
<Plus className="h-3 w-3" />
New
</button>
</div>
{/* Create input */}
{showCreateInput && (
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border/50 bg-white/5">
<input
autoFocus
value={createName}
onChange={(e) => 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"
/>
<button
type="button"
disabled={!createName.trim()}
onClick={handleCreate}
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 hover:bg-primary/30 text-primary disabled:opacity-40 transition-colors"
>
<Check className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => { setShowCreateInput(false); setCreateName('') }}
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10 text-muted-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
{/* Collections list */}
<div className="max-h-72 overflow-y-auto no-scrollbar">
{allCollections.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center px-4">
<Layers className="h-6 w-6 text-muted-foreground/40" />
<p className="text-xs text-muted-foreground">
No collections yet. Create one to group items together.
</p>
</div>
) : (
<ul className="divide-y divide-border/30">
{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 (
<li key={collection.id} className="flex items-center justify-between gap-2 px-3 py-2.5 bg-red-500/10">
<span className="text-xs text-foreground/80 truncate">Delete "{collection.name}"?</span>
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={() => { deleteCollection(collection.id); setDeletingId(null) }}
className="rounded-md px-2 py-0.5 text-[11px] font-medium bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors"
>
Delete
</button>
<button
type="button"
onClick={() => setDeletingId(null)}
className="rounded-md px-2 py-0.5 text-[11px] font-medium hover:bg-white/10 text-muted-foreground transition-colors"
>
Cancel
</button>
</div>
</li>
)
}
if (isRenaming) {
return (
<li key={collection.id} className="flex items-center gap-1.5 px-3 py-2">
<ColorDot color={renameColor || '#6366f1'} onChange={setRenameColor} />
<input
autoFocus
value={renameValue}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => handleRenameConfirm(collection.id)}
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 hover:bg-primary/30 text-primary transition-colors"
>
<Check className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => setRenamingId(null)}
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10 text-muted-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</li>
)
}
return (
<li key={collection.id}>
<div className="group flex items-center gap-2 px-3 py-2 hover:bg-white/5 transition-colors">
{/* Color dot — click to pick color */}
<ColorDot
color={collection.color ?? '#6366f1'}
onChange={(c) => updateCollection(collection.id, { color: c })}
/>
{/* Name + count — clicking toggles membership */}
<button
type="button"
onClick={() => toggleMembership(collection.id)}
className="flex-1 min-w-0 flex items-center gap-1.5 text-left"
>
<span className={cn('truncate text-xs font-medium', isIn ? 'text-foreground' : 'text-muted-foreground')}>
{collection.name}
</span>
<span className="shrink-0 text-[10px] text-muted-foreground/60">
{collection.nodeIds.length}
</span>
</button>
{/* Membership check */}
<div
className={cn(
'flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors pointer-events-none',
isIn ? 'border-primary bg-primary/20 text-primary' : 'border-border/50',
)}
>
{isIn && <Check className="h-2.5 w-2.5" />}
</div>
{/* Expand toggle (only if has members) */}
{collection.nodeIds.length > 0 && (
<button
type="button"
onClick={() => toggleExpand(collection.id)}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground transition-colors"
>
{isExpanded
? <ChevronDown className="h-3 w-3" />
: <ChevronRight className="h-3 w-3" />}
</button>
)}
{/* More dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-white/10 transition-colors opacity-0 group-hover:opacity-100"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="left" align="start" className="min-w-40">
<DropdownMenuItem onClick={() => { setRenamingId(collection.id); setRenameValue(collection.name); setRenameColor(collection.color ?? '') }}>
<Pencil className="h-3.5 w-3.5" />
Rename
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={() => setDeletingId(collection.id)}>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Expanded member list */}
{isExpanded && (
<ul className="pb-1 pl-6 pr-3 flex flex-col gap-0.5">
{collection.nodeIds.map((nid) => {
const n = nodes[nid]
return (
<li key={nid} className="flex items-center gap-1.5 py-0.5">
<span className="h-1 w-1 rounded-full bg-muted-foreground/40 shrink-0" />
<span className={cn('truncate text-[11px]', nid === nodeId ? 'text-foreground font-medium' : 'text-muted-foreground')}>
{n?.name ?? nid}
</span>
</li>
)
})}
</ul>
)}
</li>
)
})}
</ul>
)}
</div>
</PopoverContent>
</Popover>
)
}
@@ -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() {
</div>
</PanelSection>
<PanelSection title="Collections">
<ActionGroup>
<CollectionsPopover nodeId={selectedId as AnyNode['id']} collectionIds={node.collectionIds}>
<ActionButton label="Manage collections…" />
</CollectionsPopover>
</ActionGroup>
</PanelSection>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
<ActionButton icon={<Copy className="h-3.5 w-3.5" />} label="Duplicate" onClick={handleDuplicate} />
<ActionButton
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
label="Delete"
onClick={handleDelete}
<ActionButton
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
label="Delete"
onClick={handleDelete}
className="hover:bg-red-500/20"
/>
</ActionGroup>
@@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="relative shrink-0 h-3 w-3 rounded-sm border border-border/50 cursor-pointer hover:ring-1 hover:ring-ring/50 transition-all"
style={{ backgroundColor: color }}
onClick={(e) => e.stopPropagation()}
/>
</PopoverTrigger>
<PopoverContent side="left" align="center" sideOffset={6} className="w-auto p-1.5">
<div className="grid grid-cols-4 gap-1">
{PALETTE_COLORS.map((c) => (
<button
key={c}
type="button"
className={cn(
'h-5 w-5 rounded-sm border transition-transform hover:scale-110',
c === color ? 'border-foreground/50 ring-1 ring-ring/50' : 'border-border/30',
)}
style={{ backgroundColor: c }}
onClick={() => { onChange(c); setOpen(false) }}
/>
))}
</div>
</PopoverContent>
</Popover>
)
}
@@ -41,18 +41,7 @@ import {
PopoverTrigger,
} from "@/components/ui/primitives/popover";
import { motion, AnimatePresence, LayoutGroup } from "motion/react";
// Preset colors for zones
const PRESET_COLORS = [
"#3b82f6", // blue
"#22c55e", // green
"#eab308", // yellow
"#f97316", // orange
"#ef4444", // red
"#a855f7", // purple
"#ec4899", // pink
"#06b6d4", // cyan
];
import { ColorDot } from "@/components/ui/primitives/color-dot";
// ============================================================================
// PROPERTY LINE SECTION
@@ -953,37 +942,9 @@ function ZoneItem({ zone, isLast }: { zone: ZoneNode, isLast?: boolean }) {
{/* Horizontal branch line */}
<div className="absolute top-1/2 h-px bg-border/50 pointer-events-none" style={{ left: 8, width: 4 }} />
<Popover>
<PopoverTrigger asChild>
<button
className={cn(
"mr-2 size-3 shrink-0 rounded-sm border border-border/50 transition-all hover:scale-110 cursor-pointer",
!isSelected && "opacity-40"
)}
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>
<span className={cn("mr-2", !isSelected && "opacity-40")}>
<ColorDot color={zone.color} onChange={handleColorChange} />
</span>
<div className="flex-1 min-w-0 pr-1">
<InlineRenameInput
node={zone}
@@ -1,6 +1,7 @@
import { ZoneNode } from "@pascal-app/core";
import { ZoneNode, useScene } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import { useState } from "react";
import { ColorDot } from "@/components/ui/primitives/color-dot";
import { InlineRenameInput } from "./inline-rename-input";
import { TreeNodeWrapper } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions";
@@ -13,6 +14,7 @@ interface ZoneTreeNodeProps {
export function ZoneTreeNode({ node, depth, isLast }: ZoneTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false);
const updateNode = useScene((state) => state.updateNode);
const isSelected = useViewer((state) => state.selection.zoneId === node.id);
const isHovered = useViewer((state) => state.hoveredId === node.id);
const setSelection = useViewer((state) => state.setSelection);
@@ -41,9 +43,9 @@ export function ZoneTreeNode({ node, depth, isLast }: ZoneTreeNodeProps) {
return (
<TreeNodeWrapper
icon={
<div
className="w-3 h-3 rounded-sm border border-border/50"
style={{ backgroundColor: node.color }}
<ColorDot
color={node.color}
onChange={(color) => updateNode(node.id, { color })}
/>
}
label={
@@ -4,23 +4,8 @@ import { Camera, Hexagon, Trash2 } from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/utils";
import useEditor from "@/store/use-editor";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/primitives/popover";
// Preset colors for zones
const PRESET_COLORS = [
"#3b82f6", // blue
"#22c55e", // green
"#eab308", // yellow
"#f97316", // orange
"#ef4444", // red
"#a855f7", // purple
"#ec4899", // pink
"#06b6d4", // cyan
];
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);
@@ -57,34 +42,9 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
)}
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>
<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 */}