Files
stack/apps/web/src/lib/hooks/useLayout.ts
Jason Woltje 12fa093f58
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fix(SEC-WEB-33+35): Fix Mermaid error display + useWorkspaceId error logging
SEC-WEB-33: Replace raw diagram source and detailed error messages in
MermaidViewer error UI with a generic "Diagram rendering failed" message.
Detailed errors are logged to console.error for debugging only.

SEC-WEB-35: Add console.warn in useWorkspaceId when no workspace ID is
found in localStorage, making it easier to distinguish "no workspace
selected" from silent hook failure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 18:16:07 -06:00

248 lines
6.4 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";
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);
setLayouts(parsed as Record<string, LayoutConfig>);
}
// 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]: {
id: DEFAULT_LAYOUT_NAME,
name: "Default Layout",
layout: [],
},
});
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;
}