Files
stack/apps/web/src/app/(authenticated)/page.tsx
Jason Woltje ff5a09c3fb
All checks were successful
ci/woodpecker/push/web Pipeline was successful
feat(web): add widget config dialog and layout management controls (#499)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-24 01:11:47 +00:00

243 lines
7.3 KiB
TypeScript

"use client";
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 { WidgetConfigDialog } from "@/components/widgets/WidgetConfigDialog";
import { DEFAULT_LAYOUT } from "@/components/widgets/defaultLayout";
import { fetchDefaultLayout, createLayout, updateLayout } from "@/lib/api/layouts";
import { useWorkspaceId } from "@/lib/hooks";
export default function DashboardPage(): ReactElement {
const workspaceId = useWorkspaceId();
const [layout, setLayout] = useState<WidgetPlacement[]>(DEFAULT_LAYOUT);
const [layoutId, setLayoutId] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [isPickerOpen, setIsPickerOpen] = useState(false);
const [configWidgetId, setConfigWidgetId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Debounce timer for auto-saving layout changes
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Load the user's default layout (or create one)
useEffect(() => {
if (!workspaceId) {
setIsLoading(false);
return;
}
const wsId = workspaceId;
const ac = new AbortController();
async function loadLayout(): Promise<void> {
try {
const existing = await fetchDefaultLayout(wsId);
if (ac.signal.aborted) return;
if (existing) {
setLayout(existing.layout);
setLayoutId(existing.id);
} else {
const created = await createLayout(wsId, {
name: "Default",
isDefault: true,
layout: DEFAULT_LAYOUT,
});
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- aborted can change during await
if (ac.signal.aborted) return;
setLayout(created.layout);
setLayoutId(created.id);
}
} catch (err: unknown) {
console.error("[Dashboard] Failed to load layout:", err);
}
setIsLoading(false);
}
void loadLayout();
return (): void => {
ac.abort();
};
}, [workspaceId]);
// Save layout changes with debounce
const saveLayout = useCallback(
(newLayout: WidgetPlacement[]) => {
if (!workspaceId || !layoutId) return;
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
saveTimerRef.current = setTimeout(() => {
void updateLayout(workspaceId, layoutId, { layout: newLayout }).catch((err: unknown) => {
console.error("[Dashboard] Failed to save layout:", err);
});
}, 800);
},
[workspaceId, layoutId]
);
const handleLayoutChange = useCallback(
(newLayout: WidgetPlacement[]) => {
setLayout(newLayout);
saveLayout(newLayout);
},
[saveLayout]
);
const handleRemoveWidget = useCallback(
(widgetId: string) => {
const updated = layout.filter((item) => item.i !== widgetId);
setLayout(updated);
saveLayout(updated);
},
[layout, saveLayout]
);
const handleAddWidget = useCallback(
(placement: WidgetPlacement) => {
const updated = [...layout, placement];
setLayout(updated);
saveLayout(updated);
},
[layout, saveLayout]
);
const handleResetLayout = useCallback((): void => {
setLayout(DEFAULT_LAYOUT);
saveLayout(DEFAULT_LAYOUT);
}, [saveLayout]);
const handleEditWidget = useCallback((widgetId: string): void => {
setConfigWidgetId(widgetId);
}, []);
if (isLoading) {
return (
<div className="flex items-center justify-center" style={{ minHeight: 400 }}>
<div className="flex flex-col items-center gap-2">
<div
className="w-8 h-8 border-2 border-t-transparent rounded-full animate-spin"
style={{ borderColor: "var(--primary)", borderTopColor: "transparent" }}
/>
<span className="text-sm" style={{ color: "var(--muted)" }}>
Loading dashboard...
</span>
</div>
</div>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{/* Dashboard header with edit toggle */}
<div className="flex items-center justify-between">
<h1
style={{
fontSize: "1.5rem",
fontWeight: 700,
color: "var(--text)",
margin: 0,
}}
>
Dashboard
</h1>
<div className="flex items-center gap-2">
{isEditing && (
<>
<button
onClick={handleResetLayout}
style={{
padding: "6px 14px",
borderRadius: "var(--r)",
border: "1px solid var(--border)",
background: "transparent",
color: "var(--muted)",
fontSize: "0.83rem",
fontWeight: 500,
cursor: "pointer",
transition: "all 0.15s ease",
}}
>
Reset
</button>
<button
onClick={(): void => {
setIsPickerOpen(true);
}}
style={{
padding: "6px 14px",
borderRadius: "var(--r)",
border: "1px solid var(--border)",
background: "transparent",
color: "var(--text-2)",
fontSize: "0.83rem",
fontWeight: 500,
cursor: "pointer",
transition: "all 0.15s ease",
}}
>
+ Add Widget
</button>
</>
)}
<button
onClick={(): void => {
setIsEditing((prev) => !prev);
if (isEditing) setIsPickerOpen(false);
}}
style={{
padding: "6px 14px",
borderRadius: "var(--r)",
border: isEditing ? "1px solid var(--primary)" : "1px solid var(--border)",
background: isEditing ? "var(--primary)" : "transparent",
color: isEditing ? "#fff" : "var(--text-2)",
fontSize: "0.83rem",
fontWeight: 500,
cursor: "pointer",
transition: "all 0.15s ease",
}}
>
{isEditing ? "Done" : "Edit Layout"}
</button>
</div>
</div>
{/* Widget grid */}
<WidgetGrid
layout={layout}
onLayoutChange={handleLayoutChange}
{...(isEditing && { onRemoveWidget: handleRemoveWidget })}
{...(isEditing && { onEditWidget: handleEditWidget })}
isEditing={isEditing}
/>
{/* Widget config dialog */}
{configWidgetId && (
<WidgetConfigDialog
widgetId={configWidgetId}
open
onClose={(): void => {
setConfigWidgetId(null);
}}
/>
)}
{/* Widget picker drawer */}
<WidgetPicker
open={isPickerOpen}
onClose={(): void => {
setIsPickerOpen(false);
}}
onAddWidget={handleAddWidget}
currentLayout={layout}
/>
</div>
);
}