Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
17 kB · 495 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496import { useFullscreen } from "@/contexts/fullscreen-context";import { cn } from "@/lib/utils";import { DragDropProvider, useDraggable } from "@dnd-kit/react";import type { LivestreamStore } from "@streamplace/core";import { Plus, RectangleHorizontal, RotateCcw, Save, Trash2, X,} from "lucide-react";import { useCallback, useEffect, useRef, useState } from "react";import { Button } from "../ui/button";import { SidebarTrigger } from "../ui/sidebar";import { useLayout } from "./hooks/use-layout";import { removeLeafAt, replaceLeafWithSplit, updateLeafAt, type LayoutNode,} from "./layout";import { DraggableLayoutRenderer, findElementById, getLeafWidget, idToPath, readPointer, type DragState,} from "./layout-renderer";import { WIDGET_KEYS, WIDGET_REGISTRY, getWidgetMeta } from "./registry";
interface ControlPanelProps { store: LivestreamStore; user?: string;}
/** Prefix for drawer item source ids (e.g. `widget-stream-monitor`). */const WIDGET_SOURCE_PREFIX = "widget-";
/** * Top-level control panel. Owns the drag-and-drop context, the widget * drawer, and the layout renderer. The drawer and the layout live inside * the same `DragDropProvider` so the user can drag drawer items onto * leaves. */export function ControlPanel({ store, user }: ControlPanelProps) { const { layout, setLayout, resetLayout, presets, savePreset, loadPreset, deletePreset, } = useLayout();
const { theatre, setTheatre } = useFullscreen();
const [presetName, setPresetName] = useState(""); const [showSaveInput, setShowSaveInput] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false); const [dragState, setDragState] = useState<DragState | null>(null);
const handleUpdate = useCallback( (updater: (node: LayoutNode) => LayoutNode) => { setLayout(updater(layout)); }, [layout, setLayout], );
const handleWidgetChange = useCallback( (path: number[], newWidget: string) => { if (!(newWidget in WIDGET_REGISTRY)) return; setLayout(updateLeafAt(layout, path, newWidget)); }, [layout, setLayout], );
const handleWidgetRemove = useCallback( (path: number[]) => { setLayout(removeLeafAt(layout, path)); }, [layout, setLayout], );
const handleSavePreset = useCallback(() => { const name = presetName.trim(); if (!name) return; savePreset(name); setPresetName(""); setShowSaveInput(false); }, [presetName, savePreset]);
// Live drag tracking. `onDragOver` fires on target change; `onDragMove` // fires on every pointer move (so the overlay tracks the cursor even // when it stays within a single leaf). const handleDragOver = useCallback((event: any) => { const target = event.operation?.target; const pointer = readPointer(event.operation); if (target && pointer) { setDragState({ targetId: String(target.id), pointer }); } else { setDragState(null); } }, []);
const handleDragMove = useCallback((event: any) => { const target = event.operation?.target; const pointer = readPointer(event.operation); if (target && pointer) { setDragState({ targetId: String(target.id), pointer }); } }, []);
/** * Unified drop handler. Supports two kinds of source: * - leaf source (`leaf-{path}`): move the widget; split the target * and remove the source from its original slot * - drawer source (`widget-{key}`): add the widget; split the target * with the dragged widget, no source removal */ const handleDragEnd = useCallback( (event: any) => { setDragState(null); if (event.canceled) return; const { source, target } = event.operation; if (!source || !target) return;
const sourceId = String(source.id); const targetId = String(target.id); if (sourceId === targetId) return;
const isWidgetSource = sourceId.startsWith(WIDGET_SOURCE_PREFIX);
// Resolve the source widget and (for leaf sources) its path. let sourceWidget: string; let sourcePath: number[] | null = null; if (isWidgetSource) { sourceWidget = sourceId.slice(WIDGET_SOURCE_PREFIX.length); if (!(sourceWidget in WIDGET_REGISTRY)) return; } else { sourcePath = idToPath(sourceId); if (!sourcePath) return; const widget = getLeafWidget(layout, sourcePath); if (!widget) return; sourceWidget = widget; }
const targetPath = idToPath(targetId); if (!targetPath) return; const targetWidget = getLeafWidget(layout, targetPath);
const targetEl = findElementById(targetId); const pointer = readPointer(event.operation); if (!targetEl || !pointer) return;
const rect = targetEl.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return;
const relX = (pointer.x - rect.left) / rect.width; const relY = (pointer.y - rect.top) / rect.height; if ( !Number.isFinite(relX) || !Number.isFinite(relY) || relX < 0 || relX > 1 || relY < 0 || relY > 1 ) { return; }
const finalSourceWidget = sourceWidget; let nextLayout: LayoutNode;
// Dropping onto a blank slot just fills it; no split, since a // split with a blank in one half would be weird. For leaf // sources we also remove the source so the widget effectively // moves into the empty slot. if (targetWidget === "blank") { const filled = updateLeafAt(layout, targetPath, finalSourceWidget); nextLayout = sourcePath ? removeLeafAt(filled, sourcePath) : filled; } else { // Dominant axis determines split direction. The sign of the // displacement chooses which side the source widget lands on. const horizontalDominant = Math.abs(relX - 0.5) >= Math.abs(relY - 0.5); const direction = horizontalDominant ? "horizontal" : "vertical"; const sourceFirst = horizontalDominant ? relX < 0.5 : relY < 0.5;
const withSplit = replaceLeafWithSplit( layout, targetPath, finalSourceWidget, direction, sourceFirst, ); nextLayout = sourcePath ? removeLeafAt(withSplit, sourcePath) : withSplit; }
setLayout(nextLayout); }, [layout, setLayout], );
return ( <DragDropProvider onDragOver={handleDragOver} onDragMove={handleDragMove} onDragEnd={handleDragEnd} > <div className="relative flex h-full min-h-0 flex-col"> {/* Toolbar */} {!theatre && ( <div className="flex shrink-0 items-center gap-2 bg-(--color-sidebar) px-3 py-2.5"> <SidebarTrigger /> {/* Preset selector */} {presets.length > 0 && ( <select value="" onChange={(e) => { if (e.target.value) loadPreset(e.target.value); }} className="h-7 rounded border border-(--color-border) bg-(--color-bg) px-2 text-xs text-(--color-fg)" > <option value="">Load preset…</option> {presets.map((p) => ( <option key={p.name} value={p.name}> {p.name} </option> ))} </select> )}
{/* Save preset */} {showSaveInput ? ( <div className="flex items-center gap-1"> <input value={presetName} onChange={(e) => setPresetName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") handleSavePreset(); if (e.key === "Escape") setShowSaveInput(false); }} placeholder="Preset name…" autoFocus className="h-7 w-32 rounded border border-(--color-border) bg-(--color-bg) px-2 text-xs text-(--color-fg)" /> <button type="button" onClick={handleSavePreset} className="h-7 rounded bg-(--color-accent) px-2 text-xs text-(--color-accent-fg) transition-colors hover:bg-(--color-accent-hover)" > Save </button> </div> ) : ( <button type="button" onClick={() => setShowSaveInput(true)} className="flex h-7 items-center gap-1 rounded border border-(--color-border) px-2 text-xs text-(--color-fg-muted) transition-colors hover:border-(--color-border-strong) hover:text-(--color-fg)" > <Save className="size-3" /> Save Preset </button> )}
{/* Preset management */} {presets.length > 0 && ( <div className="flex items-center gap-1"> {presets.map((p) => ( <div key={p.name} className="flex h-7 items-center gap-0.5 rounded border border-(--color-border) bg-(--color-bg) px-1.5 text-xs" > <button type="button" onClick={() => loadPreset(p.name)} className="text-(--color-fg-muted) transition-colors hover:text-(--color-fg)" > {p.name} </button> <button type="button" onClick={() => deletePreset(p.name)} className="text-(--color-fg-muted) transition-colors hover:text-red-400" > <Trash2 className="size-3" /> </button> </div> ))} </div> )}
<div className="flex-1" />
{/* Add Widget toggle */} <button type="button" id="drawer-toggle-button" onClick={() => setDrawerOpen((v) => !v)} className={cn( "flex h-7 items-center gap-1 rounded border px-2 text-xs transition-colors", drawerOpen ? "border-(--color-accent) bg-(--color-accent)/15 text-(--color-accent)" : "border-(--color-border) text-(--color-fg-muted) hover:border-(--color-border-strong) hover:text-(--color-fg)", )} aria-pressed={drawerOpen} > {drawerOpen ? ( <X className="size-3" /> ) : ( <Plus className="size-3" /> )} {drawerOpen ? "Close" : "Add Widget"} </button>
{/* Reset */} <button type="button" onClick={resetLayout} className="flex h-7 items-center gap-1 rounded border border-(--color-border) px-2 text-xs text-(--color-fg-muted) transition-colors hover:border-(--color-border-strong) hover:text-(--color-fg)" title="Reset to default layout" > <RotateCcw className="size-3" /> Reset </button>
{/* Theatre mode */} <button type="button" onClick={() => setTheatre(true)} className="flex h-7 items-center gap-1 rounded border border-(--color-border) px-2 text-xs text-(--color-fg-muted) transition-colors hover:border-(--color-border-strong) hover:text-(--color-fg)" title="Theatre mode" aria-label="Theatre mode" > <RectangleHorizontal className="size-3" /> Theatre </button> </div> )}
{/* Drawer (slides down between toolbar and layout) */} {!theatre && ( <WidgetDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} /> )}
{/* Layout area */} <div className="min-h-0 flex-1 p-1.5"> <DraggableLayoutRenderer layout={layout} store={store} user={user} onUpdate={handleUpdate} onWidgetChange={handleWidgetChange} onWidgetRemove={handleWidgetRemove} dragState={dragState} /> </div>
{/* Theatre-mode exit button: floating top-right inside the control panel so it's reachable after the toolbar and drawer are gone. */} {theatre && ( <button type="button" onClick={() => setTheatre(false)} aria-label="Exit theatre mode" title="Exit theatre mode" className="absolute top-2 right-10 z-20 flex h-7 items-center gap-1 rounded border border-(--color-border) bg-(--color-bg-elevated)/80 px-2 text-xs text-(--color-fg-muted) opacity-50 backdrop-blur transition-colors hover:border-(--color-border-strong) hover:text-(--color-fg) hover:opacity-100" > <X className="size-3" /> Exit Theatre </button> )} </div> </DragDropProvider> );}
/** * Drawer with draggable cards for every widget in the registry (except * `blank`, which is only a placeholder, not a real widget). Drag a card * onto a leaf in the layout to add it. * Positioned as a 'drawer' that slides down from the toolbar. */function WidgetDrawer({ open, onClose,}: { open: boolean; onClose: () => void;}) { const [exiting, setExiting] = useState(false); const exitingRef = useRef(false);
useEffect(() => { if (exiting) { const timer = setTimeout(() => { setExiting(false); exitingRef.current = false; onClose(); }, 500); return () => clearTimeout(timer); } }, [exiting, onClose]);
const handleClose = useCallback(() => { if (!exitingRef.current) { exitingRef.current = true; setExiting(true); } }, []);
const outerDivRef = useCallback( (el: HTMLDivElement | null) => { if (el) { const handleClick = (e: MouseEvent) => { if (!el.contains(e.target as Node)) { if ( e.target instanceof HTMLElement && e.target.closest("#drawer-toggle-button") ) { return; } handleClose(); } }; document.addEventListener("mousedown", handleClick); return () => { document.removeEventListener("mousedown", handleClick); }; } }, [handleClose], );
if (!open && !exiting) return null;
return ( <> <div className={cn( "absolute top-0 right-0 left-0 z-10 mx-auto w-full max-w-2xl", exiting ? "animate-fade-out-up" : "animate-fade-in-down", )} ref={outerDivRef} > <div className="flex shrink-0 flex-col gap-1 rounded-b-lg border-b border-(--color-border) bg-(--color-bg) px-3 py-2"> <div className="flex items-center justify-between"> <div className="text-(--color-fg-muted)"> Drag a widget to add it </div> <Button variant="ghost" size="icon" onClick={handleClose}> <X className="size-4 text-(--color-fg-muted)" /> </Button> </div> <div className="flex flex-wrap gap-1.5"> {WIDGET_KEYS.filter((k) => k !== "blank").map((key) => ( <DrawerWidgetCard key={key} widgetKey={key} /> ))} </div> </div> </div> </> );}
function DrawerWidgetCard({ widgetKey }: { widgetKey: string }) { const { ref, isDragging } = useDraggable({ id: `${WIDGET_SOURCE_PREFIX}${widgetKey}`, }); const meta = getWidgetMeta(widgetKey); if (!meta) return null; const Icon = meta.icon; return ( <div ref={ref} className={cn( "flex cursor-grab items-center gap-1.5 rounded-md border border-(--color-border) bg-(--color-bg-elevated) px-2.5 py-1.5 transition-opacity select-none hover:border-(--color-border-strong) active:cursor-grabbing", isDragging && "opacity-50", )} style={ isDragging ? { color: meta.color, borderColor: meta.color } : undefined } > <Icon className="size-3.5 text-(--color-fg-muted)" /> <span className="text-xs font-medium">{meta.title}</span> </div> );}