From f93fa60fff0510f2e1b57ad8e87089d736b0ad99 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 24 Feb 2026 00:59:45 +0000 Subject: [PATCH] feat(web): add widget picker drawer for dashboard customization (#498) Co-authored-by: Jason Woltje Co-committed-by: Jason Woltje --- apps/web/src/app/(authenticated)/page.tsx | 80 +++-- .../src/components/widgets/WidgetPicker.tsx | 277 ++++++++++++++++++ docs/TASKS.md | 22 +- 3 files changed, 350 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/components/widgets/WidgetPicker.tsx diff --git a/apps/web/src/app/(authenticated)/page.tsx b/apps/web/src/app/(authenticated)/page.tsx index 641627e..07a4b62 100644 --- a/apps/web/src/app/(authenticated)/page.tsx +++ b/apps/web/src/app/(authenticated)/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import type { ReactElement } from "react"; import type { WidgetPlacement } from "@mosaic/shared"; import { WidgetGrid } from "@/components/widgets/WidgetGrid"; +import { WidgetPicker } from "@/components/widgets/WidgetPicker"; import { DEFAULT_LAYOUT } from "@/components/widgets/defaultLayout"; import { fetchDefaultLayout, createLayout, updateLayout } from "@/lib/api/layouts"; import { useWorkspaceId } from "@/lib/hooks"; @@ -13,6 +14,7 @@ export default function DashboardPage(): ReactElement { const [layout, setLayout] = useState(DEFAULT_LAYOUT); const [layoutId, setLayoutId] = useState(null); const [isEditing, setIsEditing] = useState(false); + const [isPickerOpen, setIsPickerOpen] = useState(false); const [isLoading, setIsLoading] = useState(true); // Debounce timer for auto-saving layout changes @@ -95,6 +97,15 @@ export default function DashboardPage(): ReactElement { [layout, saveLayout] ); + const handleAddWidget = useCallback( + (placement: WidgetPlacement) => { + const updated = [...layout, placement]; + setLayout(updated); + saveLayout(updated); + }, + [layout, saveLayout] + ); + if (isLoading) { return (
@@ -125,24 +136,47 @@ export default function DashboardPage(): ReactElement { > Dashboard - +
+ {isEditing && ( + + )} + +
{/* Widget grid */} @@ -152,6 +186,16 @@ export default function DashboardPage(): ReactElement { {...(isEditing && { onRemoveWidget: handleRemoveWidget })} isEditing={isEditing} /> + + {/* Widget picker drawer */} + { + setIsPickerOpen(false); + }} + onAddWidget={handleAddWidget} + currentLayout={layout} + /> ); } diff --git a/apps/web/src/components/widgets/WidgetPicker.tsx b/apps/web/src/components/widgets/WidgetPicker.tsx new file mode 100644 index 0000000..5ee2caa --- /dev/null +++ b/apps/web/src/components/widgets/WidgetPicker.tsx @@ -0,0 +1,277 @@ +/** + * WidgetPicker — Dialog to browse available widgets and add them to the dashboard. + */ + +import { useState, useCallback } from "react"; +import type { ReactElement } from "react"; +import type { WidgetPlacement } from "@mosaic/shared"; +import { getAllWidgets, type WidgetDefinition } from "./WidgetRegistry"; + +export interface WidgetPickerProps { + open: boolean; + onClose: () => void; + onAddWidget: (placement: WidgetPlacement) => void; + currentLayout: WidgetPlacement[]; +} + +/** Generate a unique widget ID: "WidgetType-" */ +function generateWidgetId(widgetName: string): string { + const suffix = Math.random().toString(36).slice(2, 8); + return `${widgetName}-${suffix}`; +} + +/** Find the first open Y position at x=0 that doesn't overlap */ +function findNextY(layout: WidgetPlacement[]): number { + if (layout.length === 0) return 0; + let maxBottom = 0; + for (const item of layout) { + const bottom = item.y + item.h; + if (bottom > maxBottom) maxBottom = bottom; + } + return maxBottom; +} + +function WidgetPickerItem({ + widget, + onAdd, +}: { + widget: WidgetDefinition; + onAdd: () => void; +}): ReactElement { + const [hovered, setHovered] = useState(false); + + return ( +
{ + setHovered(true); + }} + onMouseLeave={(): void => { + setHovered(false); + }} + style={{ + display: "flex", + alignItems: "center", + gap: 12, + padding: "12px 16px", + borderRadius: "var(--r)", + background: hovered ? "var(--surface-2)" : "transparent", + transition: "background 0.12s ease", + }} + > +
+
+ {widget.displayName} +
+
+ {widget.description} +
+
+ Default size: {widget.defaultWidth}×{widget.defaultHeight} +
+
+ +
+ ); +} + +export function WidgetPicker({ + open, + onClose, + onAddWidget, + currentLayout, +}: WidgetPickerProps): ReactElement | null { + const allWidgets = getAllWidgets(); + const [search, setSearch] = useState(""); + + const filtered = search + ? allWidgets.filter( + (w) => + w.displayName.toLowerCase().includes(search.toLowerCase()) || + w.description.toLowerCase().includes(search.toLowerCase()) + ) + : allWidgets; + + const handleAdd = useCallback( + (widget: WidgetDefinition) => { + const placement: WidgetPlacement = { + i: generateWidgetId(widget.name), + x: 0, + y: findNextY(currentLayout), + w: widget.defaultWidth, + h: widget.defaultHeight, + minW: widget.minWidth, + minH: widget.minHeight, + }; + if (widget.maxWidth !== undefined) placement.maxW = widget.maxWidth; + if (widget.maxHeight !== undefined) placement.maxH = widget.maxHeight; + onAddWidget(placement); + }, + [currentLayout, onAddWidget] + ); + + if (!open) return null; + + return ( + <> + {/* Backdrop */} +