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..149e1414 --- /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 { 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 controlValuesByItem = useInteractive( + useShallow((s) => + Object.fromEntries(interactiveItems.map((n) => [n.id, s.items[n.id]?.controlValues ?? []])), + ), + ) + + 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]/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/ui/item-catalog/catalog-items.tsx b/apps/editor/components/ui/item-catalog/catalog-items.tsx index 8a72a8d5..d3465ec4 100644 --- a/apps/editor/components/ui/item-catalog/catalog-items.tsx +++ b/apps/editor/components/ui/item-catalog/catalog-items.tsx @@ -1260,6 +1260,24 @@ 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' + } + ], + effects: [ + { + kind: 'light', + intensityRange: [0, 2], + color: '#ffffff', + offset: [0, 1.4, 0], + } + ] + } }, {