Files
stack/apps/web/src/lib/hooks/useLayout.ts
Jason Woltje ab902250f8
All checks were successful
ci/woodpecker/push/web Pipeline was successful
feat(web-hud): seed default layout with orchestration widgets
2026-02-17 16:07:09 -06:00

319 lines
7.7 KiB
TypeScript

/**
* Hook for managing widget layouts
*/
import { useCallback, useState, useEffect } from "react";
import type { WidgetPlacement, LayoutConfig } from "@mosaic/shared";
import { safeJsonParse, isLayoutConfigRecord } from "@/lib/utils/safe-json";
const STORAGE_KEY = "mosaic-layout";
const DEFAULT_LAYOUT_NAME = "default";
/**
* Local storage key for user's workspace preference
*/
const WORKSPACE_KEY = "mosaic-workspace-id";
function createDefaultLayout(): LayoutConfig {
return {
id: DEFAULT_LAYOUT_NAME,
name: "Default Layout",
layout: [
{
i: "tasks-1",
x: 0,
y: 0,
w: 2,
h: 3,
minW: 1,
minH: 2,
isDraggable: true,
isResizable: true,
},
{
i: "calendar-1",
x: 2,
y: 0,
w: 2,
h: 2,
minW: 1,
minH: 2,
isDraggable: true,
isResizable: true,
},
{
i: "agent-status-1",
x: 2,
y: 2,
w: 2,
h: 2,
minW: 1,
minH: 1,
isDraggable: true,
isResizable: true,
},
{
i: "orchestrator-events-1",
x: 0,
y: 3,
w: 2,
h: 2,
minW: 1,
minH: 1,
isDraggable: true,
isResizable: true,
},
{
i: "quick-capture-1",
x: 2,
y: 4,
w: 2,
h: 1,
minW: 1,
minH: 1,
isDraggable: true,
isResizable: true,
},
],
};
}
interface UseLayoutReturn {
layouts: Record<string, LayoutConfig>;
currentLayout: LayoutConfig | undefined;
currentLayoutId: string;
isLoading: boolean;
updateLayout: (layoutItems: WidgetPlacement[]) => void;
addWidget: (widget: WidgetPlacement) => void;
removeWidget: (widgetId: string) => void;
updateWidget: (widgetId: string, updates: Partial<WidgetPlacement>) => void;
createLayout: (name: string) => string;
deleteLayout: (layoutId: string) => void;
renameLayout: (layoutId: string, name: string) => void;
switchLayout: (layoutId: string) => void;
resetLayout: () => void;
}
/**
* Hook to manage widget layout state
*/
export function useLayout(): UseLayoutReturn {
const [layouts, setLayouts] = useState<Record<string, LayoutConfig>>({});
const [currentLayoutId, setCurrentLayoutId] = useState<string>(DEFAULT_LAYOUT_NAME);
const [isLoading, setIsLoading] = useState(true);
// Load layouts from localStorage on mount with runtime type validation
useEffect(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const emptyFallback: Record<string, LayoutConfig> = {};
const parsed = safeJsonParse(stored, isLayoutConfigRecord, emptyFallback);
const parsedLayouts = parsed as Record<string, LayoutConfig>;
if (Object.keys(parsedLayouts).length > 0) {
setLayouts(parsedLayouts);
} else {
setLayouts({
[DEFAULT_LAYOUT_NAME]: createDefaultLayout(),
});
}
} else {
setLayouts({
[DEFAULT_LAYOUT_NAME]: createDefaultLayout(),
});
}
// Load current layout ID preference
const storedLayoutId = localStorage.getItem(`${STORAGE_KEY}-current`);
if (storedLayoutId) {
setCurrentLayoutId(storedLayoutId);
}
} catch (error) {
console.error("Failed to load layouts from localStorage:", error);
} finally {
setIsLoading(false);
}
}, []);
// Save layouts to localStorage whenever they change
useEffect(() => {
if (!isLoading) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(layouts));
localStorage.setItem(`${STORAGE_KEY}-current`, currentLayoutId);
} catch (error) {
console.error("Failed to save layouts to localStorage:", error);
}
}
}, [layouts, currentLayoutId, isLoading]);
const currentLayout = layouts[currentLayoutId];
const updateLayout = useCallback(
(layoutItems: WidgetPlacement[]) => {
setLayouts((prev) => ({
...prev,
[currentLayoutId]: {
id: currentLayoutId,
name: prev[currentLayoutId]?.name ?? "My Layout",
layout: layoutItems,
},
}));
},
[currentLayoutId]
);
const addWidget = useCallback(
(widget: WidgetPlacement) => {
setLayouts((prev) => {
const currentLayoutData = prev[currentLayoutId];
if (!currentLayoutData) {
return prev;
}
return {
...prev,
[currentLayoutId]: {
...currentLayoutData,
layout: [...currentLayoutData.layout, widget],
},
};
});
},
[currentLayoutId]
);
const removeWidget = useCallback(
(widgetId: string) => {
setLayouts((prev) => {
const currentLayoutData = prev[currentLayoutId];
if (!currentLayoutData) {
return prev;
}
return {
...prev,
[currentLayoutId]: {
...currentLayoutData,
layout: currentLayoutData.layout.filter((w) => w.i !== widgetId),
},
};
});
},
[currentLayoutId]
);
const updateWidget = useCallback(
(widgetId: string, updates: Partial<WidgetPlacement>) => {
setLayouts((prev) => {
const currentLayoutData = prev[currentLayoutId];
if (!currentLayoutData) {
return prev;
}
return {
...prev,
[currentLayoutId]: {
...currentLayoutData,
layout: currentLayoutData.layout.map((w) =>
w.i === widgetId ? { ...w, ...updates } : w
),
},
};
});
},
[currentLayoutId]
);
const createLayout = useCallback((name: string) => {
const id = `layout-${String(Date.now())}`;
setLayouts((prev) => ({
...prev,
[id]: {
id,
name,
layout: [],
},
}));
setCurrentLayoutId(id);
return id;
}, []);
const deleteLayout = useCallback(
(layoutId: string) => {
setLayouts((prev) => {
const { [layoutId]: _deleted, ...rest } = prev;
// If we deleted the current layout, switch to default
if (layoutId === currentLayoutId) {
const remainingIds = Object.keys(rest);
setCurrentLayoutId(remainingIds[0] ?? DEFAULT_LAYOUT_NAME);
}
return rest;
});
},
[currentLayoutId]
);
const renameLayout = useCallback((layoutId: string, name: string) => {
setLayouts((prev) => {
const existing = prev[layoutId];
if (!existing) return prev;
return {
...prev,
[layoutId]: {
...existing,
name,
},
};
});
}, []);
const resetLayout = useCallback(() => {
setLayouts({
[DEFAULT_LAYOUT_NAME]: createDefaultLayout(),
});
setCurrentLayoutId(DEFAULT_LAYOUT_NAME);
}, []);
return {
layouts,
currentLayout,
currentLayoutId,
isLoading,
updateLayout,
addWidget,
removeWidget,
updateWidget,
createLayout,
deleteLayout,
renameLayout,
switchLayout: setCurrentLayoutId,
resetLayout,
};
}
/**
* Hook to get the current workspace ID
*/
export function useWorkspaceId(): string | null {
const [workspaceId, setWorkspaceId] = useState<string | null>(null);
useEffect(() => {
try {
const stored = localStorage.getItem(WORKSPACE_KEY);
if (stored) {
setWorkspaceId(stored);
} else {
console.warn(
`useWorkspaceId: No workspace ID found in localStorage (key: "${WORKSPACE_KEY}"). ` +
"This may indicate no workspace has been selected yet."
);
}
} catch (error) {
console.error("Failed to load workspace ID from localStorage:", error);
}
}, []);
return workspaceId;
}