Dashboard.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  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. }
  152. return parsed;
  153. } catch {
  154. // Invalid JSON, use default
  155. }
  156. }
  157. // Default layout: all widgets visible in original order
  158. return {
  159. order: widgets.map((w) => w.id),
  160. hidden: widgets.filter((w) => w.defaultVisible === false).map((w) => w.id),
  161. sizes: getDefaultSizes(),
  162. };
  163. });
  164. const [showHiddenPanel, setShowHiddenPanel] = useState(false);
  165. const [isStacked, setIsStacked] = useState(false);
  166. useEffect(() => {
  167. if (!stackBelow) return undefined;
  168. const mediaQuery = window.matchMedia(`(max-width: ${stackBelow}px)`);
  169. const handleChange = (event: MediaQueryListEvent | MediaQueryList) => {
  170. setIsStacked(event.matches);
  171. };
  172. handleChange(mediaQuery);
  173. const onChange = (event: MediaQueryListEvent) => handleChange(event);
  174. if (mediaQuery.addEventListener) {
  175. mediaQuery.addEventListener('change', onChange);
  176. } else {
  177. mediaQuery.addListener(onChange);
  178. }
  179. return () => {
  180. if (mediaQuery.removeEventListener) {
  181. mediaQuery.removeEventListener('change', onChange);
  182. } else {
  183. mediaQuery.removeListener(onChange);
  184. }
  185. };
  186. }, [stackBelow]);
  187. const effectiveColumns = stackBelow && isStacked ? 1 : columns;
  188. // Listen for toggle-hidden-panel event from parent
  189. useEffect(() => {
  190. const handleToggle = () => setShowHiddenPanel(prev => !prev);
  191. window.addEventListener('toggle-hidden-panel', handleToggle);
  192. return () => window.removeEventListener('toggle-hidden-panel', handleToggle);
  193. }, []);
  194. // Save layout to localStorage whenever it changes
  195. useEffect(() => {
  196. localStorage.setItem(storageKey, JSON.stringify(layout));
  197. }, [layout, storageKey]);
  198. // Ensure all widget IDs are in the order array (for newly added widgets)
  199. useEffect(() => {
  200. const allIds = widgets.map((w) => w.id);
  201. const missingIds = allIds.filter((id) => !layout.order.includes(id));
  202. if (missingIds.length > 0) {
  203. setLayout((prev) => ({
  204. ...prev,
  205. order: [...prev.order, ...missingIds],
  206. }));
  207. }
  208. }, [widgets, layout.order]);
  209. const sensors = useSensors(
  210. useSensor(PointerSensor, {
  211. activationConstraint: {
  212. distance: 8,
  213. },
  214. }),
  215. useSensor(KeyboardSensor, {
  216. coordinateGetter: sortableKeyboardCoordinates,
  217. })
  218. );
  219. const handleDragEnd = (event: DragEndEvent) => {
  220. const { active, over } = event;
  221. if (over && active.id !== over.id) {
  222. setLayout((prev) => {
  223. const oldIndex = prev.order.indexOf(active.id as string);
  224. const newIndex = prev.order.indexOf(over.id as string);
  225. return {
  226. ...prev,
  227. order: arrayMove(prev.order, oldIndex, newIndex),
  228. };
  229. });
  230. }
  231. };
  232. const toggleVisibility = (id: string) => {
  233. setLayout((prev) => ({
  234. ...prev,
  235. hidden: prev.hidden.includes(id)
  236. ? prev.hidden.filter((h) => h !== id)
  237. : [...prev.hidden, id],
  238. }));
  239. };
  240. const toggleSize = (id: string) => {
  241. setLayout((prev) => {
  242. const currentSize = prev.sizes[id] || 4;
  243. // Cycle: 1 → 2 → 4 → 1
  244. const nextSize = currentSize === 1 ? 2 : currentSize === 2 ? 4 : 1;
  245. return {
  246. ...prev,
  247. sizes: {
  248. ...prev.sizes,
  249. [id]: nextSize as 1 | 2 | 4,
  250. },
  251. };
  252. });
  253. };
  254. const resetLayout = () => {
  255. const defaultLayout = {
  256. order: widgets.map((w) => w.id),
  257. hidden: widgets.filter((w) => w.defaultVisible === false).map((w) => w.id),
  258. sizes: getDefaultSizes(),
  259. };
  260. setLayout(defaultLayout);
  261. onResetLayout?.();
  262. };
  263. // Get ordered widgets
  264. const orderedWidgets = layout.order
  265. .map((id) => widgets.find((w) => w.id === id))
  266. .filter(Boolean) as DashboardWidget[];
  267. const visibleWidgets = orderedWidgets.filter((w) => !layout.hidden.includes(w.id));
  268. const hiddenWidgets = orderedWidgets.filter((w) => layout.hidden.includes(w.id));
  269. // Render external controls if provided
  270. const externalControls = renderControls?.({
  271. hiddenCount: hiddenWidgets.length,
  272. showHiddenPanel,
  273. setShowHiddenPanel,
  274. resetLayout,
  275. });
  276. return (
  277. <div className="space-y-4">
  278. {/* External controls slot */}
  279. {externalControls}
  280. {/* Dashboard Controls */}
  281. {!hideControls && !renderControls && (
  282. <div className="flex items-center justify-end gap-2">
  283. <Button variant="secondary" size="sm" onClick={resetLayout}>
  284. <RotateCcw className="w-4 h-4" />
  285. Reset Layout
  286. </Button>
  287. {hiddenWidgets.length > 0 && (
  288. <Button
  289. variant="secondary"
  290. size="sm"
  291. onClick={() => setShowHiddenPanel(!showHiddenPanel)}
  292. >
  293. <Eye className="w-4 h-4" />
  294. {hiddenWidgets.length} Hidden
  295. </Button>
  296. )}
  297. </div>
  298. )}
  299. {/* Hidden Widgets Panel */}
  300. {showHiddenPanel && hiddenWidgets.length > 0 && (
  301. <div className="p-4 bg-bambu-dark rounded-xl border border-bambu-dark-tertiary">
  302. <p className="text-sm text-bambu-gray mb-3">Hidden widgets (click to show):</p>
  303. <div className="flex flex-wrap gap-2">
  304. {hiddenWidgets.map((widget) => (
  305. <button
  306. key={widget.id}
  307. onClick={() => toggleVisibility(widget.id)}
  308. 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"
  309. >
  310. <Eye className="w-3 h-3" />
  311. {widget.title}
  312. </button>
  313. ))}
  314. </div>
  315. </div>
  316. )}
  317. {/* Draggable Widgets Grid */}
  318. <DndContext
  319. sensors={sensors}
  320. collisionDetection={closestCenter}
  321. onDragEnd={handleDragEnd}
  322. >
  323. <SortableContext items={visibleWidgets.map((w) => w.id)} strategy={rectSortingStrategy}>
  324. <div
  325. className="grid gap-6"
  326. style={{
  327. gridTemplateColumns: `repeat(${effectiveColumns}, minmax(0, 1fr))`,
  328. }}
  329. >
  330. {visibleWidgets.map((widget) => {
  331. const size = layout.sizes[widget.id] || 2;
  332. const columnSpan = Math.min(size, effectiveColumns);
  333. return (
  334. <SortableWidget
  335. key={widget.id}
  336. id={widget.id}
  337. title={widget.title}
  338. component={widget.component}
  339. isHidden={layout.hidden.includes(widget.id)}
  340. size={size}
  341. columnSpan={columnSpan}
  342. onToggleVisibility={() => toggleVisibility(widget.id)}
  343. onToggleSize={() => toggleSize(widget.id)}
  344. />
  345. );
  346. })}
  347. </div>
  348. </SortableContext>
  349. </DndContext>
  350. {visibleWidgets.length === 0 && (
  351. <div className="text-center py-12 text-bambu-gray">
  352. <p>All widgets are hidden.</p>
  353. <Button className="mt-4" onClick={resetLayout}>
  354. Reset Layout
  355. </Button>
  356. </div>
  357. )}
  358. </div>
  359. );
  360. }