Dashboard.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. import { useState, useEffect, type ReactNode } from 'react';
  2. import {
  3. DndContext,
  4. closestCenter,
  5. KeyboardSensor,
  6. PointerSensor,
  7. useSensor,
  8. useSensors,
  9. type DragEndEvent,
  10. } from '@dnd-kit/core';
  11. import {
  12. arrayMove,
  13. SortableContext,
  14. sortableKeyboardCoordinates,
  15. useSortable,
  16. rectSortingStrategy,
  17. } from '@dnd-kit/sortable';
  18. import { CSS } from '@dnd-kit/utilities';
  19. import { GripVertical, Eye, EyeOff, RotateCcw, Maximize2, Minimize2 } from 'lucide-react';
  20. import { Button } from './Button';
  21. export interface DashboardWidget {
  22. id: string;
  23. title: string;
  24. /** Render function that receives the current size for responsive content */
  25. component: ReactNode | ((size: 1 | 2 | 4) => ReactNode);
  26. defaultVisible?: boolean;
  27. defaultSize?: 1 | 2 | 4; // 1 = quarter, 2 = half, 4 = full width (default)
  28. }
  29. interface DashboardProps {
  30. widgets: DashboardWidget[];
  31. storageKey: string;
  32. columns?: number;
  33. stackBelow?: number;
  34. hideControls?: boolean;
  35. onResetLayout?: () => void;
  36. renderControls?: (controls: {
  37. hiddenCount: number;
  38. showHiddenPanel: boolean;
  39. setShowHiddenPanel: (show: boolean) => void;
  40. resetLayout: () => void;
  41. }) => ReactNode;
  42. }
  43. interface LayoutState {
  44. order: string[];
  45. hidden: string[];
  46. sizes: Record<string, 1 | 2 | 4>;
  47. }
  48. function SortableWidget({
  49. id,
  50. title,
  51. component,
  52. isHidden,
  53. size,
  54. columnSpan,
  55. onToggleVisibility,
  56. onToggleSize,
  57. }: {
  58. id: string;
  59. title: string;
  60. component: ReactNode | ((size: 1 | 2 | 4) => ReactNode);
  61. isHidden: boolean;
  62. size: 1 | 2 | 4;
  63. columnSpan: number;
  64. onToggleVisibility: () => void;
  65. onToggleSize: () => void;
  66. }) {
  67. const {
  68. attributes,
  69. listeners,
  70. setNodeRef,
  71. transform,
  72. transition,
  73. isDragging,
  74. } = useSortable({ id });
  75. const style = {
  76. transform: CSS.Transform.toString(transform),
  77. transition,
  78. opacity: isDragging ? 0.5 : 1,
  79. };
  80. if (isHidden) return null;
  81. return (
  82. <div
  83. ref={setNodeRef}
  84. style={{
  85. ...style,
  86. gridColumn: `span ${columnSpan}`,
  87. }}
  88. className={`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary overflow-hidden ${
  89. isDragging ? 'ring-2 ring-bambu-green shadow-lg' : ''
  90. }`}
  91. >
  92. {/* Widget Header */}
  93. <div className="flex items-center justify-between px-4 py-3 border-b border-bambu-dark-tertiary bg-bambu-dark/30">
  94. <div className="flex items-center gap-2">
  95. <button
  96. {...attributes}
  97. {...listeners}
  98. className="cursor-grab active:cursor-grabbing p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
  99. title="Drag to reorder"
  100. >
  101. <GripVertical className="w-6 h-6 md:w-4 md:h-4 text-bambu-gray" />
  102. </button>
  103. <h3 className="text-sm font-medium text-white">{title}</h3>
  104. </div>
  105. <div className="flex items-center gap-1">
  106. <button
  107. onClick={onToggleSize}
  108. className="p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
  109. title={`Size: ${size === 1 ? '1/4' : size === 2 ? '1/2' : 'Full'} - Click to cycle`}
  110. >
  111. {size === 4 ? (
  112. <Minimize2 className="w-4 h-4 text-bambu-gray hover:text-white" />
  113. ) : (
  114. <Maximize2 className="w-4 h-4 text-bambu-gray hover:text-white" />
  115. )}
  116. </button>
  117. <button
  118. onClick={onToggleVisibility}
  119. className="p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
  120. title="Hide widget"
  121. >
  122. <EyeOff className="w-4 h-4 text-bambu-gray hover:text-white" />
  123. </button>
  124. </div>
  125. </div>
  126. {/* Widget Content */}
  127. <div className="p-4">
  128. {typeof component === 'function' ? component(size) : component}
  129. </div>
  130. </div>
  131. );
  132. }
  133. export function Dashboard({ widgets, storageKey, columns = 4, stackBelow, hideControls = false, onResetLayout, renderControls }: DashboardProps) {
  134. // Build default sizes from widget definitions
  135. const getDefaultSizes = () => {
  136. const sizes: Record<string, 1 | 2 | 4> = {};
  137. widgets.forEach((w) => {
  138. sizes[w.id] = w.defaultSize || 4;
  139. });
  140. return sizes;
  141. };
  142. const [layout, setLayout] = useState<LayoutState>(() => {
  143. // Load saved layout from localStorage
  144. const saved = localStorage.getItem(storageKey);
  145. if (saved) {
  146. try {
  147. const parsed = JSON.parse(saved);
  148. // Ensure sizes exist (for backwards compatibility)
  149. if (!parsed.sizes) {
  150. parsed.sizes = getDefaultSizes();
  151. } else {
  152. // Merge in default sizes for any new widgets not in saved layout
  153. const defaults = getDefaultSizes();
  154. for (const id in defaults) {
  155. if (!(id in parsed.sizes)) {
  156. parsed.sizes[id] = defaults[id];
  157. }
  158. }
  159. }
  160. return parsed;
  161. } catch {
  162. // Invalid JSON, use default
  163. }
  164. }
  165. // Default layout: all widgets visible in original order
  166. return {
  167. order: widgets.map((w) => w.id),
  168. hidden: widgets.filter((w) => w.defaultVisible === false).map((w) => w.id),
  169. sizes: getDefaultSizes(),
  170. };
  171. });
  172. const [showHiddenPanel, setShowHiddenPanel] = useState(false);
  173. const [isStacked, setIsStacked] = useState(false);
  174. useEffect(() => {
  175. if (!stackBelow) return undefined;
  176. const mediaQuery = window.matchMedia(`(max-width: ${stackBelow}px)`);
  177. const handleChange = (event: MediaQueryListEvent | MediaQueryList) => {
  178. setIsStacked(event.matches);
  179. };
  180. handleChange(mediaQuery);
  181. const onChange = (event: MediaQueryListEvent) => handleChange(event);
  182. if (mediaQuery.addEventListener) {
  183. mediaQuery.addEventListener('change', onChange);
  184. } else {
  185. mediaQuery.addListener(onChange);
  186. }
  187. return () => {
  188. if (mediaQuery.removeEventListener) {
  189. mediaQuery.removeEventListener('change', onChange);
  190. } else {
  191. mediaQuery.removeListener(onChange);
  192. }
  193. };
  194. }, [stackBelow]);
  195. const effectiveColumns = stackBelow && isStacked ? 1 : columns;
  196. // Listen for toggle-hidden-panel event from parent
  197. useEffect(() => {
  198. const handleToggle = () => setShowHiddenPanel(prev => !prev);
  199. window.addEventListener('toggle-hidden-panel', handleToggle);
  200. return () => window.removeEventListener('toggle-hidden-panel', handleToggle);
  201. }, []);
  202. // Save layout to localStorage whenever it changes
  203. useEffect(() => {
  204. localStorage.setItem(storageKey, JSON.stringify(layout));
  205. }, [layout, storageKey]);
  206. // Ensure all widget IDs are in the order array (for newly added widgets)
  207. useEffect(() => {
  208. const allIds = widgets.map((w) => w.id);
  209. const missingIds = allIds.filter((id) => !layout.order.includes(id));
  210. if (missingIds.length > 0) {
  211. setLayout((prev) => ({
  212. ...prev,
  213. order: [...prev.order, ...missingIds],
  214. }));
  215. }
  216. }, [widgets, layout.order]);
  217. const sensors = useSensors(
  218. useSensor(PointerSensor, {
  219. activationConstraint: {
  220. distance: 8,
  221. },
  222. }),
  223. useSensor(KeyboardSensor, {
  224. coordinateGetter: sortableKeyboardCoordinates,
  225. })
  226. );
  227. const handleDragEnd = (event: DragEndEvent) => {
  228. const { active, over } = event;
  229. if (over && active.id !== over.id) {
  230. setLayout((prev) => {
  231. const oldIndex = prev.order.indexOf(active.id as string);
  232. const newIndex = prev.order.indexOf(over.id as string);
  233. return {
  234. ...prev,
  235. order: arrayMove(prev.order, oldIndex, newIndex),
  236. };
  237. });
  238. }
  239. };
  240. const toggleVisibility = (id: string) => {
  241. setLayout((prev) => ({
  242. ...prev,
  243. hidden: prev.hidden.includes(id)
  244. ? prev.hidden.filter((h) => h !== id)
  245. : [...prev.hidden, id],
  246. }));
  247. };
  248. const toggleSize = (id: string) => {
  249. setLayout((prev) => {
  250. const currentSize = prev.sizes[id] || 4;
  251. // Cycle: 1 → 2 → 4 → 1
  252. const nextSize = currentSize === 1 ? 2 : currentSize === 2 ? 4 : 1;
  253. return {
  254. ...prev,
  255. sizes: {
  256. ...prev.sizes,
  257. [id]: nextSize as 1 | 2 | 4,
  258. },
  259. };
  260. });
  261. };
  262. const resetLayout = () => {
  263. const defaultLayout = {
  264. order: widgets.map((w) => w.id),
  265. hidden: widgets.filter((w) => w.defaultVisible === false).map((w) => w.id),
  266. sizes: getDefaultSizes(),
  267. };
  268. setLayout(defaultLayout);
  269. onResetLayout?.();
  270. };
  271. // Get ordered widgets
  272. const orderedWidgets = layout.order
  273. .map((id) => widgets.find((w) => w.id === id))
  274. .filter(Boolean) as DashboardWidget[];
  275. const visibleWidgets = orderedWidgets.filter((w) => !layout.hidden.includes(w.id));
  276. const hiddenWidgets = orderedWidgets.filter((w) => layout.hidden.includes(w.id));
  277. // Render external controls if provided
  278. const externalControls = renderControls?.({
  279. hiddenCount: hiddenWidgets.length,
  280. showHiddenPanel,
  281. setShowHiddenPanel,
  282. resetLayout,
  283. });
  284. return (
  285. <div className="space-y-4">
  286. {/* External controls slot */}
  287. {externalControls}
  288. {/* Dashboard Controls */}
  289. {!hideControls && !renderControls && (
  290. <div className="flex items-center justify-end gap-2">
  291. <Button variant="secondary" size="sm" onClick={resetLayout}>
  292. <RotateCcw className="w-4 h-4" />
  293. Reset Layout
  294. </Button>
  295. {hiddenWidgets.length > 0 && (
  296. <Button
  297. variant="secondary"
  298. size="sm"
  299. onClick={() => setShowHiddenPanel(!showHiddenPanel)}
  300. >
  301. <Eye className="w-4 h-4" />
  302. {hiddenWidgets.length} Hidden
  303. </Button>
  304. )}
  305. </div>
  306. )}
  307. {/* Hidden Widgets Panel */}
  308. {showHiddenPanel && hiddenWidgets.length > 0 && (
  309. <div className="p-4 bg-bambu-dark rounded-xl border border-bambu-dark-tertiary">
  310. <p className="text-sm text-bambu-gray mb-3">Hidden widgets (click to show):</p>
  311. <div className="flex flex-wrap gap-2">
  312. {hiddenWidgets.map((widget) => (
  313. <button
  314. key={widget.id}
  315. onClick={() => toggleVisibility(widget.id)}
  316. className="px-3 py-1.5 bg-bambu-dark-tertiary hover:bg-bambu-green/20 rounded-lg text-sm text-white transition-colors flex items-center gap-2"
  317. >
  318. <Eye className="w-3 h-3" />
  319. {widget.title}
  320. </button>
  321. ))}
  322. </div>
  323. </div>
  324. )}
  325. {/* Draggable Widgets Grid */}
  326. <DndContext
  327. sensors={sensors}
  328. collisionDetection={closestCenter}
  329. onDragEnd={handleDragEnd}
  330. >
  331. <SortableContext items={visibleWidgets.map((w) => w.id)} strategy={rectSortingStrategy}>
  332. <div
  333. className="grid gap-6"
  334. style={{
  335. gridTemplateColumns: `repeat(${effectiveColumns}, minmax(0, 1fr))`,
  336. }}
  337. >
  338. {visibleWidgets.map((widget) => {
  339. const size = layout.sizes[widget.id] || 2;
  340. const columnSpan = Math.min(size, effectiveColumns);
  341. return (
  342. <SortableWidget
  343. key={widget.id}
  344. id={widget.id}
  345. title={widget.title}
  346. component={widget.component}
  347. isHidden={layout.hidden.includes(widget.id)}
  348. size={size}
  349. columnSpan={columnSpan}
  350. onToggleVisibility={() => toggleVisibility(widget.id)}
  351. onToggleSize={() => toggleSize(widget.id)}
  352. />
  353. );
  354. })}
  355. </div>
  356. </SortableContext>
  357. </DndContext>
  358. {visibleWidgets.length === 0 && (
  359. <div className="text-center py-12 text-bambu-gray">
  360. <p>All widgets are hidden.</p>
  361. <Button className="mt-4" onClick={resetLayout}>
  362. Reset Layout
  363. </Button>
  364. </div>
  365. )}
  366. </div>
  367. );
  368. }