Dashboard.tsx 10 KB

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