Files
rack-planner/client/src/components/mapper/MapToolbar.tsx
T
jason f4e139972e Add VLAN management page at /vlans
- Full CRUD: create, inline-edit, delete with confirm dialog
- Table shows VLAN ID, name, description, color swatch
- Add-VLAN form at top; hover shows edit/delete actions per row
- Route registered in App.tsx under ProtectedRoute
- VLANs nav button added to RackToolbar and MapToolbar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:02:16 -05:00

147 lines
5.6 KiB
TypeScript

import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Download, Server, LogOut, ChevronDown, Tag } from 'lucide-react';
import { toast } from 'sonner';
import { toPng } from 'html-to-image';
import { useReactFlow } from '@xyflow/react';
import { Button } from '../ui/Button';
import { useAuthStore } from '../../store/useAuthStore';
import type { ServiceMapSummary } from '../../types';
import { apiClient } from '../../api/client';
interface MapToolbarProps {
maps: ServiceMapSummary[];
activeMapId: string | null;
activeMapName: string;
onSelectMap: (id: string) => void;
onCreateMap: () => void;
onPopulate: () => void;
flowContainerRef: React.RefObject<HTMLDivElement | null>;
}
export function MapToolbar({
maps,
activeMapId,
activeMapName,
onSelectMap,
onCreateMap,
onPopulate,
flowContainerRef,
}: MapToolbarProps) {
const navigate = useNavigate();
const { logout } = useAuthStore();
const { fitView } = useReactFlow();
const [exporting, setExporting] = useState(false);
const [mapDropdownOpen, setMapDropdownOpen] = useState(false);
async function handleExport() {
if (!flowContainerRef.current) return;
setExporting(true);
const toastId = toast.loading('Exporting…');
// Temporarily hide React Flow UI chrome
const minimap = flowContainerRef.current.querySelector('.react-flow__minimap') as HTMLElement | null;
const controls = flowContainerRef.current.querySelector('.react-flow__controls') as HTMLElement | null;
if (minimap) minimap.style.display = 'none';
if (controls) controls.style.display = 'none';
try {
const dataUrl = await toPng(flowContainerRef.current, { cacheBust: true });
const link = document.createElement('a');
link.download = `rackmapper-map-${activeMapName.replace(/\s+/g, '-')}-${Date.now()}.png`;
link.href = dataUrl;
link.click();
toast.success('Exported', { id: toastId });
} catch {
toast.error('Export failed', { id: toastId });
} finally {
if (minimap) minimap.style.display = '';
if (controls) controls.style.display = '';
setExporting(false);
}
}
async function handleLogout() {
await logout();
navigate('/login', { replace: true });
}
return (
<div className="flex items-center justify-between px-4 py-2 bg-slate-800 border-b border-slate-700 z-10">
{/* Left */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-blue-500 rounded flex items-center justify-center">
<svg width="14" height="14" viewBox="0 0 18 18" fill="none">
<rect x="1" y="2" width="16" height="3" rx="1" fill="white" />
<rect x="1" y="7" width="16" height="3" rx="1" fill="white" opacity="0.7" />
<rect x="1" y="12" width="16" height="3" rx="1" fill="white" opacity="0.4" />
</svg>
</div>
<span className="text-sm font-bold text-slate-200 tracking-wider">RACKMAPPER</span>
</div>
<span className="text-slate-600 text-xs hidden sm:inline">Service Mapper</span>
{/* Map selector */}
<div className="relative">
<button
onClick={() => setMapDropdownOpen((v) => !v)}
className="flex items-center gap-1 px-2 py-1 rounded bg-slate-700 border border-slate-600 text-sm text-slate-200 hover:bg-slate-600 transition-colors"
>
<span className="max-w-[140px] truncate">{activeMapId ? activeMapName : 'Select map…'}</span>
<ChevronDown size={12} />
</button>
{mapDropdownOpen && (
<div className="absolute top-full left-0 mt-1 w-52 bg-slate-800 border border-slate-700 rounded-lg shadow-xl z-50 overflow-hidden">
{maps.map((m) => (
<button
key={m.id}
className={`w-full text-left px-3 py-2 text-sm hover:bg-slate-700 transition-colors ${m.id === activeMapId ? 'text-blue-400' : 'text-slate-200'}`}
onClick={() => { onSelectMap(m.id); setMapDropdownOpen(false); }}
>
{m.name}
</button>
))}
<div className="border-t border-slate-700">
<button
className="w-full text-left px-3 py-2 text-sm text-blue-400 hover:bg-slate-700 transition-colors"
onClick={() => { onCreateMap(); setMapDropdownOpen(false); }}
>
+ New map
</button>
</div>
</div>
)}
</div>
</div>
{/* Right */}
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" onClick={() => navigate('/rack')}>
<Server size={14} />
Rack Planner
</Button>
<Button size="sm" variant="secondary" onClick={() => navigate('/vlans')}>
<Tag size={14} />
VLANs
</Button>
{activeMapId && (
<>
<Button size="sm" variant="secondary" onClick={onPopulate} title="Import all rack modules as nodes">
Import Rack
</Button>
<Button size="sm" variant="secondary" onClick={() => fitView({ padding: 0.1 })}>
Fit View
</Button>
<Button size="sm" variant="secondary" onClick={handleExport} loading={exporting} disabled={exporting}>
<Download size={14} />
Export PNG
</Button>
</>
)}
<Button size="sm" variant="ghost" onClick={handleLogout} aria-label="Sign out">
<LogOut size={14} />
</Button>
</div>
</div>
);
}