All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
158 lines
4.7 KiB
TypeScript
158 lines
4.7 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 { 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 [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]
|
|
);
|
|
|
|
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>
|
|
<button
|
|
onClick={() => {
|
|
setIsEditing((prev) => !prev);
|
|
}}
|
|
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>
|
|
|
|
{/* Widget grid */}
|
|
<WidgetGrid
|
|
layout={layout}
|
|
onLayoutChange={handleLayoutChange}
|
|
{...(isEditing && { onRemoveWidget: handleRemoveWidget })}
|
|
isEditing={isEditing}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|