Files
resolutionflow/frontend/src/components/network/DiagramHeader.tsx
chihlasm 015df1fe5f fix(network): consolidate import buttons, redesign empty state, add shortcut overlay
- Import/Export button in editor header: removed standalone Import button, moved
  draw.io import into Export/Import dropdown with labelled sections; fixes
  conceptual trap where Import implied operating on the current diagram
- List page: replaced two identical Upload-icon Import buttons with a single
  dropdown (Import JSON / Import draw.io) with format descriptions
- Empty state: replaced icon-in-box with a horizontal card featuring a static
  SVG topology preview, MSP-specific value prop, and dual CTAs
- Keyboard shortcuts: new KeyboardShortcutsOverlay component (4-group grid),
  triggered by ? key or the ? button pinned to the canvas bottom-right corner;
  wired into useCanvasShortcuts hook
- Fixed Share2 → FileOutput icon for draw.io export (Share2 = send to someone,
  FileOutput = export file format)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 04:49:25 +00:00

276 lines
9.5 KiB
TypeScript

import { useState, useCallback, useRef, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { ChevronLeft, Save, Download, FileJson, FileCode, Image, FileText, Undo2, Redo2, MousePointer2, Hand, FileOutput, Upload, Cable } from 'lucide-react'
import { cn } from '@/lib/utils'
export type InteractionMode = 'select' | 'pan' | 'connect'
interface DiagramHeaderProps {
name: string
clientName: string | null
isDirty: boolean
isSaving: boolean
lastSavedAt: Date | null
diagramId: string | null
onNameChange: (name: string) => void
onSave: () => void
onExportPng: () => void
onExportSvg: () => void
onExportPdf: () => void
onExportJson: () => void
onExportDrawio: () => void
onImportDrawio: () => void // draw.io import — triggered from Export menu
onUndo: () => void
onRedo: () => void
canUndo: boolean
canRedo: boolean
interactionMode: InteractionMode
onModeChange: (mode: InteractionMode) => void
}
export function DiagramHeader({
name,
clientName,
isDirty,
isSaving,
lastSavedAt,
diagramId,
onNameChange,
onSave,
onExportPng,
onExportSvg,
onExportPdf,
onExportJson,
onExportDrawio,
onImportDrawio,
onUndo,
onRedo,
canUndo,
canRedo,
interactionMode,
onModeChange,
}: 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
// eslint-disable-next-line react-hooks/purity
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" />
<div className="flex items-center gap-1">
<button
onClick={onUndo}
disabled={!canUndo}
title="Undo (Ctrl+Z)"
className="p-1.5 rounded text-muted-foreground hover:text-primary hover:bg-elevated disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<Undo2 size={16} />
</button>
<button
onClick={onRedo}
disabled={!canRedo}
title="Redo (Ctrl+Y)"
className="p-1.5 rounded text-muted-foreground hover:text-primary hover:bg-elevated disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<Redo2 size={16} />
</button>
</div>
<div className="mx-2 h-5 w-px bg-border-default" />
{/* Interaction mode toggle */}
<div className="flex items-center overflow-hidden rounded border border-default">
<button
onClick={() => onModeChange('select')}
title="Select (V)"
className={cn(
'flex items-center gap-1.5 px-2.5 py-1.5 text-xs transition-colors',
interactionMode === 'select'
? 'bg-elevated text-primary'
: 'text-muted-foreground hover:text-primary hover:bg-elevated/50',
)}
>
<MousePointer2 size={15} />
<span className="hidden sm:inline">Select</span>
</button>
<button
onClick={() => onModeChange('pan')}
title="Pan (H)"
className={cn(
'flex items-center gap-1.5 border-l border-default px-2.5 py-1.5 text-xs transition-colors',
interactionMode === 'pan'
? 'bg-elevated text-primary'
: 'text-muted-foreground hover:text-primary hover:bg-elevated/50',
)}
>
<Hand size={15} />
<span className="hidden sm:inline">Pan</span>
</button>
<button
onClick={() => onModeChange('connect')}
title="Connect (C)"
className={cn(
'flex items-center gap-1.5 border-l border-default px-2.5 py-1.5 text-xs transition-colors',
interactionMode === 'connect'
? 'bg-elevated text-primary'
: 'text-muted-foreground hover:text-primary hover:bg-elevated/50',
)}
>
<Cable size={15} />
<span className="hidden sm:inline">Connect</span>
</button>
</div>
<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" />
{isDirty && !isSaving ? (
<span className="text-[10px] text-amber-400">Unsaved changes</span>
) : lastSavedAt ? (
<span className="text-[10px] text-muted-foreground">{formatLastSaved()}</span>
) : null}
<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 / Import
</button>
{showExportMenu && (
<div className="absolute right-0 top-full z-50 mt-1 w-44 rounded border border-default bg-card py-1 shadow-lg">
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Export as</div>
<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} /> PNG
</button>
<button
onClick={() => { onExportSvg(); setShowExportMenu(false) }}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
>
<FileCode size={12} /> SVG
</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} /> 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} /> JSON
</button>
)}
<button
onClick={() => { onExportDrawio(); setShowExportMenu(false) }}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
>
<FileOutput size={12} /> draw.io
</button>
<div className="my-1 border-t border-default" />
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Import</div>
<button
onClick={() => { onImportDrawio(); setShowExportMenu(false) }}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
>
<Upload size={12} /> draw.io file
</button>
</div>
)}
</div>
</div>
)
}