feat: add NetworkCanvas wrapper and DiagramHeader components
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
162
frontend/src/components/network/DiagramHeader.tsx
Normal file
162
frontend/src/components/network/DiagramHeader.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ChevronLeft, Save, Download, FileJson, Image, FileText } from 'lucide-react'
|
||||
|
||||
interface DiagramHeaderProps {
|
||||
name: string
|
||||
clientName: string | null
|
||||
isSaving: boolean
|
||||
lastSavedAt: Date | null
|
||||
diagramId: string | null
|
||||
onNameChange: (name: string) => void
|
||||
onSave: () => void
|
||||
onExportPng: () => void
|
||||
onExportPdf: () => void
|
||||
onExportJson: () => void
|
||||
}
|
||||
|
||||
export function DiagramHeader({
|
||||
name,
|
||||
clientName,
|
||||
isSaving,
|
||||
lastSavedAt,
|
||||
diagramId,
|
||||
onNameChange,
|
||||
onSave,
|
||||
onExportPng,
|
||||
onExportPdf,
|
||||
onExportJson,
|
||||
}: DiagramHeaderProps) {
|
||||
const navigate = useNavigate()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editValue, setEditValue] = useState(name)
|
||||
const [showExportMenu, setShowExportMenu] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const exportMenuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (editing && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
inputRef.current.select()
|
||||
}
|
||||
}, [editing])
|
||||
|
||||
useEffect(() => {
|
||||
setEditValue(name)
|
||||
}, [name])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showExportMenu) return
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (exportMenuRef.current && !exportMenuRef.current.contains(e.target as HTMLElement)) {
|
||||
setShowExportMenu(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [showExportMenu])
|
||||
|
||||
const handleConfirmName = useCallback(() => {
|
||||
setEditing(false)
|
||||
if (editValue.trim() && editValue !== name) {
|
||||
onNameChange(editValue.trim())
|
||||
} else {
|
||||
setEditValue(name)
|
||||
}
|
||||
}, [editValue, name, onNameChange])
|
||||
|
||||
const formatLastSaved = () => {
|
||||
if (!lastSavedAt) return null
|
||||
const diff = Date.now() - lastSavedAt.getTime()
|
||||
if (diff < 60_000) return 'Saved just now'
|
||||
const mins = Math.floor(diff / 60_000)
|
||||
return `Saved ${mins}m ago`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-14 items-center gap-3 border-b border-default bg-card px-4">
|
||||
<button
|
||||
onClick={() => navigate('/network-diagrams')}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
Network Maps
|
||||
</button>
|
||||
|
||||
<div className="mx-2 h-5 w-px bg-border-default" />
|
||||
|
||||
{editing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={editValue}
|
||||
onChange={e => setEditValue(e.target.value)}
|
||||
onBlur={handleConfirmName}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleConfirmName(); if (e.key === 'Escape') { setEditing(false); setEditValue(name) } }}
|
||||
className="rounded border border-accent bg-input px-2 py-1 text-sm font-heading font-semibold text-heading focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="text-sm font-heading font-semibold text-heading hover:text-accent"
|
||||
>
|
||||
{name || 'Untitled Diagram'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{clientName && (
|
||||
<span className="rounded-full bg-elevated px-2 py-0.5 text-[10px] text-muted-foreground">
|
||||
{clientName}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{lastSavedAt && (
|
||||
<span className="text-[10px] text-muted-foreground">{formatLastSaved()}</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
className="flex items-center gap-1.5 rounded bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50"
|
||||
>
|
||||
<Save size={14} />
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
|
||||
<div className="relative" ref={exportMenuRef}>
|
||||
<button
|
||||
onClick={() => setShowExportMenu(prev => !prev)}
|
||||
className="flex items-center gap-1.5 rounded border border-default px-3 py-1.5 text-xs text-primary hover:border-hover"
|
||||
>
|
||||
<Download size={14} />
|
||||
Export
|
||||
</button>
|
||||
{showExportMenu && (
|
||||
<div className="absolute right-0 top-full z-50 mt-1 w-40 rounded border border-default bg-card py-1 shadow-lg">
|
||||
<button
|
||||
onClick={() => { onExportPng(); setShowExportMenu(false) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
<Image size={12} /> Export PNG
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onExportPdf(); setShowExportMenu(false) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
<FileText size={12} /> Export PDF
|
||||
</button>
|
||||
{diagramId && (
|
||||
<button
|
||||
onClick={() => { onExportJson(); setShowExportMenu(false) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
<FileJson size={12} /> Export JSON
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
87
frontend/src/components/network/NetworkCanvas.tsx
Normal file
87
frontend/src/components/network/NetworkCanvas.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
BackgroundVariant,
|
||||
type OnConnect,
|
||||
type OnNodesChange,
|
||||
type OnEdgesChange,
|
||||
type Node,
|
||||
type Edge,
|
||||
} from '@xyflow/react'
|
||||
import { nodeTypes } from './nodes/nodeTypes'
|
||||
import { edgeTypes } from './edges/edgeTypes'
|
||||
|
||||
interface NetworkCanvasProps {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
onNodesChange: OnNodesChange
|
||||
onEdgesChange: OnEdgesChange
|
||||
onConnect: OnConnect
|
||||
onNodeSelect: (node: Node | null) => void
|
||||
onEdgeSelect: (edge: Edge | null) => void
|
||||
onDrop: (event: React.DragEvent) => void
|
||||
onDragOver: (event: React.DragEvent) => void
|
||||
}
|
||||
|
||||
export function NetworkCanvas({
|
||||
nodes,
|
||||
edges,
|
||||
onNodesChange,
|
||||
onEdgesChange,
|
||||
onConnect,
|
||||
onNodeSelect,
|
||||
onEdgeSelect,
|
||||
onDrop,
|
||||
onDragOver,
|
||||
}: NetworkCanvasProps) {
|
||||
const handleSelectionChange = useCallback(({ nodes: selectedNodes, edges: selectedEdges }: { nodes: Node[]; edges: Edge[] }) => {
|
||||
if (selectedNodes.length === 1) {
|
||||
onNodeSelect(selectedNodes[0])
|
||||
onEdgeSelect(null)
|
||||
} else if (selectedEdges.length === 1) {
|
||||
onEdgeSelect(selectedEdges[0])
|
||||
onNodeSelect(null)
|
||||
} else {
|
||||
onNodeSelect(null)
|
||||
onEdgeSelect(null)
|
||||
}
|
||||
}, [onNodeSelect, onEdgeSelect])
|
||||
|
||||
const handlePaneClick = useCallback(() => {
|
||||
onNodeSelect(null)
|
||||
onEdgeSelect(null)
|
||||
}, [onNodeSelect, onEdgeSelect])
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onPaneClick={handlePaneClick}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
defaultEdgeOptions={{ type: 'connection' }}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
multiSelectionKeyCode="Shift"
|
||||
fitView
|
||||
className="bg-page"
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} color="var(--color-border-default)" gap={20} size={1} />
|
||||
<Controls className="!border-default !bg-card [&>button]:!border-default [&>button]:!bg-card [&>button]:!fill-text-primary" />
|
||||
<MiniMap
|
||||
nodeColor="var(--color-bg-elevated)"
|
||||
maskColor="rgba(0,0,0,0.5)"
|
||||
className="!border-default !bg-card"
|
||||
position="bottom-right"
|
||||
/>
|
||||
</ReactFlow>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user