diff --git a/apps/editor/app/viewer/[id]/collections-panel.tsx b/apps/editor/app/viewer/[id]/collections-panel.tsx new file mode 100644 index 00000000..f5a68c06 --- /dev/null +++ b/apps/editor/app/viewer/[id]/collections-panel.tsx @@ -0,0 +1,300 @@ +'use client' + +import { + type AnyNodeId, + type CollectionId, + type Control, + type ControlValue, + type ItemNode, + useInteractive, + useScene, +} from '@pascal-app/core' +import { ChevronDown, ChevronRight } from 'lucide-react' +import { useMemo, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { cn } from '@/lib/utils' + +// ─── Shared control derivation ─────────────────────────────────────────────── + +type ItemControlRef = { itemId: AnyNodeId; controlIndex: number } + +type SharedControlDef = { + kind: 'toggle' | 'slider' | 'temperature' + label?: string + min?: number + max?: number + step?: number + unit?: string + refs: ItemControlRef[] +} + +function deriveSharedControls(items: ItemNode[]): SharedControlDef[] { + if (items.length === 0) return [] + const result: SharedControlDef[] = [] + + for (const kind of ['toggle', 'slider', 'temperature'] as const) { + const refs: ItemControlRef[] = [] + let ref: Control | null = null + let allHave = true + + for (const item of items) { + const idx = item.asset.interactive!.controls.findIndex((c) => c.kind === kind) + if (idx === -1) { allHave = false; break } + refs.push({ itemId: item.id, controlIndex: idx }) + if (!ref) ref = item.asset.interactive!.controls[idx]! + } + + if (!allHave || !ref) continue + + const def: SharedControlDef = { kind, label: ref.label, refs } + if ('min' in ref) { def.min = ref.min; def.max = ref.max } + if ('step' in ref) def.step = (ref as { step?: number }).step + if ('unit' in ref) def.unit = (ref as { unit?: string }).unit + result.push(def) + } + + return result +} + +// ─── Shared control widget ─────────────────────────────────────────────────── + +function SharedWidget({ def, value, onChange }: { def: SharedControlDef; value: ControlValue; onChange: (v: ControlValue) => void }) { + if (def.kind === 'toggle') { + return ( + + ) + } + + return ( +
+
+ {def.label ?? def.kind} + {value}{def.kind === 'temperature' ? '°' : ''}{def.unit ? ` ${def.unit}` : ''} +
+ onChange(Number(e.target.value))} + onPointerDown={(e) => e.stopPropagation()} + className="w-full accent-white" + /> +
+ ) +} + +// ─── Individual item control widget ────────────────────────────────────────── + +function ItemWidget({ control, value, onChange }: { control: Control; value: ControlValue; onChange: (v: ControlValue) => void }) { + if (control.kind === 'toggle') { + return ( + + ) + } + + return ( +
+
+ {control.label ?? control.kind} + {value}{control.kind === 'temperature' ? '°' : ''}{'unit' in control && control.unit ? ` ${control.unit}` : ''} +
+ onChange(Number(e.target.value))} + onPointerDown={(e) => e.stopPropagation()} + className="w-full accent-white" + /> +
+ ) +} + +// ─── Collection row ─────────────────────────────────────────────────────────── + +function CollectionRow({ collectionId }: { collectionId: CollectionId }) { + const collection = useScene((s) => s.collections[collectionId]) + + const interactiveItems = useScene( + useShallow((s) => + (collection?.nodeIds ?? []) + .map((id) => s.nodes[id]) + .filter((n): n is ItemNode => n?.type === 'item' && !!n.asset.interactive) + ), + ) + + const allItems = useInteractive((s) => s.items) + const controlValuesByItem = useMemo( + () => Object.fromEntries(interactiveItems.map((n) => [n.id, allItems[n.id]?.controlValues ?? []])), + [allItems, interactiveItems], + ) + + const setControlValue = useInteractive((s) => s.setControlValue) + + const [expanded, setExpanded] = useState(false) + const [expandedItemIds, setExpandedItemIds] = useState>(new Set()) + + if (!collection) return null + + const sharedControls = deriveSharedControls(interactiveItems) + + const getSharedValue = (def: SharedControlDef): ControlValue => { + if (def.kind === 'toggle') { + return def.refs.every(({ itemId, controlIndex }) => Boolean(controlValuesByItem[itemId]?.[controlIndex])) + } + const first = def.refs[0]! + return controlValuesByItem[first.itemId]?.[first.controlIndex] ?? 0 + } + + const setSharedValue = (def: SharedControlDef, value: ControlValue) => { + for (const { itemId, controlIndex } of def.refs) { + setControlValue(itemId, controlIndex, value) + } + } + + const toggleItemExpand = (id: AnyNodeId) => { + setExpandedItemIds((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + return ( +
+ {/* Header */} + + + {/* Expanded */} + {expanded && ( +
+ {interactiveItems.length === 0 ? ( +

No interactive items.

+ ) : ( + <> + {/* Shared controls */} + {sharedControls.length > 0 && ( +
+

All

+
+ {sharedControls.map((def, i) => ( + setSharedValue(def, v)} + /> + ))} +
+
+ )} + + {/* Individual items */} + {interactiveItems.map((item) => { + const isItemExpanded = expandedItemIds.has(item.id) + const controls = item.asset.interactive!.controls + const values = controlValuesByItem[item.id] ?? [] + + return ( +
+ + + {isItemExpanded && ( +
+ {controls.map((control, i) => ( + setControlValue(item.id, i, v)} + /> + ))} +
+ )} +
+ ) + })} + + )} +
+ )} +
+ ) +} + +// ─── Main panel ─────────────────────────────────────────────────────────────── + +export function CollectionsPanel() { + const collectionIds = useScene( + useShallow((s) => Object.keys(s.collections) as CollectionId[]), + ) + + if (collectionIds.length === 0) return null + + return ( +
+
+ Collections +
+
+ {collectionIds.map((id) => ( + + ))} +
+
+ ) +} diff --git a/apps/editor/app/viewer/[id]/page.tsx b/apps/editor/app/viewer/[id]/page.tsx index bc173276..f643a9cd 100644 --- a/apps/editor/app/viewer/[id]/page.tsx +++ b/apps/editor/app/viewer/[id]/page.tsx @@ -1,7 +1,7 @@ 'use client' import { initSpatialGridSync, useScene } from '@pascal-app/core' -import { useViewer, Viewer } from '@pascal-app/viewer' +import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer' import Link from 'next/link' import { useParams } from 'next/navigation' import { useEffect, useState } from 'react' @@ -180,6 +180,7 @@ export default function ViewerPage() { + diff --git a/apps/editor/app/viewer/[id]/viewer-overlay.tsx b/apps/editor/app/viewer/[id]/viewer-overlay.tsx index b2c06bb1..03ff3721 100644 --- a/apps/editor/app/viewer/[id]/viewer-overlay.tsx +++ b/apps/editor/app/viewer/[id]/viewer-overlay.tsx @@ -26,6 +26,7 @@ import type { ProjectOwner } from '@/features/community/lib/projects/types' import { ActionButton } from '@/components/ui/action-menu/action-button' import { TooltipProvider } from '@/components/ui/primitives/tooltip' import { emitter } from '@pascal-app/core' +import { CollectionsPanel } from './collections-panel' const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = { stacked: 'Stacked', @@ -246,6 +247,11 @@ export const ViewerOverlay = ({ )} + {/* Collections Panel - Top Right */} +
+ +
+ {/* Controls Panel - Bottom Center */}
diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx index ff8af373..ee41bdd3 100644 --- a/apps/editor/components/editor/index.tsx +++ b/apps/editor/components/editor/index.tsx @@ -11,6 +11,7 @@ import useEditor from '@/store/use-editor' import { FeedbackDialog } from '../feedback-dialog' import { PascalRadio } from '../pascal-radio' import { CeilingSystem } from '../systems/ceiling/ceiling-system' +import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system' import { ZoneSystem } from '../systems/zone/zone-system' import { ToolManager } from '../tools/tool-manager' import { ActionMenu } from '../ui/action-menu' @@ -153,6 +154,7 @@ export default function Editor({ projectId }: EditorProps) { +
) diff --git a/apps/editor/components/systems/zone/zone-label-editor-system.tsx b/apps/editor/components/systems/zone/zone-label-editor-system.tsx new file mode 100644 index 00000000..33881f0e --- /dev/null +++ b/apps/editor/components/systems/zone/zone-label-editor-system.tsx @@ -0,0 +1,184 @@ +'use client' + +import { useScene, type ZoneNode } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { Check, Pencil } from 'lucide-react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { useShallow } from 'zustand/react/shallow' +import useEditor from '@/store/use-editor' + +// ─── Per-zone label editor ──────────────────────────────────────────────────── + +function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) { + const zone = useScene((s) => s.nodes[zoneId] as ZoneNode | undefined) + const updateNode = useScene((s) => s.updateNode) + const setSelection = useViewer((s) => s.setSelection) + const [editing, setEditing] = useState(false) + const [value, setValue] = useState('') + const inputRef = useRef(null) + const [labelEl, setLabelEl] = useState(null) + + // Keep a ref so the click handler never has a stale zone name + const zoneNameRef = useRef(zone?.name ?? '') + useEffect(() => { zoneNameRef.current = zone?.name ?? '' }, [zone?.name]) + + // Setup: find the label element, enable pointer events, and hide the + // zone-renderer's own text node (children[0]) — we replace it via portal. + useEffect(() => { + const el = document.getElementById(`${zoneId}-label`) + if (!el) return + setLabelEl(el) + el.style.pointerEvents = 'auto' + + const textEl = el.children[0] as HTMLElement | undefined + if (textEl) textEl.style.display = 'none' + + return () => { + el.style.pointerEvents = '' + if (textEl) textEl.style.display = '' + } + }, [zoneId]) + + // Focus + select-all when entering edit mode + useEffect(() => { + if (editing) { + inputRef.current?.focus() + inputRef.current?.select() + } + }, [editing]) + + const save = useCallback(() => { + const trimmed = value.trim() + if (trimmed !== (zone?.name ?? '')) { + updateNode(zoneId, { name: trimmed || undefined }) + } + setEditing(false) + }, [value, zone?.name, updateNode, zoneId]) + + const cancel = useCallback(() => { + setValue(zone?.name ?? '') + setEditing(false) + }, [zone?.name]) + + if (!labelEl) return null + + const shadowColor = zone?.color ?? '#6366f1' + const textShadow = [ + `-1px -1px 0 ${shadowColor}`, + ` 1px -1px 0 ${shadowColor}`, + `-1px 1px 0 ${shadowColor}`, + ` 1px 1px 0 ${shadowColor}`, + ].join(',') + + // order: -1 puts this flex item before children[0] (hidden) and children[1] (pin) + const sharedStyle: React.CSSProperties = { + order: -1, + color: 'white', + textShadow, + fontSize: 14, + fontFamily: 'sans-serif', + userSelect: 'none', + display: 'inline-flex', + alignItems: 'center', + gap: 4, + whiteSpace: 'nowrap', + } + + return createPortal( + editing ? ( +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + setValue(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation() + if (e.key === 'Enter') { e.preventDefault(); save() } + if (e.key === 'Escape') { e.preventDefault(); cancel() } + }} + onBlur={save} + onClick={(e) => e.stopPropagation()} + style={{ + width: `${Math.max((value || zone?.name || '').length + 1, 4)}ch`, + border: 'none', + borderBottom: `1px solid ${shadowColor}`, + background: 'transparent', + color: 'white', + textShadow, + outline: 'none', + padding: 0, + margin: 0, + fontSize: 'inherit', + lineHeight: 'inherit', + fontFamily: 'inherit', + textAlign: 'center', + }} + /> + +
+ ) : ( + + ), + labelEl, + ) +} + +// ─── System: rendered in the main React tree (outside Canvas) ───────────────── + +export function ZoneLabelEditorSystem() { + const zoneIds = useScene( + useShallow((s) => + Object.values(s.nodes) + .filter((n) => n.type === 'zone') + .map((n) => n.id as ZoneNode['id']), + ), + ) + const structureLayer = useEditor((s) => s.structureLayer) + const mode = useEditor((s) => s.mode) + + if (structureLayer !== 'zones' || mode !== 'select') return null + + return ( + <> + {zoneIds.map((id) => ( + + ))} + + ) +} 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/item-catalog/catalog-items.tsx b/apps/editor/components/ui/item-catalog/catalog-items.tsx index 876936cf..9c386f46 100644 --- a/apps/editor/components/ui/item-catalog/catalog-items.tsx +++ b/apps/editor/components/ui/item-catalog/catalog-items.tsx @@ -1,5 +1,62 @@ import { AssetInput, ItemNode } from "@pascal-app/core"; export const CATALOG_ITEMS: AssetInput[] = [ + { + "id": "tesla", + "category": "outdoor", + tags: ["floor", "garage"], + "name": "Tesla", + "thumbnail": "/items/tesla/thumbnail.webp", + "src": "/items/tesla/model.glb", + "scale": [ + 1, + 1, + 1 + ], + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "dimensions": [ + 2, + 1.7, + 5 + ] + }, + { + "id": "ev-wall-charger", + "category": "appliance", + tags: ["wall", "garage"], + "name": "Ev-wall-charger", + "thumbnail": "/items/ev-wall-charger/thumbnail.webp", + "src": "/items/ev-wall-charger/model.glb", + "scale": [ + 1, + 1, + 1 + ], + "offset": [ + -0.07, + 0.4, + 0.15 + ], + "rotation": [ + 0, + 0, + 0 + ], + "dimensions": [ + 0.5, + 0.8, + 0.5 + ], + "attachTo": "wall" + }, { id: "pillar", category: "outdoor", @@ -612,6 +669,21 @@ export const CATALOG_ITEMS: AssetInput[] = [ rotation: [0, 0, 0], dimensions: [1, 0.5, 1.5], attachTo: "ceiling", + interactive: { + effects: [ + { + kind: 'animation', + clips: { + on: 'On', + }, + } + ], + controls: [ + { + kind: 'toggle', + } + ], + } }, { @@ -1143,19 +1215,19 @@ export const CATALOG_ITEMS: AssetInput[] = [ dimensions: [1.5, 2.5, 3.5], }, - { - id: "suspended-fireplace", - category: "furniture", - tags: ["ceiling", "decor"], - name: "Suspended Fireplace", - thumbnail: "/items/suspended-fireplace/thumbnail.webp", - src: "/items/suspended-fireplace/model.glb", - scale: [1, 1, 1], - offset: [0, 0.45, 0], - rotation: [0, 0, 0], - dimensions: [0.5, 0.5, 0.5], - attachTo: "ceiling", - }, + // { + // id: "suspended-fireplace", + // category: "furniture", + // tags: ["ceiling", "decor"], + // name: "Suspended Fireplace", + // thumbnail: "/items/suspended-fireplace/thumbnail.webp", + // src: "/items/suspended-fireplace/model.glb", + // scale: [1, 1, 1], + // offset: [0, 0.45, 0], + // rotation: [0, 0, 0], + // dimensions: [0.5, 0.5, 0.5], + // attachTo: "ceiling", + // }, { id: "tv-stand", @@ -1214,6 +1286,73 @@ export const CATALOG_ITEMS: AssetInput[] = [ rotation: [0, 0, 0], dimensions: [1, 1, 1], attachTo: "ceiling", + interactive: { + controls: [ + { + kind: 'toggle', + }, + { + kind: 'slider', label: 'Intensity', min: 0, max: 100, unit: '%', displayMode: 'dial', + default: 100 + } + ], + effects: [ + { + kind: 'light', + intensityRange: [0, 2], + color: '#ffffff', + offset: [0, -0.5, 0], + } + ] + } + }, + { + "id": "recessed-light", + category: "furniture", + "tags": ["ceiling", "lighting"], + "name": "Recessed Light", + "thumbnail": "/items/recessed-light/thumbnail.webp", + "src": "/items/recessed-light/model.glb", + "scale": [ + 1, + 1, + 1 + ], + "offset": [ + 0, + 0.094, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "dimensions": [ + 0.5, + 0.1, + 0.5 + ], + "attachTo": "ceiling", + interactive: { + controls: [ + { + kind: 'toggle', + }, + { + kind: 'slider', label: 'Intensity', min: 0, max: 100, unit: '%', displayMode: 'dial', + default: 100 + } + ], + effects: [ + { + kind: 'light', + intensityRange: [0, 2], + color: '#ffffff', + offset: [0, -0.1, 0], + } + ] + } }, { @@ -1227,6 +1366,25 @@ export const CATALOG_ITEMS: AssetInput[] = [ offset: [0.04, 0, 0.02], rotation: [0, 0, 0], dimensions: [1, 1.9, 1], + interactive: { + controls: [ + { + kind: 'toggle', + }, + { + kind: 'slider', label: 'Intensity', min: 0, max: 100, unit: '%', displayMode: 'dial' , + default: 100 + } + ], + effects: [ + { + kind: 'light', + intensityRange: [0, 2], + color: '#ffffff', + offset: [0, 1.4, 0], + } + ] + } }, { 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 ( + + + + ) + } + + if (control.kind === 'slider') { + return ( + + ) + } + + if (control.kind === 'temperature') { + return ( + + ) + } + + return null +}