- 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>
512 lines
24 KiB
TypeScript
512 lines
24 KiB
TypeScript
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import { Plus, Search, Network, MoreHorizontal, Upload, ChevronDown, FileJson, FileOutput } from 'lucide-react'
|
||
import { cn } from '@/lib/utils'
|
||
import { networkDiagramsApi } from '@/api'
|
||
import { toast } from '@/lib/toast'
|
||
import { CATEGORY_COLORS } from '@/components/network/nodes/deviceRegistry'
|
||
import type { NetworkDiagramListItem, DiagramImportData } from '@/types'
|
||
|
||
const OTHER_COLOR = '#4f5666'
|
||
|
||
function TopologyBar({ categoryCounts, nodeCount }: { categoryCounts: Record<string, number>; nodeCount: number }) {
|
||
if (nodeCount === 0) return null
|
||
const sorted = Object.entries(categoryCounts).sort(([, a], [, b]) => b - a)
|
||
const tooltipLabel = sorted.map(([cat, count]) => `${count} ${cat}`).join(' · ')
|
||
return (
|
||
<div className="group/bar relative flex h-2 w-full overflow-hidden rounded-full" title={tooltipLabel}>
|
||
{sorted.map(([cat, count]) => (
|
||
<div
|
||
key={cat}
|
||
style={{
|
||
width: `${(count / nodeCount) * 100}%`,
|
||
backgroundColor: CATEGORY_COLORS[cat] ?? OTHER_COLOR,
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function NetworkDiagramsPage() {
|
||
const navigate = useNavigate()
|
||
const [diagrams, setDiagrams] = useState<NetworkDiagramListItem[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [search, setSearch] = useState('')
|
||
const [clientFilter, setClientFilter] = useState<string | null>(null)
|
||
const [clientOptions, setClientOptions] = useState<string[]>([])
|
||
const [clientDropdownOpen, setClientDropdownOpen] = useState(false)
|
||
const [clientSearch, setClientSearch] = useState('')
|
||
const [menuOpenId, setMenuOpenId] = useState<string | null>(null)
|
||
const [confirmArchiveId, setConfirmArchiveId] = useState<string | null>(null)
|
||
const [importMenuOpen, setImportMenuOpen] = useState(false)
|
||
const clientDropdownRef = useRef<HTMLDivElement>(null)
|
||
const importMenuRef = useRef<HTMLDivElement>(null)
|
||
const drawioListImportRef = useRef<HTMLInputElement>(null)
|
||
|
||
useEffect(() => {
|
||
if (!clientDropdownOpen) return
|
||
const handleClick = (e: MouseEvent) => {
|
||
if (clientDropdownRef.current && !clientDropdownRef.current.contains(e.target as Node)) {
|
||
setClientDropdownOpen(false)
|
||
}
|
||
}
|
||
document.addEventListener('mousedown', handleClick)
|
||
return () => document.removeEventListener('mousedown', handleClick)
|
||
}, [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 () => {
|
||
try {
|
||
const params: Record<string, string> = {}
|
||
if (clientFilter) params.client_name = clientFilter
|
||
if (search) params.search = search
|
||
const data = await networkDiagramsApi.list(params)
|
||
setDiagrams(data)
|
||
} catch {
|
||
toast.error('Failed to load diagrams')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [clientFilter, search])
|
||
|
||
const loadClients = useCallback(async () => {
|
||
try {
|
||
const clients = await networkDiagramsApi.listClients()
|
||
setClientOptions(clients)
|
||
} catch { /* ignore */ }
|
||
}, [])
|
||
|
||
useEffect(() => { loadDiagrams() }, [loadDiagrams])
|
||
useEffect(() => { loadClients() }, [loadClients])
|
||
|
||
const filteredClients = useMemo(() => {
|
||
if (!clientSearch) return clientOptions
|
||
const lower = clientSearch.toLowerCase()
|
||
return clientOptions.filter(c => c.toLowerCase().includes(lower))
|
||
}, [clientOptions, clientSearch])
|
||
|
||
const handleDuplicate = useCallback(async (id: string) => {
|
||
try {
|
||
const dup = await networkDiagramsApi.duplicate(id)
|
||
toast.success(`Created: ${dup.name}`)
|
||
loadDiagrams()
|
||
} catch {
|
||
toast.error('Failed to duplicate')
|
||
}
|
||
setMenuOpenId(null)
|
||
}, [loadDiagrams])
|
||
|
||
const handleArchive = useCallback(async (id: string) => {
|
||
try {
|
||
await networkDiagramsApi.archive(id)
|
||
toast.success('Diagram archived')
|
||
loadDiagrams()
|
||
} catch {
|
||
toast.error('Failed to archive')
|
||
}
|
||
setMenuOpenId(null)
|
||
setConfirmArchiveId(null)
|
||
}, [loadDiagrams])
|
||
|
||
const handleImport = useCallback(async () => {
|
||
const input = document.createElement('input')
|
||
input.type = 'file'
|
||
input.accept = '.json'
|
||
input.onchange = async (e) => {
|
||
const file = (e.target as HTMLInputElement).files?.[0]
|
||
if (!file) return
|
||
try {
|
||
const text = await file.text()
|
||
const data = JSON.parse(text) as DiagramImportData
|
||
const result = await networkDiagramsApi.importJson(data)
|
||
if (result.warnings.length > 0) {
|
||
toast.warning(`Imported with warnings: ${result.warnings.join(', ')}`)
|
||
} else {
|
||
toast.success('Diagram imported')
|
||
}
|
||
navigate(`/network-diagrams/${result.diagram.id}`)
|
||
} catch {
|
||
toast.error('Failed to import — check that the file is a valid diagram JSON')
|
||
}
|
||
}
|
||
input.click()
|
||
}, [navigate])
|
||
|
||
const handleListDrawioImport = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0]
|
||
if (!file) return
|
||
e.target.value = ''
|
||
try {
|
||
const { parseDrawioXml } = await import('@/lib/drawio-import')
|
||
const text = await file.text()
|
||
const { nodes: importedNodes, edges: importedEdges, warnings } = parseDrawioXml(text)
|
||
const result = await networkDiagramsApi.importJson({
|
||
schemaVersion: 1,
|
||
name: file.name.replace(/\.drawio$/i, '') || 'Imported Diagram',
|
||
client_name: null,
|
||
description: null,
|
||
nodes: importedNodes,
|
||
edges: importedEdges,
|
||
})
|
||
const allWarnings = [...warnings, ...result.warnings]
|
||
if (allWarnings.length > 0) {
|
||
toast.warning(`Imported with ${allWarnings.length} warning(s)`)
|
||
} else {
|
||
toast.success('Imported successfully')
|
||
}
|
||
navigate(`/network-diagrams/${result.diagram.id}`)
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : 'Unknown error'
|
||
toast.error(`Import failed: ${msg}`)
|
||
}
|
||
}, [navigate])
|
||
|
||
const formatDate = (dateStr: string) => {
|
||
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||
}
|
||
|
||
return (
|
||
<div className="mx-auto max-w-6xl px-6 py-8">
|
||
<div className="mb-6 flex items-start justify-between">
|
||
<div>
|
||
<h1 className="font-heading text-2xl font-bold text-heading">Network Maps</h1>
|
||
<p className="mt-1 text-sm text-muted-foreground">Visual network topology documentation for your clients</p>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
{/* Single "Import" dropdown replacing two separate buttons */}
|
||
<div className="relative" ref={importMenuRef}>
|
||
<button
|
||
onClick={() => setImportMenuOpen(prev => !prev)}
|
||
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
|
||
<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
|
||
ref={drawioListImportRef}
|
||
type="file"
|
||
accept=".drawio,.xml"
|
||
className="hidden"
|
||
onChange={handleListDrawioImport}
|
||
/>
|
||
<button
|
||
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"
|
||
>
|
||
<Plus size={14} />
|
||
New Diagram
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mb-6 flex gap-3">
|
||
<div className="relative flex-1">
|
||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||
<input
|
||
type="text"
|
||
placeholder="Search diagrams..."
|
||
value={search}
|
||
onChange={e => setSearch(e.target.value)}
|
||
className="w-full rounded border border-default bg-input pl-9 pr-3 py-2 text-sm text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
|
||
/>
|
||
</div>
|
||
<div className="relative w-48" ref={clientDropdownRef}>
|
||
<button
|
||
onClick={() => setClientDropdownOpen(prev => !prev)}
|
||
className="flex w-full items-center justify-between rounded border border-default bg-input px-3 py-2 text-sm text-primary"
|
||
>
|
||
<span className={clientFilter ? 'text-primary' : 'text-muted-foreground'}>
|
||
{clientFilter || 'All clients'}
|
||
</span>
|
||
<ChevronDown size={14} className="shrink-0 text-muted-foreground" />
|
||
</button>
|
||
{clientDropdownOpen && (
|
||
<div className="absolute left-0 top-full z-50 mt-1 w-full rounded border border-default bg-card shadow-lg">
|
||
<div className="p-2">
|
||
<input
|
||
type="text"
|
||
placeholder="Search clients..."
|
||
value={clientSearch}
|
||
onChange={e => setClientSearch(e.target.value)}
|
||
className="w-full rounded border border-default bg-input px-2 py-1 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
|
||
autoFocus
|
||
/>
|
||
</div>
|
||
<div className="max-h-48 overflow-y-auto">
|
||
<button
|
||
onClick={() => { setClientFilter(null); setClientDropdownOpen(false); setClientSearch('') }}
|
||
className={cn('w-full px-3 py-1.5 text-left text-xs hover:bg-elevated', !clientFilter && 'text-accent')}
|
||
>
|
||
All clients
|
||
</button>
|
||
{filteredClients.map(c => (
|
||
<button
|
||
key={c}
|
||
onClick={() => { setClientFilter(c); setClientDropdownOpen(false); setClientSearch('') }}
|
||
className={cn('w-full px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated', clientFilter === c && 'text-accent')}
|
||
>
|
||
{c}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{loading && (
|
||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||
{[1, 2, 3].map(i => (
|
||
<div key={i} className="h-40 animate-pulse rounded-lg border border-default bg-card" />
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{!loading && diagrams.length === 0 && (
|
||
<div className="rounded-lg border border-default bg-card overflow-hidden">
|
||
<div className="grid md:grid-cols-[1fr_380px]">
|
||
{/* Left: mini topology preview */}
|
||
<div className="relative flex items-center justify-center bg-[#0e1016] p-8 md:p-12 min-h-[280px]">
|
||
{/* Dot grid background */}
|
||
<svg className="absolute inset-0 h-full w-full opacity-20" xmlns="http://www.w3.org/2000/svg">
|
||
<defs>
|
||
<pattern id="dots" x="0" y="0" width="24" height="24" patternUnits="userSpaceOnUse">
|
||
<circle cx="1" cy="1" r="1" fill="#4f5666" />
|
||
</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>
|
||
)}
|
||
|
||
{!loading && diagrams.length > 0 && (
|
||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||
{diagrams.map(d => (
|
||
<div
|
||
key={d.id}
|
||
onClick={() => navigate(`/network-diagrams/${d.id}`)}
|
||
className="group relative cursor-pointer rounded-lg border border-default bg-card p-4 hover:border-hover"
|
||
>
|
||
<div className="mb-2 flex items-start justify-between">
|
||
<h3 className="font-heading text-sm font-semibold text-heading">{d.name}</h3>
|
||
<button
|
||
onClick={e => { e.stopPropagation(); setMenuOpenId(menuOpenId === d.id ? null : d.id) }}
|
||
className="rounded p-1 text-muted-foreground opacity-0 hover:bg-elevated group-hover:opacity-100"
|
||
>
|
||
<MoreHorizontal size={14} />
|
||
</button>
|
||
</div>
|
||
{d.client_name && (
|
||
<span className="mb-2 inline-block rounded-full bg-elevated px-2 py-0.5 text-[10px] text-muted-foreground">
|
||
{d.client_name}
|
||
</span>
|
||
)}
|
||
{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} />
|
||
</div>
|
||
)}
|
||
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
|
||
<span>{d.node_count} device{d.node_count !== 1 ? 's' : ''}</span>
|
||
<span>{formatDate(d.created_at)}</span>
|
||
</div>
|
||
|
||
{menuOpenId === d.id && (
|
||
<div className="absolute right-2 top-10 z-50 w-44 rounded border border-default bg-card py-1 shadow-lg">
|
||
{confirmArchiveId === d.id ? (
|
||
<>
|
||
<p className="px-3 py-1.5 text-[10px] text-muted-foreground">Archive this diagram?</p>
|
||
<div className="flex gap-1 px-2 pb-1.5">
|
||
<button
|
||
onClick={e => { e.stopPropagation(); setConfirmArchiveId(null) }}
|
||
className="flex-1 rounded border border-default px-2 py-1 text-[10px] text-primary hover:bg-elevated"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={e => { e.stopPropagation(); handleArchive(d.id) }}
|
||
className="flex-1 rounded bg-red-500/20 px-2 py-1 text-[10px] font-medium text-red-400 hover:bg-red-500/30"
|
||
>
|
||
Archive
|
||
</button>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<button
|
||
onClick={e => { e.stopPropagation(); navigate(`/network-diagrams/${d.id}`) }}
|
||
className="w-full px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated"
|
||
>
|
||
Open
|
||
</button>
|
||
<button
|
||
onClick={e => { e.stopPropagation(); handleDuplicate(d.id) }}
|
||
className="w-full px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated"
|
||
>
|
||
Duplicate
|
||
</button>
|
||
<button
|
||
onClick={e => { e.stopPropagation(); setConfirmArchiveId(d.id) }}
|
||
className="w-full px-3 py-1.5 text-left text-xs text-red-400 hover:bg-elevated"
|
||
>
|
||
Archive…
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|