Dashboard.tsx 10 KB

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