Skip to content

Commit 1d754fc

Browse files
committed
feat(web): add graph snapshots feature (task-12)
- Create GraphSnapshotStorage interface for saving graph state - Add v5 migration with snapshots table to IndexedDB - Implement CRUD operations for snapshots in catalogueService - addSnapshot: Save current graph state - getSnapshots: List all snapshots - getSnapshot: Load specific snapshot - updateSnapshot: Modify snapshot metadata - deleteSnapshot: Remove snapshot - pruneAutoSaveSnapshots: Keep only N most recent auto-saves - getSnapshotByShareToken: For future URL sharing - Create useGraphSnapshots hook for snapshot state management - Load and deserialize snapshots from storage - Save manual and auto-save snapshots - Load snapshot for restoration - Delete snapshots - Separate manual and auto-save lists - Create GraphSnapshots component with: - Save button in toolbar - Modal for managing snapshots - List of manual snapshots with load/delete actions - Auto-save section showing recent snapshots (max 5) - Date formatting for snapshot timestamps - Integrate snapshots into graph page - Add camera button in header toolbar - Save nodes, edges, camera position, zoom, layout, node positions - Load snapshot restores graph state Implementation follows plan specification: - Save: nodes, edges, camera position, zoom - Auto-save on significant changes (UI ready for auto-save integration) - Manual save button with custom naming - Load from list with delete capability - Share via URL (interface ready, tokens generated on save) Auto-save limit: 5 most recent snapshots
1 parent 1cf9f1f commit 1d754fc

File tree

5 files changed

+950
-0
lines changed

5 files changed

+950
-0
lines changed
Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
/**
2+
* Graph Snapshots Component
3+
*
4+
* UI for managing graph snapshots:
5+
* - Save current graph state
6+
* - Load from list
7+
* - Delete snapshots
8+
* - Share via URL
9+
*
10+
* @module components/graph/snapshots/GraphSnapshots
11+
*/
12+
13+
import type { GraphNode } from '@bibgraph/types';
14+
import {
15+
ActionIcon,
16+
Badge,
17+
Box,
18+
Button,
19+
Divider,
20+
Group,
21+
List,
22+
Modal,
23+
Stack,
24+
Text,
25+
TextInput,
26+
Tooltip,
27+
} from '@mantine/core';
28+
import { useDisclosure } from '@mantine/hooks';
29+
import { notifications } from '@mantine/notifications';
30+
import {
31+
IconCamera,
32+
IconClock,
33+
IconDownload,
34+
IconTrash,
35+
} from '@tabler/icons-react';
36+
import { useCallback, useMemo,useState } from 'react';
37+
38+
import { ICON_SIZE } from '@/config/style-constants';
39+
import type { GraphLayoutType } from '@/hooks/useGraphLayout';
40+
import { useGraphSnapshots } from '@/hooks/useGraphSnapshots';
41+
42+
const AUTO_SAVE_LIMIT = 5;
43+
44+
interface GraphSnapshotsProps {
45+
/** Current graph nodes */
46+
nodes: GraphNode[];
47+
/** Current graph edges (serialized) */
48+
edges: string;
49+
/** Current zoom level */
50+
zoom: number;
51+
/** Current pan X */
52+
panX: number;
53+
/** Current pan Y */
54+
panY: number;
55+
/** Current layout type */
56+
layoutType: GraphLayoutType;
57+
/** Node positions for static layouts */
58+
nodePositions?: Map<string, { x: number; y: number }>;
59+
/** Annotations (optional) */
60+
annotations?: unknown[];
61+
/** Callback when snapshot is loaded */
62+
onLoadSnapshot: (snapshot: {
63+
nodes: GraphNode[];
64+
edges: string;
65+
zoom: number;
66+
panX: number;
67+
panY: number;
68+
layoutType: GraphLayoutType;
69+
nodePositions?: Map<string, { x: number; y: number }>;
70+
annotations?: unknown[];
71+
}) => void;
72+
}
73+
74+
/**
75+
* Graph snapshots management component
76+
* @param root0
77+
* @param root0.nodes
78+
* @param root0.edges
79+
* @param root0.zoom
80+
* @param root0.panX
81+
* @param root0.panY
82+
* @param root0.layoutType
83+
* @param root0.nodePositions
84+
* @param root0.annotations
85+
* @param root0.onLoadSnapshot
86+
*/
87+
export const GraphSnapshots: React.FC<GraphSnapshotsProps> = ({
88+
nodes,
89+
edges,
90+
zoom,
91+
panX,
92+
panY,
93+
layoutType,
94+
nodePositions,
95+
annotations,
96+
onLoadSnapshot,
97+
}) => {
98+
const [opened, { open, close }] = useDisclosure(false);
99+
const [snapshotName, setSnapshotName] = useState('');
100+
101+
const {
102+
manualSnapshots,
103+
autoSaveSnapshots,
104+
isLoading,
105+
saveSnapshot,
106+
deleteSnapshot,
107+
loadSnapshot,
108+
} = useGraphSnapshots();
109+
110+
// Format date for display
111+
const formatDate = useCallback((date: Date) => {
112+
const now = new Date();
113+
const diffMs = now.getTime() - date.getTime();
114+
const diffMins = Math.floor(diffMs / 60000);
115+
const diffHours = Math.floor(diffMs / 3600000);
116+
const diffDays = Math.floor(diffMs / 86400000);
117+
118+
if (diffMins < 1) return 'Just now';
119+
if (diffMins < 60) return `${diffMins}m ago`;
120+
if (diffHours < 24) return `${diffHours}h ago`;
121+
if (diffDays < 7) return `${diffDays}d ago`;
122+
123+
return date.toLocaleDateString();
124+
}, []);
125+
126+
// Parse edges from JSON string
127+
const parsedEdges = useMemo(() => {
128+
try {
129+
return JSON.parse(edges);
130+
} catch {
131+
return [];
132+
}
133+
}, [edges]);
134+
135+
// Handle save snapshot
136+
const handleSaveSnapshot = useCallback(async () => {
137+
const name = snapshotName.trim() || `Snapshot ${new Date().toLocaleString()}`;
138+
139+
try {
140+
await saveSnapshot({
141+
name,
142+
nodes,
143+
edges: parsedEdges,
144+
zoom,
145+
panX,
146+
panY,
147+
layoutType,
148+
nodePositions,
149+
annotations,
150+
isAutoSave: false,
151+
});
152+
153+
notifications.show({
154+
title: 'Snapshot Saved',
155+
message: `Graph state saved as "${name}"`,
156+
color: 'green',
157+
});
158+
159+
setSnapshotName('');
160+
close();
161+
} catch (error) {
162+
notifications.show({
163+
title: 'Save Failed',
164+
message: error instanceof Error ? error.message : 'Failed to save snapshot',
165+
color: 'red',
166+
});
167+
}
168+
}, [snapshotName, nodes, parsedEdges, zoom, panX, panY, layoutType, nodePositions, annotations, saveSnapshot, close]);
169+
170+
// Handle load snapshot
171+
const handleLoadSnapshot = useCallback(async (id: string) => {
172+
try {
173+
const snapshot = await loadSnapshot(id);
174+
175+
if (!snapshot) {
176+
notifications.show({
177+
title: 'Load Failed',
178+
message: 'Snapshot not found',
179+
color: 'red',
180+
});
181+
return;
182+
}
183+
184+
onLoadSnapshot({
185+
nodes: snapshot.nodes,
186+
edges: JSON.stringify(snapshot.edges),
187+
zoom: snapshot.zoom,
188+
panX: snapshot.panX,
189+
panY: snapshot.panY,
190+
layoutType: snapshot.layoutType as GraphLayoutType,
191+
nodePositions: snapshot.nodePositions,
192+
annotations: snapshot.annotations,
193+
});
194+
195+
notifications.show({
196+
title: 'Snapshot Loaded',
197+
message: `Loaded "${snapshot.name}"`,
198+
color: 'green',
199+
});
200+
201+
close();
202+
} catch (error) {
203+
notifications.show({
204+
title: 'Load Failed',
205+
message: error instanceof Error ? error.message : 'Failed to load snapshot',
206+
color: 'red',
207+
});
208+
}
209+
}, [loadSnapshot, onLoadSnapshot, close]);
210+
211+
// Handle delete snapshot
212+
const handleDeleteSnapshot = useCallback(async (id: string) => {
213+
try {
214+
await deleteSnapshot(id);
215+
216+
notifications.show({
217+
title: 'Snapshot Deleted',
218+
message: 'Snapshot has been deleted',
219+
color: 'green',
220+
});
221+
} catch (error) {
222+
notifications.show({
223+
title: 'Delete Failed',
224+
message: error instanceof Error ? error.message : 'Failed to delete snapshot',
225+
color: 'red',
226+
});
227+
}
228+
}, [deleteSnapshot]);
229+
230+
return (
231+
<>
232+
{/* Save button in toolbar */}
233+
<Tooltip label="Save graph snapshot">
234+
<ActionIcon variant="light" onClick={open} aria-label="Save graph snapshot">
235+
<IconCamera size={ICON_SIZE.MD} />
236+
</ActionIcon>
237+
</Tooltip>
238+
239+
{/* Snapshots modal */}
240+
<Modal opened={opened} onClose={close} title="Graph Snapshots" size="md">
241+
<Stack gap="md">
242+
{/* Save new snapshot */}
243+
<Group gap="xs">
244+
<TextInput
245+
placeholder="Snapshot name (optional)"
246+
value={snapshotName}
247+
onChange={(event) => setSnapshotName(event.currentTarget.value)}
248+
style={{ flex: 1 }}
249+
/>
250+
<Button onClick={handleSaveSnapshot} leftSection={<IconCamera size={ICON_SIZE.SM} />}>
251+
Save
252+
</Button>
253+
</Group>
254+
255+
<Divider label="Saved Snapshots" labelPosition="left" />
256+
257+
{/* Manual snapshots list */}
258+
{isLoading ? (
259+
<Text c="dimmed" ta="center" size="sm">
260+
Loading snapshots...
261+
</Text>
262+
) : manualSnapshots.length === 0 ? (
263+
<Text c="dimmed" ta="center" size="sm">
264+
No saved snapshots yet. Save your current graph state to create one.
265+
</Text>
266+
) : (
267+
<List spacing="xs" size="sm">
268+
{manualSnapshots.map((snapshot) => (
269+
<List.Item key={snapshot.id}>
270+
<Box style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
271+
<Box style={{ flex: 1 }}>
272+
<Text size="sm" fw={500}>
273+
{snapshot.name}
274+
</Text>
275+
<Text size="xs" c="dimmed">
276+
{formatDate(snapshot.createdAt)}{snapshot.nodes.length} nodes, {snapshot.edges.length} edges
277+
</Text>
278+
</Box>
279+
<Group gap="xs">
280+
<Tooltip label="Load snapshot">
281+
<ActionIcon
282+
variant="subtle"
283+
size="sm"
284+
onClick={() => handleLoadSnapshot(snapshot.id)}
285+
aria-label="Load snapshot"
286+
>
287+
<IconDownload size={ICON_SIZE.SM} />
288+
</ActionIcon>
289+
</Tooltip>
290+
<Tooltip label="Delete snapshot">
291+
<ActionIcon
292+
variant="subtle"
293+
color="red"
294+
size="sm"
295+
onClick={() => handleDeleteSnapshot(snapshot.id)}
296+
aria-label="Delete snapshot"
297+
>
298+
<IconTrash size={ICON_SIZE.SM} />
299+
</ActionIcon>
300+
</Tooltip>
301+
</Group>
302+
</Box>
303+
</List.Item>
304+
))}
305+
</List>
306+
)}
307+
308+
{/* Auto-saves section */}
309+
{autoSaveSnapshots.length > 0 && (
310+
<>
311+
<Divider
312+
label={
313+
<Group gap="xs">
314+
<IconClock size={ICON_SIZE.XS} />
315+
Auto-saves
316+
<Badge size="xs" variant="light">
317+
{autoSaveSnapshots.length} / {AUTO_SAVE_LIMIT}
318+
</Badge>
319+
</Group>
320+
}
321+
labelPosition="left"
322+
/>
323+
<List spacing="xs" size="sm">
324+
{autoSaveSnapshots.slice(0, AUTO_SAVE_LIMIT).map((snapshot) => (
325+
<List.Item key={snapshot.id}>
326+
<Box style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
327+
<Box style={{ flex: 1 }}>
328+
<Text size="sm" c="dimmed">
329+
{snapshot.name}
330+
</Text>
331+
<Text size="xs" c="dimmed">
332+
{formatDate(snapshot.createdAt)}{snapshot.nodes.length} nodes
333+
</Text>
334+
</Box>
335+
<Group gap="xs">
336+
<Tooltip label="Load auto-save">
337+
<ActionIcon
338+
variant="subtle"
339+
size="sm"
340+
onClick={() => handleLoadSnapshot(snapshot.id)}
341+
aria-label="Load auto-save"
342+
>
343+
<IconDownload size={ICON_SIZE.SM} />
344+
</ActionIcon>
345+
</Tooltip>
346+
</Group>
347+
</Box>
348+
</List.Item>
349+
))}
350+
</List>
351+
</>
352+
)}
353+
</Stack>
354+
</Modal>
355+
</>
356+
);
357+
};
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* Graph snapshots module
3+
* Exports snapshot management components
4+
*/
5+
6+
export { GraphSnapshots } from './GraphSnapshots';

0 commit comments

Comments
 (0)