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>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { ChevronLeft, Save, Download, FileJson, FileCode, Image, FileText, Undo2, Redo2, MousePointer2, Hand, Share2, Upload, Cable } from 'lucide-react'
|
import { ChevronLeft, Save, Download, FileJson, FileCode, Image, FileText, Undo2, Redo2, MousePointer2, Hand, FileOutput, Upload, Cable } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export type InteractionMode = 'select' | 'pan' | 'connect'
|
export type InteractionMode = 'select' | 'pan' | 'connect'
|
||||||
@@ -19,7 +19,7 @@ interface DiagramHeaderProps {
|
|||||||
onExportPdf: () => void
|
onExportPdf: () => void
|
||||||
onExportJson: () => void
|
onExportJson: () => void
|
||||||
onExportDrawio: () => void
|
onExportDrawio: () => void
|
||||||
onImportDrawio: () => void
|
onImportDrawio: () => void // draw.io import — triggered from Export menu
|
||||||
onUndo: () => void
|
onUndo: () => void
|
||||||
onRedo: () => void
|
onRedo: () => void
|
||||||
canUndo: boolean
|
canUndo: boolean
|
||||||
@@ -216,55 +216,56 @@ export function DiagramHeader({
|
|||||||
{isSaving ? 'Saving...' : 'Save'}
|
{isSaving ? 'Saving...' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onImportDrawio}
|
|
||||||
className="flex items-center gap-1.5 rounded border border-default px-3 py-1.5 text-xs text-primary hover:border-hover"
|
|
||||||
>
|
|
||||||
<Upload size={14} />
|
|
||||||
Import
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="relative" ref={exportMenuRef}>
|
<div className="relative" ref={exportMenuRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowExportMenu(prev => !prev)}
|
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"
|
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} />
|
<Download size={14} />
|
||||||
Export
|
Export / Import
|
||||||
</button>
|
</button>
|
||||||
{showExportMenu && (
|
{showExportMenu && (
|
||||||
<div className="absolute right-0 top-full z-50 mt-1 w-40 rounded border border-default bg-card py-1 shadow-lg">
|
<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
|
<button
|
||||||
onClick={() => { onExportPng(); setShowExportMenu(false) }}
|
onClick={() => { onExportPng(); setShowExportMenu(false) }}
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
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
|
<Image size={12} /> PNG
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { onExportSvg(); setShowExportMenu(false) }}
|
onClick={() => { onExportSvg(); setShowExportMenu(false) }}
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
||||||
>
|
>
|
||||||
<FileCode size={12} /> Export SVG
|
<FileCode size={12} /> SVG
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { onExportPdf(); setShowExportMenu(false) }}
|
onClick={() => { onExportPdf(); setShowExportMenu(false) }}
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
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
|
<FileText size={12} /> PDF
|
||||||
</button>
|
</button>
|
||||||
{diagramId && (
|
{diagramId && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { onExportJson(); setShowExportMenu(false) }}
|
onClick={() => { onExportJson(); setShowExportMenu(false) }}
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
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
|
<FileJson size={12} /> JSON
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => { onExportDrawio(); setShowExportMenu(false) }}
|
onClick={() => { onExportDrawio(); setShowExportMenu(false) }}
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
||||||
>
|
>
|
||||||
<Share2 size={12} /> Export draw.io
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
129
frontend/src/components/network/KeyboardShortcutsOverlay.tsx
Normal file
129
frontend/src/components/network/KeyboardShortcutsOverlay.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
|
interface ShortcutRow {
|
||||||
|
keys: string[]
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShortcutGroup {
|
||||||
|
title: string
|
||||||
|
rows: ShortcutRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUPS: ShortcutGroup[] = [
|
||||||
|
{
|
||||||
|
title: 'Modes',
|
||||||
|
rows: [
|
||||||
|
{ keys: ['V'], label: 'Select mode' },
|
||||||
|
{ keys: ['H'], label: 'Pan mode' },
|
||||||
|
{ keys: ['C'], label: 'Connect mode' },
|
||||||
|
{ keys: ['Space'], label: 'Temporary pan (hold)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Canvas',
|
||||||
|
rows: [
|
||||||
|
{ keys: ['Ctrl', 'Shift', 'F'], label: 'Fit view' },
|
||||||
|
{ keys: ['Ctrl', 'A'], label: 'Select all' },
|
||||||
|
{ keys: ['Ctrl', 'Z'], label: 'Undo' },
|
||||||
|
{ keys: ['Ctrl', 'Y'], label: 'Redo' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Nodes',
|
||||||
|
rows: [
|
||||||
|
{ keys: ['Ctrl', 'C'], label: 'Copy' },
|
||||||
|
{ keys: ['Ctrl', 'V'], label: 'Paste' },
|
||||||
|
{ keys: ['Ctrl', 'D'], label: 'Duplicate' },
|
||||||
|
{ keys: ['Del'], label: 'Delete selected' },
|
||||||
|
{ keys: [']'], label: 'Bring to front' },
|
||||||
|
{ keys: ['['], label: 'Send to back' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Nudge',
|
||||||
|
rows: [
|
||||||
|
{ keys: ['↑', '↓', '←', '→'], label: 'Move 1px' },
|
||||||
|
{ keys: ['Shift', '↑↓←→'], label: 'Move 10px' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
interface KeyboardShortcutsOverlayProps {
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function Kbd({ children }: { children: string }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded border border-white/10 bg-white/[0.07] px-1.5 text-[10px] font-mono text-muted-foreground">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyboardShortcutsOverlay({ onClose }: KeyboardShortcutsOverlayProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handler)
|
||||||
|
return () => document.removeEventListener('keydown', handler)
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-[2px]"
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-xl rounded-lg border border-default bg-card shadow-2xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-default px-5 py-3.5">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-heading text-sm font-semibold text-heading">Keyboard Shortcuts</h2>
|
||||||
|
<p className="text-[11px] text-muted-foreground">Press <Kbd>?</Kbd> anytime to open this</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded border border-default text-muted-foreground hover:border-hover hover:text-primary"
|
||||||
|
>
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shortcut grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-0 divide-x divide-default">
|
||||||
|
{GROUPS.map((group, gi) => (
|
||||||
|
<div key={group.title} className={gi >= 2 ? 'border-t border-default' : ''}>
|
||||||
|
<div className="px-5 pb-2 pt-4">
|
||||||
|
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{group.title}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{group.rows.map(row => (
|
||||||
|
<div key={row.label} className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-xs text-primary">{row.label}</span>
|
||||||
|
<div className="flex shrink-0 items-center gap-0.5">
|
||||||
|
{row.keys.map((k, i) => (
|
||||||
|
<span key={i} className="flex items-center gap-0.5">
|
||||||
|
{i > 0 && <span className="text-[10px] text-muted-foreground/50">+</span>}
|
||||||
|
<Kbd>{k}</Kbd>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer hint */}
|
||||||
|
<div className="border-t border-default px-5 py-2.5 text-[11px] text-muted-foreground">
|
||||||
|
On Mac, <Kbd>Ctrl</Kbd> = <Kbd>⌘ Cmd</Kbd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ export function useCanvasShortcuts({
|
|||||||
onRedo,
|
onRedo,
|
||||||
onNudge,
|
onNudge,
|
||||||
onSetMode,
|
onSetMode,
|
||||||
|
onToggleShortcuts,
|
||||||
}: {
|
}: {
|
||||||
nodes: Node[]
|
nodes: Node[]
|
||||||
edges: Edge[]
|
edges: Edge[]
|
||||||
@@ -48,6 +49,7 @@ export function useCanvasShortcuts({
|
|||||||
onRedo: () => void
|
onRedo: () => void
|
||||||
onNudge: (dx: number, dy: number) => void
|
onNudge: (dx: number, dy: number) => void
|
||||||
onSetMode: (mode: 'select' | 'pan' | 'connect') => void
|
onSetMode: (mode: 'select' | 'pan' | 'connect') => void
|
||||||
|
onToggleShortcuts: () => void
|
||||||
}) {
|
}) {
|
||||||
const { getNodes, fitView, screenToFlowPosition, setNodes: rfSetNodes } = useReactFlow()
|
const { getNodes, fitView, screenToFlowPosition, setNodes: rfSetNodes } = useReactFlow()
|
||||||
const clipboardRef = useRef<ClipboardData | null>(null)
|
const clipboardRef = useRef<ClipboardData | null>(null)
|
||||||
@@ -279,12 +281,15 @@ export function useCanvasShortcuts({
|
|||||||
} else if (e.key === '[' && !ctrl) {
|
} else if (e.key === '[' && !ctrl) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
sendSelectedToBack()
|
sendSelectedToBack()
|
||||||
|
} else if (e.key === '?' && !ctrl) {
|
||||||
|
e.preventDefault()
|
||||||
|
onToggleShortcuts()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown)
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [copyNodes, pasteNodes, duplicateNodes, selectAll, fitView, bringSelectedToFront, sendSelectedToBack, onUndo, onRedo, onNudge, onSetMode])
|
}, [copyNodes, pasteNodes, duplicateNodes, selectAll, fitView, bringSelectedToFront, sendSelectedToBack, onUndo, onRedo, onNudge, onSetMode, onToggleShortcuts])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
copyNodes,
|
copyNodes,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { DeviceToolbar } from '@/components/network/panels/DeviceToolbar'
|
|||||||
import { PropertiesPanel } from '@/components/network/panels/PropertiesPanel'
|
import { PropertiesPanel } from '@/components/network/panels/PropertiesPanel'
|
||||||
import { AIAssistPanel } from '@/components/network/panels/AIAssistPanel'
|
import { AIAssistPanel } from '@/components/network/panels/AIAssistPanel'
|
||||||
import { CanvasEmptyPrompt } from '@/components/network/CanvasEmptyPrompt'
|
import { CanvasEmptyPrompt } from '@/components/network/CanvasEmptyPrompt'
|
||||||
|
import { KeyboardShortcutsOverlay } from '@/components/network/KeyboardShortcutsOverlay'
|
||||||
import { networkDiagramsApi, deviceTypesApi } from '@/api'
|
import { networkDiagramsApi, deviceTypesApi } from '@/api'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
import { exportToDrawio } from '@/lib/drawio-export'
|
import { exportToDrawio } from '@/lib/drawio-export'
|
||||||
@@ -73,6 +74,7 @@ function DiagramEditorInner() {
|
|||||||
const [isDragOver, setIsDragOver] = useState(false)
|
const [isDragOver, setIsDragOver] = useState(false)
|
||||||
|
|
||||||
const [interactionMode, setInteractionMode] = useState<InteractionMode>('select')
|
const [interactionMode, setInteractionMode] = useState<InteractionMode>('select')
|
||||||
|
const [showShortcuts, setShowShortcuts] = useState(false)
|
||||||
|
|
||||||
const canvasRef = useRef<HTMLDivElement | null>(null)
|
const canvasRef = useRef<HTMLDivElement | null>(null)
|
||||||
const drawioImportRef = useRef<HTMLInputElement>(null)
|
const drawioImportRef = useRef<HTMLInputElement>(null)
|
||||||
@@ -161,6 +163,7 @@ function DiagramEditorInner() {
|
|||||||
onRedo: redo,
|
onRedo: redo,
|
||||||
onNudge,
|
onNudge,
|
||||||
onSetMode: setInteractionMode,
|
onSetMode: setInteractionMode,
|
||||||
|
onToggleShortcuts: () => setShowShortcuts(v => !v),
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleNodesChange: typeof onNodesChange = useCallback((changes) => {
|
const handleNodesChange: typeof onNodesChange = useCallback((changes) => {
|
||||||
@@ -850,6 +853,14 @@ function DiagramEditorInner() {
|
|||||||
{nodes.length === 0 && !loading && (
|
{nodes.length === 0 && !loading && (
|
||||||
<CanvasEmptyPrompt onGenerate={handleAIGenerate} />
|
<CanvasEmptyPrompt onGenerate={handleAIGenerate} />
|
||||||
)}
|
)}
|
||||||
|
{/* Keyboard shortcut hint button — bottom-right corner */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowShortcuts(true)}
|
||||||
|
title="Keyboard shortcuts (?)"
|
||||||
|
className="absolute bottom-4 right-4 z-10 flex h-7 w-7 items-center justify-center rounded-full border border-default bg-card text-[11px] font-semibold text-muted-foreground hover:border-hover hover:text-primary"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{nodes.length > 0 && (
|
{nodes.length > 0 && (
|
||||||
<AIAssistPanel
|
<AIAssistPanel
|
||||||
@@ -953,6 +964,9 @@ function DiagramEditorInner() {
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleDrawioFileChange}
|
onChange={handleDrawioFileChange}
|
||||||
/>
|
/>
|
||||||
|
{showShortcuts && (
|
||||||
|
<KeyboardShortcutsOverlay onClose={() => setShowShortcuts(false)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Plus, Search, Network, MoreHorizontal, Upload, ChevronDown } from 'lucide-react'
|
import { Plus, Search, Network, MoreHorizontal, Upload, ChevronDown, FileJson, FileOutput } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { networkDiagramsApi } from '@/api'
|
import { networkDiagramsApi } from '@/api'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
@@ -39,7 +39,9 @@ export default function NetworkDiagramsPage() {
|
|||||||
const [clientSearch, setClientSearch] = useState('')
|
const [clientSearch, setClientSearch] = useState('')
|
||||||
const [menuOpenId, setMenuOpenId] = useState<string | null>(null)
|
const [menuOpenId, setMenuOpenId] = useState<string | null>(null)
|
||||||
const [confirmArchiveId, setConfirmArchiveId] = useState<string | null>(null)
|
const [confirmArchiveId, setConfirmArchiveId] = useState<string | null>(null)
|
||||||
|
const [importMenuOpen, setImportMenuOpen] = useState(false)
|
||||||
const clientDropdownRef = useRef<HTMLDivElement>(null)
|
const clientDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
const importMenuRef = useRef<HTMLDivElement>(null)
|
||||||
const drawioListImportRef = useRef<HTMLInputElement>(null)
|
const drawioListImportRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -53,6 +55,17 @@ export default function NetworkDiagramsPage() {
|
|||||||
return () => document.removeEventListener('mousedown', handleClick)
|
return () => document.removeEventListener('mousedown', handleClick)
|
||||||
}, [clientDropdownOpen])
|
}, [clientDropdownOpen])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!importMenuOpen) return
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
if (importMenuRef.current && !importMenuRef.current.contains(e.target as Node)) {
|
||||||
|
setImportMenuOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClick)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick)
|
||||||
|
}, [importMenuOpen])
|
||||||
|
|
||||||
const loadDiagrams = useCallback(async () => {
|
const loadDiagrams = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = {}
|
const params: Record<string, string> = {}
|
||||||
@@ -171,13 +184,41 @@ export default function NetworkDiagramsPage() {
|
|||||||
<p className="mt-1 text-sm text-muted-foreground">Visual network topology documentation for your clients</p>
|
<p className="mt-1 text-sm text-muted-foreground">Visual network topology documentation for your clients</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
{/* Single "Import" dropdown replacing two separate buttons */}
|
||||||
onClick={handleImport}
|
<div className="relative" ref={importMenuRef}>
|
||||||
className="flex items-center gap-1.5 rounded border border-default px-3 py-2 text-sm text-primary hover:border-hover"
|
<button
|
||||||
>
|
onClick={() => setImportMenuOpen(prev => !prev)}
|
||||||
<Upload size={14} />
|
className="flex items-center gap-1.5 rounded border border-default px-3 py-2 text-sm text-primary hover:border-hover"
|
||||||
Import
|
>
|
||||||
</button>
|
<Upload size={14} />
|
||||||
|
Import
|
||||||
|
<ChevronDown size={12} className="text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
{importMenuOpen && (
|
||||||
|
<div className="absolute right-0 top-full z-50 mt-1 w-44 rounded border border-default bg-card py-1 shadow-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => { setImportMenuOpen(false); handleImport() }}
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated"
|
||||||
|
>
|
||||||
|
<FileJson size={13} />
|
||||||
|
<div className="text-left">
|
||||||
|
<div>Import JSON</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground">ResolutionFlow format</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setImportMenuOpen(false); drawioListImportRef.current?.click() }}
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated"
|
||||||
|
>
|
||||||
|
<FileOutput size={13} />
|
||||||
|
<div className="text-left">
|
||||||
|
<div>Import draw.io</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground">.drawio or .xml file</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
ref={drawioListImportRef}
|
ref={drawioListImportRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -185,13 +226,6 @@ export default function NetworkDiagramsPage() {
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleListDrawioImport}
|
onChange={handleListDrawioImport}
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
onClick={() => drawioListImportRef.current?.click()}
|
|
||||||
className="flex items-center gap-1.5 rounded border border-default px-3 py-2 text-sm text-primary hover:border-hover"
|
|
||||||
>
|
|
||||||
<Upload size={14} />
|
|
||||||
Import draw.io
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/network-diagrams/new')}
|
onClick={() => navigate('/network-diagrams/new')}
|
||||||
className="flex items-center gap-1.5 rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90"
|
className="flex items-center gap-1.5 rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90"
|
||||||
@@ -266,16 +300,111 @@ export default function NetworkDiagramsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && diagrams.length === 0 && (
|
{!loading && diagrams.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-20">
|
<div className="rounded-lg border border-default bg-card overflow-hidden">
|
||||||
<Network size={48} className="mb-4 text-muted-foreground" />
|
<div className="grid md:grid-cols-[1fr_380px]">
|
||||||
<h2 className="font-heading text-lg font-semibold text-heading">No network maps yet</h2>
|
{/* Left: mini topology preview */}
|
||||||
<p className="mt-1 text-sm text-muted-foreground">Create your first network diagram to get started</p>
|
<div className="relative flex items-center justify-center bg-[#0e1016] p-8 md:p-12 min-h-[280px]">
|
||||||
<button
|
{/* Dot grid background */}
|
||||||
onClick={() => navigate('/network-diagrams/new')}
|
<svg className="absolute inset-0 h-full w-full opacity-20" xmlns="http://www.w3.org/2000/svg">
|
||||||
className="mt-4 rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90"
|
<defs>
|
||||||
>
|
<pattern id="dots" x="0" y="0" width="24" height="24" patternUnits="userSpaceOnUse">
|
||||||
Create First Diagram
|
<circle cx="1" cy="1" r="1" fill="#4f5666" />
|
||||||
</button>
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#dots)" />
|
||||||
|
</svg>
|
||||||
|
{/* Static topology SVG */}
|
||||||
|
<svg viewBox="0 0 460 240" className="relative w-full max-w-md" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
{/* Edges */}
|
||||||
|
<line x1="230" y1="48" x2="130" y2="130" stroke="#2a2e3a" strokeWidth="1.5" />
|
||||||
|
<line x1="230" y1="48" x2="230" y2="130" stroke="#2a2e3a" strokeWidth="1.5" />
|
||||||
|
<line x1="230" y1="48" x2="330" y2="130" stroke="#2a2e3a" strokeWidth="1.5" />
|
||||||
|
<line x1="130" y1="130" x2="80" y2="210" stroke="#2a2e3a" strokeWidth="1.5" strokeDasharray="4 3" />
|
||||||
|
<line x1="130" y1="130" x2="180" y2="210" stroke="#2a2e3a" strokeWidth="1.5" strokeDasharray="4 3" />
|
||||||
|
<line x1="330" y1="130" x2="280" y2="210" stroke="#2a2e3a" strokeWidth="1.5" strokeDasharray="4 3" />
|
||||||
|
<line x1="330" y1="130" x2="380" y2="210" stroke="#2a2e3a" strokeWidth="1.5" strokeDasharray="4 3" />
|
||||||
|
{/* Firewall node */}
|
||||||
|
<rect x="196" y="16" width="68" height="40" rx="6" fill="#1e2028" stroke="#3d4252" strokeWidth="1" />
|
||||||
|
<rect x="207" y="22" width="14" height="10" rx="2" fill="#f87171" opacity="0.9" />
|
||||||
|
<rect x="225" y="22" width="6" height="10" rx="1" fill="#3d4252" />
|
||||||
|
<rect x="235" y="22" width="20" height="10" rx="2" fill="#3d4252" />
|
||||||
|
<text x="230" y="46" textAnchor="middle" fill="#848b9b" fontSize="9" fontFamily="monospace">Firewall</text>
|
||||||
|
{/* Switch node */}
|
||||||
|
<rect x="96" y="108" width="68" height="40" rx="6" fill="#1e2028" stroke="#3d4252" strokeWidth="1" />
|
||||||
|
<circle cx="112" cy="124" r="4" fill="#34d399" opacity="0.9" />
|
||||||
|
<circle cx="122" cy="124" r="4" fill="#34d399" opacity="0.9" />
|
||||||
|
<circle cx="132" cy="124" r="4" fill="#fbbf24" opacity="0.8" />
|
||||||
|
<circle cx="142" cy="124" r="4" fill="#34d399" opacity="0.9" />
|
||||||
|
<text x="130" y="140" textAnchor="middle" fill="#848b9b" fontSize="9" fontFamily="monospace">Core Switch</text>
|
||||||
|
{/* Router node */}
|
||||||
|
<rect x="196" y="108" width="68" height="40" rx="6" fill="#1e2028" stroke="#60a5fa" strokeWidth="1" />
|
||||||
|
<circle cx="230" cy="124" r="10" fill="none" stroke="#60a5fa" strokeWidth="1.5" opacity="0.7" />
|
||||||
|
<circle cx="230" cy="124" r="5" fill="none" stroke="#60a5fa" strokeWidth="1" opacity="0.5" />
|
||||||
|
<line x1="220" y1="124" x2="240" y2="124" stroke="#60a5fa" strokeWidth="1" opacity="0.6" />
|
||||||
|
<text x="230" y="140" textAnchor="middle" fill="#93c5fd" fontSize="9" fontFamily="monospace">Router</text>
|
||||||
|
{/* Server farm */}
|
||||||
|
<rect x="296" y="108" width="68" height="40" rx="6" fill="#1e2028" stroke="#3d4252" strokeWidth="1" />
|
||||||
|
<rect x="308" y="116" width="44" height="7" rx="2" fill="#2a2e3a" />
|
||||||
|
<rect x="308" y="127" width="44" height="7" rx="2" fill="#2a2e3a" />
|
||||||
|
<circle cx="345" cy="119.5" r="2" fill="#34d399" opacity="0.9" />
|
||||||
|
<circle cx="345" cy="130.5" r="2" fill="#34d399" opacity="0.9" />
|
||||||
|
<text x="330" y="140" textAnchor="middle" fill="#848b9b" fontSize="9" fontFamily="monospace">Servers</text>
|
||||||
|
{/* Leaf nodes */}
|
||||||
|
<rect x="46" y="190" width="52" height="34" rx="5" fill="#1e2028" stroke="#2a2e3a" strokeWidth="1" />
|
||||||
|
<text x="72" y="211" textAnchor="middle" fill="#4f5666" fontSize="8" fontFamily="monospace">PC × 12</text>
|
||||||
|
<rect x="154" y="190" width="52" height="34" rx="5" fill="#1e2028" stroke="#2a2e3a" strokeWidth="1" />
|
||||||
|
<text x="180" y="211" textAnchor="middle" fill="#4f5666" fontSize="8" fontFamily="monospace">AP × 4</text>
|
||||||
|
<rect x="254" y="190" width="52" height="34" rx="5" fill="#1e2028" stroke="#2a2e3a" strokeWidth="1" />
|
||||||
|
<text x="280" y="211" textAnchor="middle" fill="#4f5666" fontSize="8" fontFamily="monospace">NAS</text>
|
||||||
|
<rect x="354" y="190" width="52" height="34" rx="5" fill="#1e2028" stroke="#2a2e3a" strokeWidth="1" />
|
||||||
|
<text x="380" y="211" textAnchor="middle" fill="#4f5666" fontSize="8" fontFamily="monospace">VM × 6</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: value prop + CTA */}
|
||||||
|
<div className="flex flex-col justify-center border-l border-default p-8">
|
||||||
|
<div className="mb-1 flex items-center gap-2">
|
||||||
|
<Network size={14} className="text-accent" />
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-accent">Network Maps</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="font-heading text-xl font-bold text-heading leading-snug">
|
||||||
|
Document every client's infrastructure — once
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
|
||||||
|
Drag-and-drop topology diagrams that live next to your tickets. Generate a first draft from a plain-text description, then keep it up to date as networks change.
|
||||||
|
</p>
|
||||||
|
<ul className="mt-4 space-y-2 text-xs text-muted-foreground">
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<div className="h-1.5 w-1.5 rounded-full bg-accent" />
|
||||||
|
AI topology generation from natural language
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<div className="h-1.5 w-1.5 rounded-full bg-accent" />
|
||||||
|
Export to PNG, SVG, PDF, or draw.io
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<div className="h-1.5 w-1.5 rounded-full bg-accent" />
|
||||||
|
Shared across your whole team instantly
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div className="mt-6 flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/network-diagrams/new')}
|
||||||
|
className="flex items-center justify-center gap-1.5 rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
Create Network Map
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setImportMenuOpen(true) }}
|
||||||
|
className="flex items-center justify-center gap-1.5 rounded border border-default px-4 py-2 text-sm text-primary hover:border-hover"
|
||||||
|
>
|
||||||
|
<Upload size={14} />
|
||||||
|
Import existing
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user