"use client"; import type { DashboardWidgetConfig, DashboardWidgetType } from "@capakraken/shared/types"; import { verticalCompactor, horizontalCompactor, type Compactor } from "react-grid-layout"; // Runs vertical compaction first (float up), then horizontal (float left). const bothCompactor: Compactor = { type: "vertical", allowOverlap: false, compact(layout, cols) { const afterVertical = verticalCompactor.compact(layout, cols); return horizontalCompactor.compact(afterVertical, cols); }, }; import dynamic from "next/dynamic"; import { Suspense, useState, useRef, useEffect, useMemo } from "react"; import { useDashboardLayout } from "~/hooks/useDashboardLayout.js"; import { WidgetContainer } from "./WidgetContainer.js"; import { AddWidgetModal } from "./AddWidgetModal.js"; import { getWidget } from "./widget-registry.js"; import { SuccessToast } from "~/components/ui/SuccessToast.js"; // Import CSS for react-grid-layout import "react-grid-layout/css/styles.css"; import "react-resizable/css/styles.css"; function WidgetFallback() { return (
); } function GridLayoutSkeleton() { return (
{Array.from({ length: 4 }).map((_, i) => (
))}
); } // Dynamic import — no WidthProvider (uses findDOMNode, broken in React 18 strict mode). // We measure container width ourselves via ResizeObserver and pass it as a prop. const GridLayout = dynamic(() => import("react-grid-layout").then((m) => m.Responsive), { ssr: false, loading: () => , }); function renderWidget( type: DashboardWidgetType, config: DashboardWidgetConfig, onConfigChange: (u: Record) => void, ) { const widget = getWidget(type); const Component = widget.component; return ( }> } onConfigChange={onConfigChange} /> ); } function DeferredWidgetFallback() { return (
); } function DeferredWidgetBody({ type, config, activationRank, isPriority, onConfigChange, }: { type: DashboardWidgetType; config: DashboardWidgetConfig; activationRank: number; isPriority: boolean; onConfigChange: (u: Record) => void; }) { const containerRef = useRef(null); const [isActive, setIsActive] = useState(isPriority); useEffect(() => { if (isPriority) { setIsActive(true); } }, [isPriority]); useEffect(() => { if (isActive) return; const element = containerRef.current; if (!element) return; const observer = new IntersectionObserver( ([entry]) => { if (!entry?.isIntersecting) return; setIsActive(true); observer.disconnect(); }, { rootMargin: "320px 0px", threshold: 0.05 }, ); observer.observe(element); return () => observer.disconnect(); }, [isActive]); useEffect(() => { if (isActive || isPriority || typeof window === "undefined") return; const activationDelayMs = 900 + Math.min(activationRank, 6) * 180; let timeoutId: number | null = null; let idleId: number | null = null; const browserWindow = window as Window & typeof globalThis & { requestIdleCallback?: ( callback: IdleRequestCallback, options?: IdleRequestOptions, ) => number; cancelIdleCallback?: (handle: number) => void; }; const activate = () => setIsActive(true); if (typeof browserWindow.requestIdleCallback === "function") { idleId = browserWindow.requestIdleCallback(activate, { timeout: activationDelayMs }); } else { timeoutId = browserWindow.setTimeout(activate, activationDelayMs); } return () => { if (idleId !== null && typeof browserWindow.cancelIdleCallback === "function") { browserWindow.cancelIdleCallback(idleId); } if (timeoutId !== null) { browserWindow.clearTimeout(timeoutId); } }; }, [activationRank, isActive, isPriority]); return
{isActive ? renderWidget(type, config, onConfigChange) : }
; } export function DashboardClient() { const [addModalOpen, setAddModalOpen] = useState(false); const { config, isHydrated, saveStatus, addWidget, removeWidget, updateWidgetConfig, onLayoutChange, resetLayout } = useDashboardLayout(); // Measure grid container width so Responsive knows the column size. // We can't use WidthProvider (uses findDOMNode, deprecated in React 18). const containerRef = useRef(null); const resizeFrameRef = useRef(null); const lastMeasuredWidthRef = useRef(0); const [gridWidth, setGridWidth] = useState(1200); // Re-run when isHydrated flips: on first mount the containerRef div doesn't // exist yet (skeleton is shown), so the effect returns early. Once hydration // completes the real container div is rendered and we need to measure it. useEffect(() => { const el = containerRef.current; if (!el) return; const updateWidth = (width: number) => { const roundedWidth = Math.max(0, Math.round(width)); if (roundedWidth === lastMeasuredWidthRef.current) return; lastMeasuredWidthRef.current = roundedWidth; setGridWidth((currentWidth) => (currentWidth === roundedWidth ? currentWidth : roundedWidth)); }; const ro = new ResizeObserver(([entry]) => { if (!entry) return; if (resizeFrameRef.current !== null) cancelAnimationFrame(resizeFrameRef.current); resizeFrameRef.current = requestAnimationFrame(() => { resizeFrameRef.current = null; updateWidth(entry.contentRect.width); }); }); ro.observe(el); updateWidth(el.getBoundingClientRect().width); return () => { ro.disconnect(); if (resizeFrameRef.current !== null) cancelAnimationFrame(resizeFrameRef.current); }; }, [isHydrated]); const layouts = useMemo( () => ({ lg: config.widgets.map((w) => ({ i: w.id, x: w.x, y: w.y, w: w.w, h: w.h, minW: w.minW ?? 2, minH: w.minH ?? 2, })), }), [config.widgets], ); const renderedWidgets = useMemo( () => config.widgets.map((widget) => { const widgetDefinition = getWidget(widget.type); const isPriorityWidget = widget.y < 3; return (
updateWidgetConfig(widget.id, { showDetails: widget.config.showDetails !== true, }) } onRemove={() => removeWidget(widget.id)} > updateWidgetConfig(widget.id, update)} />
); }), [config.widgets, removeWidget, updateWidgetConfig], ); // Show a skeleton while hydration is in-flight (avoids flashing the 1-widget default // layout before the user's real layout is loaded from localStorage or the DB). if (!isHydrated) { return (

Dashboard

Drag widgets to rearrange them and resize from the corners.

); } return (

Dashboard

Drag widgets to rearrange them and resize from the corners.

{config.widgets.length === 0 ? (

No widgets on this dashboard yet.

Start with a widget and build a view that matches how your team actually plans and monitors work.

) : (
{(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const AnyGridLayout = GridLayout as any; return ( , ) => onLayoutChange(allLayouts["lg"] ?? [])} draggableHandle=".widget-drag-handle" margin={[12, 12]} > {renderedWidgets} ); })()}
)} {addModalOpen && setAddModalOpen(false)} />}
); }