feat(network): thumbnail generation on save, shown on list page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-04-14 01:22:51 +00:00
parent 7efa22454d
commit c8f571db39
6 changed files with 84 additions and 2 deletions

View File

@@ -51,6 +51,10 @@ export const networkDiagramsApi = {
return response.data
},
async uploadThumbnail(id: string, dataUrl: string): Promise<void> {
await apiClient.post(`/network-diagrams/${id}/thumbnail`, { data_url: dataUrl })
},
async aiGenerate(data: AIGenerateRequest): Promise<AIGenerateResponse> {
const response = await apiClient.post<AIGenerateResponse>('/network-diagrams/ai-generate', data)
return response.data

View File

@@ -334,21 +334,51 @@ function DiagramEditorInner() {
nodes: serializeNodes(),
edges: serializeEdges(),
}
let savedId: string | null = diagramIdRef.current
if (diagramIdRef.current) {
await networkDiagramsApi.update(diagramIdRef.current, payload)
} else {
const created = await networkDiagramsApi.create(payload)
savedId = created.id
setDiagramId(created.id)
navigate(`/network-diagrams/${created.id}`, { replace: true })
}
setIsDirty(false)
setLastSavedAt(new Date())
// Generate thumbnail in the background — don't block save UX on failure
if (savedId && nodes.length > 0) {
try {
const { toPng } = await import('html-to-image')
const THUMB_W = 480
const THUMB_H = 300
const bounds = getNodesBounds(nodes)
const viewport = getViewportForBounds(bounds, THUMB_W, THUMB_H, 0.5, 2, 0.1)
const flowEl = document.querySelector('.react-flow__viewport') as HTMLElement | null
if (flowEl) {
const dataUrl = await toPng(flowEl, {
backgroundColor: '#16181f',
width: THUMB_W,
height: THUMB_H,
style: {
width: `${THUMB_W}px`,
height: `${THUMB_H}px`,
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`,
transformOrigin: 'top left',
},
})
await networkDiagramsApi.uploadThumbnail(savedId, dataUrl)
}
} catch {
// Thumbnail failure is silent — doesn't affect save success
}
}
} catch {
toast.error('Failed to save diagram')
} finally {
setIsSaving(false)
}
}, [name, clientName, assetName, description, serializeNodes, serializeEdges, navigate])
}, [name, clientName, assetName, description, serializeNodes, serializeEdges, navigate, nodes])
useEffect(() => {
const interval = setInterval(() => {

View File

@@ -260,6 +260,21 @@ export default function NetworkDiagramsPage() {
{d.description && (
<p className="mb-3 line-clamp-2 text-xs text-muted-foreground">{d.description}</p>
)}
{/* Thumbnail preview */}
{d.thumbnail_url ? (
<div className="mb-2 overflow-hidden rounded border border-default">
<img
src={d.thumbnail_url}
alt={d.name}
className="h-[120px] w-full object-cover"
onError={e => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
</div>
) : (
<div className="mb-2 flex h-[120px] items-center justify-center rounded border border-default bg-elevated">
<Network size={32} className="text-muted-foreground/30" />
</div>
)}
{d.node_count > 0 && (
<div className="mb-2">
<TopologyBar categoryCounts={d.category_counts} nodeCount={d.node_count} />

View File

@@ -73,6 +73,7 @@ export interface NetworkDiagramListItem {
description: string | null
node_count: number
category_counts: Record<string, number>
thumbnail_url?: string | null
created_by: string | null
created_at: string
updated_at: string