All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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>
248 lines
6.4 KiB
TypeScript
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;
|
|
}
|