Initial scaffold: full-stack RackMapper application

Complete project scaffold with working auth, REST API, Prisma/SQLite
schema, Docker config, and React frontend for both Rack Planner and
Service Mapper modules. Both server and client pass TypeScript strict
mode with zero errors. Initial migration applied.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 21:48:56 -05:00
parent 61a4d37d94
commit 231de3d005
79 changed files with 12983 additions and 0 deletions

View File

@@ -0,0 +1,107 @@
import { useState, type FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { useAuthStore } from '../../store/useAuthStore';
import { Button } from '../ui/Button';
export function LoginPage() {
const navigate = useNavigate();
const { login } = useAuthStore();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!username.trim() || !password) return;
setLoading(true);
try {
await login(username.trim(), password);
navigate('/rack', { replace: true });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Login failed');
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen bg-[#0f1117] flex items-center justify-center p-4">
<div className="w-full max-w-sm">
{/* Logo / wordmark */}
<div className="text-center mb-8">
<div className="inline-flex items-center gap-2 mb-2">
<div className="w-8 h-8 bg-blue-500 rounded flex items-center justify-center">
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<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-xl font-bold text-white tracking-wider">RACKMAPPER</span>
</div>
<p className="text-slate-500 text-sm">Network infrastructure management</p>
</div>
{/* Card */}
<form
onSubmit={handleSubmit}
className="bg-slate-800 border border-slate-700 rounded-xl p-6 space-y-4 shadow-2xl"
>
<div className="space-y-1">
<label htmlFor="username" className="block text-sm font-medium text-slate-300">
Username
</label>
<input
id="username"
type="text"
autoComplete="username"
autoFocus
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-slate-100 text-sm placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
placeholder="admin"
/>
</div>
<div className="space-y-1">
<label htmlFor="password" className="block text-sm font-medium text-slate-300">
Password
</label>
<input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-slate-100 text-sm placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
placeholder="••••••••"
/>
</div>
<Button
type="submit"
disabled={loading || !username.trim() || !password}
loading={loading}
className="w-full mt-2"
>
Sign in
</Button>
</form>
<p className="text-center text-xs text-slate-600 mt-4">
Credentials are set via Docker environment variables.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import type { ReactNode } from 'react';
import { Navigate } from 'react-router-dom';
import { useAuthStore } from '../../store/useAuthStore';
interface ProtectedRouteProps {
children: ReactNode;
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated } = useAuthStore();
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,142 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Download, Server, LogOut, ChevronDown } 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>
{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>
);
}

View File

@@ -0,0 +1,270 @@
/**
* ServiceMapper — React Flow canvas for service/infrastructure mapping.
*
* SCAFFOLD STATUS:
* ✅ Canvas renders with all node types
* ✅ Map list, select, create via toolbar
* ✅ Auto-populate from rack modules
* ✅ Node drag + debounced position save
* ✅ Edge creation by connecting handles
* ✅ Minimap, controls, dot background
* ✅ PNG export
* ⚠️ Right-click context menus (canvas + node + edge) — TODO
* ⚠️ Node edit modal (label, color, link to module) — TODO
* ⚠️ Edge type/animation toggle — TODO
* ⚠️ Multi-select operations — functional but no toolbar actions
*/
import { useEffect, useRef, useCallback, useState } from 'react';
import {
ReactFlow,
Background,
Controls,
MiniMap,
addEdge,
useNodesState,
useEdgesState,
type Node,
type Edge,
type OnConnect,
type NodeChange,
type EdgeChange,
BackgroundVariant,
ReactFlowProvider,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { toast } from 'sonner';
import { DeviceNode } from './nodes/DeviceNode';
import { ServiceNode as ServiceNodeComponent } from './nodes/ServiceNode';
import { DatabaseNode } from './nodes/DatabaseNode';
import { ApiNode } from './nodes/ApiNode';
import { ExternalNode } from './nodes/ExternalNode';
import { VlanNode } from './nodes/VlanNode';
import { FirewallNode } from './nodes/FirewallNode';
import { LBNode } from './nodes/LBNode';
import { UserNode } from './nodes/UserNode';
import { NoteNode } from './nodes/NoteNode';
import { MapToolbar } from './MapToolbar';
import { useMapStore } from '../../store/useMapStore';
import { apiClient } from '../../api/client';
import type { ServiceMap } from '../../types';
const NODE_TYPES = {
DEVICE: DeviceNode,
SERVICE: ServiceNodeComponent,
DATABASE: DatabaseNode,
API: ApiNode,
EXTERNAL: ExternalNode,
VLAN: VlanNode,
FIREWALL: FirewallNode,
LOAD_BALANCER: LBNode,
USER: UserNode,
NOTE: NoteNode,
};
function toFlowNodes(map: ServiceMap): Node[] {
return map.nodes.map((n) => ({
id: n.id,
type: n.nodeType,
position: { x: n.positionX, y: n.positionY },
data: {
label: n.label,
color: n.color,
icon: n.icon,
metadata: n.metadata,
module: n.module,
},
}));
}
function toFlowEdges(map: ServiceMap): Edge[] {
return map.edges.map((e) => ({
id: e.id,
source: e.sourceId,
target: e.targetId,
label: e.label,
type: e.edgeType,
animated: e.animated,
}));
}
function ServiceMapperInner() {
const { maps, activeMap, fetchMaps, loadMap, createMap, setActiveMap } = useMapStore();
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const flowContainerRef = useRef<HTMLDivElement>(null);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Load maps list on mount
useEffect(() => {
fetchMaps().catch(() => toast.error('Failed to load maps'));
}, [fetchMaps]);
// When active map changes, update flow state
useEffect(() => {
if (activeMap) {
setNodes(toFlowNodes(activeMap));
setEdges(toFlowEdges(activeMap));
} else {
setNodes([]);
setEdges([]);
}
}, [activeMap, setNodes, setEdges]);
// Debounced node position save (500ms after drag end)
const handleNodesChange = useCallback(
(changes: NodeChange<Node>[]) => {
onNodesChange(changes);
const positionChanges = changes.filter(
(c): c is NodeChange<Node> & { type: 'position'; dragging: false } =>
c.type === 'position' && !(c as { dragging?: boolean }).dragging
);
if (positionChanges.length === 0) return;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(async () => {
for (const change of positionChanges) {
const nodeId = (change as { id: string }).id;
const position = (change as { position?: { x: number; y: number } }).position;
if (!position) continue;
try {
await apiClient.nodes.update(nodeId, {
positionX: position.x,
positionY: position.y,
});
} catch {
// Silent — position drift on failure is acceptable
}
}
}, 500);
},
[onNodesChange]
);
const handleEdgesChange = useCallback(
(changes: EdgeChange<Edge>[]) => {
onEdgesChange(changes);
},
[onEdgesChange]
);
const onConnect: OnConnect = useCallback(
async (connection) => {
if (!activeMap) return;
try {
const edge = await apiClient.maps.addEdge(activeMap.id, {
sourceId: connection.source,
targetId: connection.target,
});
setEdges((eds) =>
addEdge(
{
id: edge.id,
source: edge.sourceId,
target: edge.targetId,
type: edge.edgeType,
animated: edge.animated,
},
eds
)
);
} catch {
toast.error('Failed to create connection');
}
},
[activeMap, setEdges]
);
async function handleSelectMap(id: string) {
try {
await loadMap(id);
} catch {
toast.error('Failed to load map');
}
}
async function handleCreateMap() {
const name = prompt('Map name:');
if (!name?.trim()) return;
try {
const map = await createMap(name.trim());
setActiveMap(map);
} catch {
toast.error('Failed to create map');
}
}
async function handlePopulate() {
if (!activeMap) return;
try {
const updated = await apiClient.maps.populate(activeMap.id);
setActiveMap(updated);
toast.success('Rack modules imported');
} catch {
toast.error('Failed to import rack modules');
}
}
return (
<div className="flex flex-col h-screen bg-[#0f1117]">
<MapToolbar
maps={maps}
activeMapId={activeMap?.id ?? null}
activeMapName={activeMap?.name ?? ''}
onSelectMap={handleSelectMap}
onCreateMap={handleCreateMap}
onPopulate={handlePopulate}
flowContainerRef={flowContainerRef}
/>
<div ref={flowContainerRef} className="flex-1 relative">
{!activeMap ? (
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
<p className="text-slate-300 font-medium">No map selected</p>
<p className="text-slate-500 text-sm">
Select a map from the toolbar or create a new one.
</p>
</div>
) : (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={NODE_TYPES}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={onConnect}
snapToGrid
snapGrid={[15, 15]}
fitView
deleteKeyCode="Delete"
className="bg-[#1e2433]"
>
<Background
variant={BackgroundVariant.Dots}
gap={20}
size={1}
color="#2d3748"
/>
<Controls className="!bg-slate-800 !border-slate-700 !shadow-xl" />
<MiniMap
className="!bg-slate-900 !border-slate-700"
nodeColor="#475569"
maskColor="rgba(15,17,23,0.7)"
/>
</ReactFlow>
)}
</div>
</div>
);
}
export function ServiceMapper() {
return (
<ReactFlowProvider>
<ServiceMapperInner />
</ReactFlowProvider>
);
}

View File

@@ -0,0 +1,24 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Zap } from 'lucide-react';
export const ApiNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'API';
const method = (data as { method?: string }).method;
return (
<div className={`min-w-[140px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden ${selected ? 'ring-2 ring-yellow-500 border-yellow-500' : 'border-yellow-700'}`}>
<Handle type="target" position={Position.Top} className="!bg-yellow-400 !border-yellow-600" />
<div className="px-3 py-2 flex items-center gap-2">
<Zap size={13} className="text-yellow-400 shrink-0" />
<span className="text-xs font-medium text-slate-100 truncate flex-1">{label}</span>
{method && (
<span className="text-[10px] px-1 py-0.5 rounded bg-yellow-900/60 text-yellow-300 border border-yellow-700/50 font-mono shrink-0">
{method}
</span>
)}
</div>
<Handle type="source" position={Position.Bottom} className="!bg-yellow-400 !border-yellow-600" />
</div>
);
});
ApiNode.displayName = 'ApiNode';

View File

@@ -0,0 +1,18 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Database } from 'lucide-react';
export const DatabaseNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'Database';
return (
<div className={`min-w-[140px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden ${selected ? 'ring-2 ring-teal-500 border-teal-500' : 'border-teal-700'}`}>
<Handle type="target" position={Position.Top} className="!bg-teal-400 !border-teal-600" />
<div className="px-3 py-2 flex items-center gap-2">
<Database size={13} className="text-teal-400 shrink-0" />
<span className="text-xs font-medium text-slate-100 truncate">{label}</span>
</div>
<Handle type="source" position={Position.Bottom} className="!bg-teal-400 !border-teal-600" />
</div>
);
});
DatabaseNode.displayName = 'DatabaseNode';

View File

@@ -0,0 +1,56 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import type { Module } from '../../../types';
import { MODULE_TYPE_COLORS, MODULE_TYPE_LABELS } from '../../../lib/constants';
import { Badge } from '../../ui/Badge';
export interface DeviceNodeData {
label: string;
module?: Module;
[key: string]: unknown;
}
export const DeviceNode = memo(({ data, selected }: NodeProps) => {
const nodeData = data as DeviceNodeData;
const mod = nodeData.module;
const colors = mod ? MODULE_TYPE_COLORS[mod.type] : null;
return (
<div
className={`min-w-[160px] max-w-[200px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden transition-all ${
selected ? 'ring-2 ring-blue-500 border-blue-500' : 'border-slate-600'
} ${colors ? colors.border : ''}`}
>
<Handle type="target" position={Position.Top} className="!bg-slate-400 !border-slate-600" />
{/* Colored accent strip */}
{colors && <div className={`h-1 w-full ${colors.bg}`} />}
<div className="px-3 py-2">
<div className="flex items-center gap-1.5 mb-1">
<svg width="12" height="12" viewBox="0 0 18 18" fill="none" className="shrink-0 opacity-60">
<rect x="1" y="2" width="16" height="3" rx="1" fill="currentColor" />
<rect x="1" y="7" width="16" height="3" rx="1" fill="currentColor" opacity="0.7" />
<rect x="1" y="12" width="16" height="3" rx="1" fill="currentColor" opacity="0.4" />
</svg>
<span className="text-xs font-semibold text-slate-100 truncate">{nodeData.label}</span>
</div>
{mod && (
<div className="flex flex-wrap gap-1">
<Badge variant="slate" className="text-[10px]">{MODULE_TYPE_LABELS[mod.type]}</Badge>
{mod.ipAddress && (
<span className="text-[10px] text-slate-400 font-mono">{mod.ipAddress}</span>
)}
</div>
)}
{!mod && (
<span className="text-[10px] text-slate-500">Unlinked device</span>
)}
</div>
<Handle type="source" position={Position.Bottom} className="!bg-slate-400 !border-slate-600" />
</div>
);
});
DeviceNode.displayName = 'DeviceNode';

View File

@@ -0,0 +1,18 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Cloud } from 'lucide-react';
export const ExternalNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'External';
return (
<div className={`min-w-[140px] bg-slate-800 border-2 border-dashed rounded-lg shadow-lg ${selected ? 'ring-2 ring-slate-400 border-slate-400' : 'border-slate-500'}`}>
<Handle type="target" position={Position.Top} className="!bg-slate-400 !border-slate-600" />
<div className="px-3 py-2 flex items-center gap-2">
<Cloud size={13} className="text-slate-400 shrink-0" />
<span className="text-xs font-medium text-slate-300 truncate">{label}</span>
</div>
<Handle type="source" position={Position.Bottom} className="!bg-slate-400 !border-slate-600" />
</div>
);
});
ExternalNode.displayName = 'ExternalNode';

View File

@@ -0,0 +1,18 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Shield } from 'lucide-react';
export const FirewallNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'Firewall';
return (
<div className={`min-w-[140px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden ${selected ? 'ring-2 ring-red-500 border-red-500' : 'border-red-700'}`}>
<Handle type="target" position={Position.Top} className="!bg-red-400 !border-red-600" />
<div className="px-3 py-2 flex items-center gap-2">
<Shield size={13} className="text-red-400 shrink-0" />
<span className="text-xs font-semibold text-slate-100 truncate">{label}</span>
</div>
<Handle type="source" position={Position.Bottom} className="!bg-red-400 !border-red-600" />
</div>
);
});
FirewallNode.displayName = 'FirewallNode';

View File

@@ -0,0 +1,18 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Scale } from 'lucide-react';
export const LBNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'Load Balancer';
return (
<div className={`min-w-[140px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden ${selected ? 'ring-2 ring-orange-500 border-orange-500' : 'border-orange-700'}`}>
<Handle type="target" position={Position.Top} className="!bg-orange-400 !border-orange-600" />
<div className="px-3 py-2 flex items-center gap-2">
<Scale size={13} className="text-orange-400 shrink-0" />
<span className="text-xs font-medium text-slate-100 truncate">{label}</span>
</div>
<Handle type="source" position={Position.Bottom} className="!bg-orange-400 !border-orange-600" />
</div>
);
});
LBNode.displayName = 'LBNode';

View File

@@ -0,0 +1,21 @@
import { memo } from 'react';
import { type NodeProps } from '@xyflow/react';
import { StickyNote } from 'lucide-react';
/** NoteNode has no handles — it's a free-floating annotation. */
export const NoteNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'Note';
return (
<div
className={`min-w-[140px] max-w-[240px] bg-yellow-900/40 border rounded-lg shadow-lg ${
selected ? 'ring-2 ring-yellow-400 border-yellow-400' : 'border-yellow-700/60'
}`}
>
<div className="px-3 py-2 flex items-start gap-2">
<StickyNote size={12} className="text-yellow-400 shrink-0 mt-0.5" />
<span className="text-xs text-yellow-100/90 whitespace-pre-wrap break-words">{label}</span>
</div>
</div>
);
});
NoteNode.displayName = 'NoteNode';

View File

@@ -0,0 +1,25 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Layers } from 'lucide-react';
export const ServiceNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'Service';
const color = (data as { color?: string }).color ?? '#3b82f6';
return (
<div
className={`min-w-[140px] bg-slate-800 rounded-lg shadow-lg border overflow-hidden ${
selected ? 'ring-2 ring-blue-500 border-blue-500' : 'border-slate-600'
}`}
style={{ borderLeftColor: color, borderLeftWidth: 3 }}
>
<Handle type="target" position={Position.Top} className="!bg-slate-400 !border-slate-600" />
<div className="px-3 py-2 flex items-center gap-2">
<Layers size={13} style={{ color }} className="shrink-0" />
<span className="text-xs font-medium text-slate-100 truncate">{label}</span>
</div>
<Handle type="source" position={Position.Bottom} className="!bg-slate-400 !border-slate-600" />
</div>
);
});
ServiceNode.displayName = 'ServiceNode';

View File

@@ -0,0 +1,18 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { User } from 'lucide-react';
export const UserNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'User';
return (
<div className={`min-w-[120px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden ${selected ? 'ring-2 ring-slate-400 border-slate-400' : 'border-slate-600'}`}>
<Handle type="target" position={Position.Top} className="!bg-slate-400 !border-slate-600" />
<div className="px-3 py-2 flex items-center gap-2">
<User size={13} className="text-slate-400 shrink-0" />
<span className="text-xs font-medium text-slate-300 truncate">{label}</span>
</div>
<Handle type="source" position={Position.Bottom} className="!bg-slate-400 !border-slate-600" />
</div>
);
});
UserNode.displayName = 'UserNode';

View File

@@ -0,0 +1,21 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
export const VlanNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'VLAN';
const color = (data as { color?: string }).color ?? '#6366f1';
return (
<div
className={`min-w-[120px] rounded-lg shadow-lg border-2 ${selected ? 'ring-2 ring-offset-1 ring-offset-slate-900' : ''}`}
style={{ backgroundColor: `${color}22`, borderColor: color }}
>
<Handle type="target" position={Position.Top} style={{ borderColor: color }} />
<div className="px-3 py-2 flex items-center gap-2">
<div className="w-3 h-3 rounded-sm shrink-0" style={{ backgroundColor: color }} />
<span className="text-xs font-semibold text-slate-100 truncate">{label}</span>
</div>
<Handle type="source" position={Position.Bottom} style={{ borderColor: color }} />
</div>
);
});
VlanNode.displayName = 'VlanNode';

View File

@@ -0,0 +1,227 @@
import { useState, type FormEvent } from 'react';
import { toast } from 'sonner';
import type { ModuleType } from '../../types';
import { Modal } from '../ui/Modal';
import { Button } from '../ui/Button';
import { useRackStore } from '../../store/useRackStore';
import {
MODULE_TYPE_LABELS,
MODULE_TYPE_COLORS,
MODULE_U_DEFAULTS,
MODULE_PORT_DEFAULTS,
} from '../../lib/constants';
import { cn } from '../../lib/utils';
interface AddModuleModalProps {
open: boolean;
onClose: () => void;
rackId: string;
uPosition: number;
}
const ALL_TYPES: ModuleType[] = [
'SWITCH', 'AGGREGATE_SWITCH', 'ROUTER', 'FIREWALL', 'PATCH_PANEL',
'MODEM', 'SERVER', 'NAS', 'PDU', 'AP', 'BLANK', 'OTHER',
];
export function AddModuleModal({ open, onClose, rackId, uPosition }: AddModuleModalProps) {
const { addModule } = useRackStore();
const [selectedType, setSelectedType] = useState<ModuleType | null>(null);
const [name, setName] = useState('');
const [uSize, setUSize] = useState(1);
const [portCount, setPortCount] = useState(0);
const [ipAddress, setIpAddress] = useState('');
const [manufacturer, setManufacturer] = useState('');
const [model, setModel] = useState('');
const [loading, setLoading] = useState(false);
function handleTypeSelect(type: ModuleType) {
setSelectedType(type);
setName(MODULE_TYPE_LABELS[type]);
setUSize(MODULE_U_DEFAULTS[type]);
setPortCount(MODULE_PORT_DEFAULTS[type]);
}
function reset() {
setSelectedType(null);
setName('');
setUSize(1);
setPortCount(0);
setIpAddress('');
setManufacturer('');
setModel('');
}
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!selectedType || !name.trim()) return;
setLoading(true);
try {
await addModule(rackId, {
name: name.trim(),
type: selectedType,
uPosition,
uSize,
portCount,
ipAddress: ipAddress.trim() || undefined,
manufacturer: manufacturer.trim() || undefined,
model: model.trim() || undefined,
});
toast.success(`${name.trim()} added at U${uPosition}`);
reset();
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to add module');
} finally {
setLoading(false);
}
}
function handleClose() {
if (!loading) {
reset();
onClose();
}
}
return (
<Modal open={open} onClose={handleClose} title={`Add Module — U${uPosition}`} size="md">
<form onSubmit={handleSubmit} className="space-y-4">
{/* Type selector */}
{!selectedType ? (
<div>
<p className="text-xs text-slate-400 mb-2">Select device type</p>
<div className="grid grid-cols-3 gap-1.5">
{ALL_TYPES.map((type) => {
const colors = MODULE_TYPE_COLORS[type];
return (
<button
key={type}
type="button"
onClick={() => handleTypeSelect(type)}
className={cn(
'flex flex-col items-center gap-1 px-2 py-2 rounded border text-center hover:brightness-125 transition-all',
colors.bg,
colors.border
)}
>
<span className="text-[11px] font-medium text-white leading-tight">
{MODULE_TYPE_LABELS[type]}
</span>
<span className="text-[10px] text-slate-400">
{MODULE_U_DEFAULTS[type]}U
</span>
</button>
);
})}
</div>
</div>
) : (
<>
<div className="flex items-center gap-2">
<div
className={cn(
'px-2 py-0.5 rounded text-xs font-medium border',
MODULE_TYPE_COLORS[selectedType].bg,
MODULE_TYPE_COLORS[selectedType].border,
'text-white'
)}
>
{MODULE_TYPE_LABELS[selectedType]}
</div>
<button
type="button"
onClick={() => setSelectedType(null)}
className="text-xs text-slate-500 hover:text-slate-300 underline"
>
Change type
</button>
</div>
<div className="space-y-1">
<label className="block text-sm text-slate-300">
Name <span className="text-red-400">*</span>
</label>
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<label className="block text-sm text-slate-300">Size (U)</label>
<input
type="number"
min={1}
max={20}
value={uSize}
onChange={(e) => setUSize(Number(e.target.value))}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="space-y-1">
<label className="block text-sm text-slate-300">Port count</label>
<input
type="number"
min={0}
max={128}
value={portCount}
onChange={(e) => setPortCount(Number(e.target.value))}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="space-y-1">
<label className="block text-sm text-slate-300">IP Address</label>
<input
value={ipAddress}
onChange={(e) => setIpAddress(e.target.value)}
disabled={loading}
placeholder="192.168.1.1"
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="block text-sm text-slate-300">Manufacturer</label>
<input
value={manufacturer}
onChange={(e) => setManufacturer(e.target.value)}
disabled={loading}
placeholder="Cisco, Ubiquiti…"
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="space-y-1">
<label className="block text-sm text-slate-300">Model</label>
<input
value={model}
onChange={(e) => setModel(e.target.value)}
disabled={loading}
placeholder="SG300-28…"
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-1">
<Button variant="secondary" size="sm" type="button" onClick={handleClose} disabled={loading}>
Cancel
</Button>
<Button size="sm" type="submit" loading={loading} disabled={!name.trim()}>
Add Module
</Button>
</div>
</>
)}
</form>
</Modal>
);
}

View File

@@ -0,0 +1,109 @@
import { useState, type FormEvent } from 'react';
import { toast } from 'sonner';
import { Modal } from '../ui/Modal';
import { Button } from '../ui/Button';
import { useRackStore } from '../../store/useRackStore';
interface AddRackModalProps {
open: boolean;
onClose: () => void;
}
export function AddRackModal({ open, onClose }: AddRackModalProps) {
const { addRack } = useRackStore();
const [name, setName] = useState('');
const [totalU, setTotalU] = useState(42);
const [location, setLocation] = useState('');
const [loading, setLoading] = useState(false);
function reset() {
setName('');
setTotalU(42);
setLocation('');
}
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!name.trim()) return;
setLoading(true);
try {
await addRack(name.trim(), totalU, location.trim() || undefined);
toast.success(`Rack "${name.trim()}" created`);
reset();
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create rack');
} finally {
setLoading(false);
}
}
function handleClose() {
if (!loading) {
reset();
onClose();
}
}
return (
<Modal open={open} onClose={handleClose} title="Add Rack" size="sm">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1">
<label htmlFor="rack-name" className="block text-sm text-slate-300">
Name <span className="text-red-400">*</span>
</label>
<input
id="rack-name"
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
disabled={loading}
placeholder="e.g. Main Rack"
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label htmlFor="rack-u" className="block text-sm text-slate-300">
Size (U)
</label>
<input
id="rack-u"
type="number"
min={1}
max={100}
value={totalU}
onChange={(e) => setTotalU(Number(e.target.value))}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="space-y-1">
<label htmlFor="rack-location" className="block text-sm text-slate-300">
Location
</label>
<input
id="rack-location"
value={location}
onChange={(e) => setLocation(e.target.value)}
disabled={loading}
placeholder="e.g. Server Room"
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-1">
<Button variant="secondary" size="sm" type="button" onClick={handleClose} disabled={loading}>
Cancel
</Button>
<Button size="sm" type="submit" loading={loading} disabled={!name.trim()}>
Create Rack
</Button>
</div>
</form>
</Modal>
);
}

View File

@@ -0,0 +1,146 @@
import { useState, useEffect, type FormEvent } from 'react';
import { toast } from 'sonner';
import type { Module } from '../../types';
import { Modal } from '../ui/Modal';
import { Button } from '../ui/Button';
import { Badge } from '../ui/Badge';
import { useRackStore } from '../../store/useRackStore';
import { apiClient } from '../../api/client';
import { MODULE_TYPE_LABELS } from '../../lib/constants';
interface ModuleEditPanelProps {
module: Module;
open: boolean;
onClose: () => void;
}
export function ModuleEditPanel({ module, open, onClose }: ModuleEditPanelProps) {
const { updateModuleLocal } = useRackStore();
const [name, setName] = useState(module.name);
const [ipAddress, setIpAddress] = useState(module.ipAddress ?? '');
const [manufacturer, setManufacturer] = useState(module.manufacturer ?? '');
const [modelVal, setModelVal] = useState(module.model ?? '');
const [notes, setNotes] = useState(module.notes ?? '');
const [uSize, setUSize] = useState(module.uSize);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (open) {
setName(module.name);
setIpAddress(module.ipAddress ?? '');
setManufacturer(module.manufacturer ?? '');
setModelVal(module.model ?? '');
setNotes(module.notes ?? '');
setUSize(module.uSize);
}
}, [open, module]);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setLoading(true);
try {
const updated = await apiClient.modules.update(module.id, {
name: name.trim(),
uSize,
ipAddress: ipAddress.trim() || undefined,
manufacturer: manufacturer.trim() || undefined,
model: modelVal.trim() || undefined,
notes: notes.trim() || undefined,
});
updateModuleLocal(module.id, updated);
toast.success('Module updated');
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Update failed');
} finally {
setLoading(false);
}
}
return (
<Modal open={open} onClose={onClose} title="Edit Module" size="md">
<form onSubmit={handleSubmit} className="space-y-3">
{/* Type (read-only) */}
<div className="flex items-center gap-2 pb-1">
<Badge variant="slate">{MODULE_TYPE_LABELS[module.type]}</Badge>
<span className="text-xs text-slate-500">U{module.uPosition} · rack position</span>
</div>
<div className="space-y-1">
<label className="block text-sm text-slate-300">Name <span className="text-red-400">*</span></label>
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="block text-sm text-slate-300">Size (U)</label>
<input
type="number" min={1} max={20}
value={uSize}
onChange={(e) => setUSize(Number(e.target.value))}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="space-y-1">
<label className="block text-sm text-slate-300">IP Address</label>
<input
value={ipAddress}
onChange={(e) => setIpAddress(e.target.value)}
disabled={loading}
placeholder="192.168.1.1"
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="block text-sm text-slate-300">Manufacturer</label>
<input
value={manufacturer}
onChange={(e) => setManufacturer(e.target.value)}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="space-y-1">
<label className="block text-sm text-slate-300">Model</label>
<input
value={modelVal}
onChange={(e) => setModelVal(e.target.value)}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
</div>
<div className="space-y-1">
<label className="block text-sm text-slate-300">Notes</label>
<textarea
rows={3}
value={notes}
onChange={(e) => setNotes(e.target.value)}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 resize-none"
/>
</div>
<div className="flex justify-end gap-3 pt-1">
<Button variant="secondary" size="sm" type="button" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button size="sm" type="submit" loading={loading} disabled={!name.trim()}>
Save Changes
</Button>
</div>
</form>
</Modal>
);
}

View File

@@ -0,0 +1,267 @@
import { useState, useEffect, type FormEvent } from 'react';
import { toast } from 'sonner';
import type { Port, Vlan, VlanMode } from '../../types';
import { Modal } from '../ui/Modal';
import { Button } from '../ui/Button';
import { Badge } from '../ui/Badge';
import { apiClient } from '../../api/client';
import { useRackStore } from '../../store/useRackStore';
interface PortConfigModalProps {
portId: string;
open: boolean;
onClose: () => void;
}
export function PortConfigModal({ portId, open, onClose }: PortConfigModalProps) {
const { racks, updateModuleLocal } = useRackStore();
const [port, setPort] = useState<Port | null>(null);
const [vlans, setVlans] = useState<Vlan[]>([]);
const [label, setLabel] = useState('');
const [mode, setMode] = useState<VlanMode>('ACCESS');
const [nativeVlanId, setNativeVlanId] = useState<string>('');
const [taggedVlanIds, setTaggedVlanIds] = useState<string[]>([]);
const [notes, setNotes] = useState('');
const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(false);
// Quick-create VLAN
const [newVlanId, setNewVlanId] = useState('');
const [newVlanName, setNewVlanName] = useState('');
const [creatingVlan, setCreatingVlan] = useState(false);
// Find the port from store
useEffect(() => {
if (!open) return;
let found: Port | undefined;
for (const rack of racks) {
for (const mod of rack.modules) {
found = mod.ports.find((p) => p.id === portId);
if (found) break;
}
if (found) break;
}
if (found) {
setPort(found);
setLabel(found.label ?? '');
setMode(found.mode);
setNativeVlanId(found.nativeVlan?.toString() ?? '');
setTaggedVlanIds(found.vlans.filter((v) => v.tagged).map((v) => v.vlanId));
setNotes(found.notes ?? '');
}
}, [open, portId, racks]);
// Load VLAN list
useEffect(() => {
if (!open) return;
setFetching(true);
apiClient.vlans
.list()
.then(setVlans)
.catch(() => toast.error('Failed to load VLANs'))
.finally(() => setFetching(false));
}, [open]);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setLoading(true);
try {
const vlanAssignments = [
...(mode === 'ACCESS' && nativeVlanId
? [{ vlanId: vlans.find((v) => v.vlanId === Number(nativeVlanId))?.id ?? '', tagged: false }]
: []),
...(mode !== 'ACCESS'
? taggedVlanIds.map((id) => ({ vlanId: id, tagged: true }))
: []),
].filter((v) => v.vlanId);
await apiClient.ports.update(portId, {
label: label.trim() || undefined,
mode,
nativeVlan: nativeVlanId ? Number(nativeVlanId) : null,
notes: notes.trim() || undefined,
vlans: vlanAssignments,
});
// Refresh racks to reflect changes
const portsInRack = racks.flatMap((r) => r.modules).find((m) => m.ports.some((p) => p.id === portId));
if (portsInRack) {
const updatedPorts = await apiClient.modules.getPorts(portsInRack.id);
updateModuleLocal(portsInRack.id, { ports: updatedPorts });
}
toast.success('Port saved');
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Save failed');
} finally {
setLoading(false);
}
}
async function handleCreateVlan() {
const id = Number(newVlanId);
if (!id || !newVlanName.trim()) return;
setCreatingVlan(true);
try {
const created = await apiClient.vlans.create({ vlanId: id, name: newVlanName.trim() });
setVlans((v) => [...v, created].sort((a, b) => a.vlanId - b.vlanId));
setNewVlanId('');
setNewVlanName('');
toast.success(`VLAN ${id} created`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create VLAN');
} finally {
setCreatingVlan(false);
}
}
function toggleTaggedVlan(vlanDbId: string) {
setTaggedVlanIds((prev) =>
prev.includes(vlanDbId) ? prev.filter((id) => id !== vlanDbId) : [...prev, vlanDbId]
);
}
if (!port) return null;
return (
<Modal open={open} onClose={onClose} title={`Port ${port.portNumber} Configuration`} size="md">
<form onSubmit={handleSubmit} className="space-y-4">
{/* Port info */}
<div className="flex items-center gap-2">
<Badge variant="slate">{port.portType}</Badge>
<span className="text-xs text-slate-500">Port #{port.portNumber}</span>
</div>
{/* Label */}
<div className="space-y-1">
<label className="block text-sm text-slate-300">Label</label>
<input
value={label}
onChange={(e) => setLabel(e.target.value)}
disabled={loading}
placeholder="e.g. Server 1 uplink"
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
{/* Mode */}
<div className="space-y-1">
<label className="block text-sm text-slate-300">Mode</label>
<div className="flex gap-2">
{(['ACCESS', 'TRUNK', 'HYBRID'] as VlanMode[]).map((m) => (
<button
key={m}
type="button"
onClick={() => setMode(m)}
className={`px-3 py-1.5 rounded text-xs font-medium border transition-colors ${
mode === m
? 'bg-blue-600 border-blue-500 text-white'
: 'bg-slate-900 border-slate-600 text-slate-400 hover:border-slate-500'
}`}
>
{m}
</button>
))}
</div>
</div>
{/* Native VLAN */}
<div className="space-y-1">
<label className="block text-sm text-slate-300">Native VLAN</label>
<select
value={nativeVlanId}
onChange={(e) => setNativeVlanId(e.target.value)}
disabled={loading || fetching}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
>
<option value=""> Untagged </option>
{vlans.map((v) => (
<option key={v.id} value={v.vlanId.toString()}>
VLAN {v.vlanId} {v.name}
</option>
))}
</select>
</div>
{/* Tagged VLANs — Trunk/Hybrid only */}
{mode !== 'ACCESS' && (
<div className="space-y-1">
<label className="block text-sm text-slate-300">Tagged VLANs</label>
<div className="max-h-32 overflow-y-auto bg-slate-900 border border-slate-600 rounded-lg p-2 flex flex-wrap gap-1.5">
{vlans.length === 0 && (
<span className="text-xs text-slate-600">No VLANs defined yet</span>
)}
{vlans.map((v) => (
<button
key={v.id}
type="button"
onClick={() => toggleTaggedVlan(v.id)}
className={`px-2 py-0.5 rounded text-xs border transition-colors ${
taggedVlanIds.includes(v.id)
? 'bg-blue-700 border-blue-500 text-white'
: 'bg-slate-800 border-slate-600 text-slate-400 hover:border-slate-400'
}`}
>
{v.vlanId} {v.name}
</button>
))}
</div>
</div>
)}
{/* Quick-create VLAN */}
<div className="border border-slate-700 rounded-lg p-3 space-y-2">
<p className="text-xs font-medium text-slate-400">Quick-create VLAN</p>
<div className="flex gap-2">
<input
type="number"
min={1}
max={4094}
value={newVlanId}
onChange={(e) => setNewVlanId(e.target.value)}
placeholder="VLAN ID"
className="w-24 bg-slate-900 border border-slate-600 rounded px-2 py-1.5 text-xs text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<input
value={newVlanName}
onChange={(e) => setNewVlanName(e.target.value)}
placeholder="Name"
className="flex-1 bg-slate-900 border border-slate-600 rounded px-2 py-1.5 text-xs text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<Button
type="button"
size="sm"
variant="secondary"
onClick={handleCreateVlan}
loading={creatingVlan}
disabled={!newVlanId || !newVlanName.trim()}
>
Add
</Button>
</div>
</div>
{/* Notes */}
<div className="space-y-1">
<label className="block text-sm text-slate-300">Notes</label>
<textarea
rows={2}
value={notes}
onChange={(e) => setNotes(e.target.value)}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 resize-none"
/>
</div>
<div className="flex justify-end gap-3">
<Button variant="secondary" size="sm" type="button" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button size="sm" type="submit" loading={loading}>
Save Port
</Button>
</div>
</form>
</Modal>
);
}

View File

@@ -0,0 +1,57 @@
/**
* DevicePalette — sidebar showing all available device types.
*
* SCAFFOLD: Currently a static visual list. Full drag-to-rack DnD requires
* @dnd-kit integration with the RackColumn drop targets (see roadmap).
*/
import type { ModuleType } from '../../types';
import { MODULE_TYPE_LABELS, MODULE_TYPE_COLORS, MODULE_U_DEFAULTS, MODULE_PORT_DEFAULTS } from '../../lib/constants';
import { cn } from '../../lib/utils';
const ALL_TYPES: ModuleType[] = [
'SWITCH', 'AGGREGATE_SWITCH', 'ROUTER', 'FIREWALL', 'PATCH_PANEL',
'MODEM', 'SERVER', 'NAS', 'PDU', 'AP', 'BLANK', 'OTHER',
];
interface DevicePaletteProps {
/** Called when user clicks a device type to place it. */
onSelect?: (type: ModuleType) => void;
}
export function DevicePalette({ onSelect }: DevicePaletteProps) {
return (
<aside className="w-44 shrink-0 flex flex-col bg-slate-800 border-r border-slate-700 overflow-y-auto">
<div className="px-3 py-2 border-b border-slate-700">
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Devices</p>
<p className="text-[10px] text-slate-600 mt-0.5">Click a slot, then choose type</p>
</div>
<div className="flex flex-col gap-1 p-2">
{ALL_TYPES.map((type) => {
const colors = MODULE_TYPE_COLORS[type];
return (
<button
key={type}
onClick={() => onSelect?.(type)}
className={cn(
'flex items-center gap-2 px-2 py-1.5 rounded border text-left w-full hover:brightness-125 transition-all',
colors.bg,
colors.border
)}
aria-label={`Add ${MODULE_TYPE_LABELS[type]}`}
>
<div className={cn('w-2 h-2 rounded-sm shrink-0', colors.bg, 'brightness-150')} />
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-white truncate">
{MODULE_TYPE_LABELS[type]}
</div>
<div className="text-[10px] text-slate-400">
{MODULE_U_DEFAULTS[type]}U · {MODULE_PORT_DEFAULTS[type]} ports
</div>
</div>
</button>
);
})}
</div>
</aside>
);
}

View File

@@ -0,0 +1,148 @@
import { useState } from 'react';
import { Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import type { Module } from '../../types';
import { cn } from '../../lib/utils';
import { MODULE_TYPE_COLORS, MODULE_TYPE_LABELS, U_HEIGHT_PX } from '../../lib/constants';
import { Badge } from '../ui/Badge';
import { ConfirmDialog } from '../ui/ConfirmDialog';
import { ModuleEditPanel } from '../modals/ModuleEditPanel';
import { PortConfigModal } from '../modals/PortConfigModal';
import { useRackStore } from '../../store/useRackStore';
import { apiClient } from '../../api/client';
interface ModuleBlockProps {
module: Module;
}
export function ModuleBlock({ module }: ModuleBlockProps) {
const { removeModuleLocal } = useRackStore();
const [hovered, setHovered] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deletingLoading, setDeletingLoading] = useState(false);
const [portModalOpen, setPortModalOpen] = useState(false);
const [selectedPortId, setSelectedPortId] = useState<string | null>(null);
const colors = MODULE_TYPE_COLORS[module.type];
const height = module.uSize * U_HEIGHT_PX;
const hasPorts = module.ports.length > 0;
async function handleDelete() {
setDeletingLoading(true);
try {
await apiClient.modules.delete(module.id);
removeModuleLocal(module.id);
toast.success(`${module.name} removed`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Delete failed');
} finally {
setDeletingLoading(false);
setConfirmDeleteOpen(false);
}
}
function openPort(portId: string) {
setSelectedPortId(portId);
setPortModalOpen(true);
}
return (
<>
<div
className={cn(
'relative w-full border-l-2 cursor-pointer select-none flex flex-col justify-between px-2 py-0.5 overflow-hidden transition-opacity',
colors.bg,
colors.border,
hovered && 'brightness-110'
)}
style={{ height }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={() => setEditOpen(true)}
role="button"
tabIndex={0}
aria-label={`Edit ${module.name}`}
onKeyDown={(e) => e.key === 'Enter' && setEditOpen(true)}
>
{/* Main content */}
<div className="flex items-center gap-1 min-w-0">
<span className="text-xs font-semibold text-white truncate flex-1">{module.name}</span>
<Badge variant="slate" className="text-[10px] shrink-0">
{MODULE_TYPE_LABELS[module.type]}
</Badge>
</div>
{module.ipAddress && (
<div className="text-[10px] text-slate-300 font-mono truncate">{module.ipAddress}</div>
)}
{/* Port dots — only if module has ports and enough height */}
{hasPorts && height >= 28 && (
<div
className="flex flex-wrap gap-0.5 mt-0.5"
onClick={(e) => e.stopPropagation()}
>
{module.ports.slice(0, 32).map((port) => {
const hasVlan = port.vlans.length > 0;
return (
<button
key={port.id}
onClick={() => openPort(port.id)}
aria-label={`Port ${port.portNumber}`}
className={cn(
'w-2.5 h-2.5 rounded-sm border transition-colors',
hasVlan
? 'bg-green-400 border-green-500 hover:bg-green-300'
: 'bg-slate-600 border-slate-500 hover:bg-slate-400'
)}
/>
);
})}
{module.ports.length > 32 && (
<span className="text-[9px] text-slate-400">+{module.ports.length - 32}</span>
)}
</div>
)}
{/* Delete button — hover only */}
{hovered && (
<button
className="absolute top-0.5 right-0.5 p-0.5 rounded bg-red-800/80 hover:bg-red-600 text-white transition-colors z-10"
onClick={(e) => {
e.stopPropagation();
setConfirmDeleteOpen(true);
}}
aria-label={`Delete ${module.name}`}
>
<Trash2 size={11} />
</button>
)}
</div>
<ModuleEditPanel module={module} open={editOpen} onClose={() => setEditOpen(false)} />
<ConfirmDialog
open={confirmDeleteOpen}
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={handleDelete}
title="Remove Module"
message={`Remove "${module.name}" from the rack? This will also delete all associated port configuration.`}
confirmLabel="Remove"
loading={deletingLoading}
/>
{selectedPortId && (
<PortConfigModal
portId={selectedPortId}
open={portModalOpen}
onClose={() => {
setPortModalOpen(false);
setSelectedPortId(null);
}}
/>
)}
</>
);
}

View File

@@ -0,0 +1,106 @@
import { useState } from 'react';
import { Trash2, MapPin } from 'lucide-react';
import { toast } from 'sonner';
import type { Rack } from '../../types';
import { buildOccupancyMap } from '../../lib/utils';
import { ModuleBlock } from './ModuleBlock';
import { RackSlot } from './RackSlot';
import { ConfirmDialog } from '../ui/ConfirmDialog';
import { useRackStore } from '../../store/useRackStore';
interface RackColumnProps {
rack: Rack;
}
export function RackColumn({ rack }: RackColumnProps) {
const { deleteRack } = useRackStore();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const occupancy = buildOccupancyMap(rack.modules);
async function handleDelete() {
setDeleting(true);
try {
await deleteRack(rack.id);
toast.success(`Rack "${rack.name}" deleted`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Delete failed');
} finally {
setDeleting(false);
setConfirmDeleteOpen(false);
}
}
// Build the slot render list — modules span multiple U slots
const slots: Array<{ u: number; moduleId: string | null }> = [];
const renderedModuleIds = new Set<string>();
for (let u = 1; u <= rack.totalU; u++) {
const moduleId = occupancy.get(u) ?? null;
slots.push({ u, moduleId });
}
return (
<>
<div className="flex flex-col min-w-[200px] w-48 shrink-0">
{/* Rack header */}
<div className="flex items-center gap-1 bg-slate-700 border border-slate-600 rounded-t-lg px-2 py-1.5 group">
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-slate-100 truncate">{rack.name}</div>
{rack.location && (
<div className="flex items-center gap-0.5 text-[10px] text-slate-400">
<MapPin size={9} />
{rack.location}
</div>
)}
</div>
<button
onClick={() => setConfirmDeleteOpen(true)}
aria-label={`Delete rack ${rack.name}`}
className="p-1 rounded hover:bg-red-800/50 text-slate-500 hover:text-red-400 transition-colors opacity-0 group-hover:opacity-100"
>
<Trash2 size={12} />
</button>
</div>
{/* U-slot body */}
<div className="border-x border-slate-600 bg-[#1e2433] flex flex-col">
{slots.map(({ u, moduleId }) => {
if (moduleId) {
const module = rack.modules.find((m) => m.id === moduleId);
if (!module) return null;
// Only render the block on its first U (top)
if (module.uPosition !== u) return null;
if (renderedModuleIds.has(moduleId)) return null;
renderedModuleIds.add(moduleId);
return <ModuleBlock key={module.id} module={module} />;
}
return <RackSlot key={u} rackId={rack.id} uPosition={u} />;
})}
</div>
{/* Rack footer */}
<div className="bg-slate-700 border border-slate-600 rounded-b-lg px-2 py-1 flex items-center justify-between">
<span className="text-[10px] text-slate-400">{rack.totalU}U rack</span>
<span className="text-[10px] text-slate-500">
{rack.modules.reduce((acc, m) => acc + m.uSize, 0)}/{rack.totalU}U used
</span>
</div>
</div>
<ConfirmDialog
open={confirmDeleteOpen}
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={handleDelete}
title="Delete Rack"
message={`Delete "${rack.name}" and all modules inside? This cannot be undone.`}
confirmLabel="Delete Rack"
loading={deleting}
/>
</>
);
}

View File

@@ -0,0 +1,56 @@
import { useEffect, useRef } from 'react';
import { toast } from 'sonner';
import { useRackStore } from '../../store/useRackStore';
import { RackToolbar } from './RackToolbar';
import { RackColumn } from './RackColumn';
import { RackSkeleton } from '../ui/Skeleton';
export function RackPlanner() {
const { racks, loading, fetchRacks } = useRackStore();
const canvasRef = useRef<HTMLDivElement>(null);
useEffect(() => {
fetchRacks().catch(() => toast.error('Failed to load racks'));
}, [fetchRacks]);
return (
<div className="flex flex-col h-screen bg-[#0f1117]">
<RackToolbar rackCanvasRef={canvasRef} />
<div className="flex flex-1 overflow-hidden">
{/* Main rack canvas */}
<div className="flex-1 overflow-auto">
{loading ? (
<RackSkeleton />
) : racks.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full gap-3 text-center px-4">
<div className="w-16 h-16 bg-slate-800 rounded-xl border border-slate-700 flex items-center justify-center">
<svg width="32" height="32" viewBox="0 0 18 18" fill="none">
<rect x="1" y="2" width="16" height="3" rx="1" fill="#475569" />
<rect x="1" y="7" width="16" height="3" rx="1" fill="#475569" opacity="0.7" />
<rect x="1" y="12" width="16" height="3" rx="1" fill="#475569" opacity="0.4" />
</svg>
</div>
<div>
<p className="text-slate-300 font-medium">No racks yet</p>
<p className="text-slate-500 text-sm mt-1">
Click <strong className="text-slate-300">Add Rack</strong> in the toolbar to create your first rack.
</p>
</div>
</div>
) : (
<div
ref={canvasRef}
className="flex gap-4 p-4 min-h-full items-start"
style={{ background: '#0f1117' }}
>
{racks.map((rack) => (
<RackColumn key={rack.id} rack={rack} />
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { useState } from 'react';
import { Plus } from 'lucide-react';
import { U_HEIGHT_PX } from '../../lib/constants';
import { AddModuleModal } from '../modals/AddModuleModal';
interface RackSlotProps {
rackId: string;
uPosition: number;
/** True if this slot is occupied (skips rendering — ModuleBlock renders instead) */
occupied?: boolean;
}
export function RackSlot({ rackId, uPosition, occupied = false }: RackSlotProps) {
const [addModuleOpen, setAddModuleOpen] = useState(false);
if (occupied) return null;
return (
<>
<div
className="w-full border border-dashed border-slate-700/50 hover:border-blue-500/50 hover:bg-blue-500/5 transition-colors group cursor-pointer flex items-center justify-between px-2"
style={{ height: U_HEIGHT_PX }}
onClick={() => setAddModuleOpen(true)}
role="button"
tabIndex={0}
aria-label={`Add module at U${uPosition}`}
onKeyDown={(e) => e.key === 'Enter' && setAddModuleOpen(true)}
>
<span className="text-[10px] text-slate-600 group-hover:text-slate-500 font-mono">
U{uPosition}
</span>
<Plus
size={10}
className="text-slate-700 group-hover:text-blue-500 opacity-0 group-hover:opacity-100 transition-opacity"
/>
</div>
<AddModuleModal
open={addModuleOpen}
onClose={() => setAddModuleOpen(false)}
rackId={rackId}
uPosition={uPosition}
/>
</>
);
}

View File

@@ -0,0 +1,84 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Download, Map, LogOut } from 'lucide-react';
import { toast } from 'sonner';
import { toPng } from 'html-to-image';
import { Button } from '../ui/Button';
import { AddRackModal } from '../modals/AddRackModal';
import { useAuthStore } from '../../store/useAuthStore';
interface RackToolbarProps {
rackCanvasRef: React.RefObject<HTMLDivElement | null>;
}
export function RackToolbar({ rackCanvasRef }: RackToolbarProps) {
const navigate = useNavigate();
const { logout } = useAuthStore();
const [addRackOpen, setAddRackOpen] = useState(false);
const [exporting, setExporting] = useState(false);
async function handleExport() {
if (!rackCanvasRef.current) return;
setExporting(true);
const toastId = toast.loading('Exporting…');
try {
const dataUrl = await toPng(rackCanvasRef.current, { cacheBust: true });
const link = document.createElement('a');
link.download = `rackmapper-rack-${Date.now()}.png`;
link.href = dataUrl;
link.click();
toast.success('Exported successfully', { id: toastId });
} catch {
toast.error('Export failed', { id: toastId });
} finally {
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">
{/* Left: brand */}
<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">Rack Planner</span>
</div>
{/* Right: actions */}
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" onClick={() => navigate('/map')}>
<Map size={14} />
Service Map
</Button>
<Button size="sm" onClick={() => setAddRackOpen(true)}>
<Plus size={14} />
Add Rack
</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>
<AddRackModal open={addRackOpen} onClose={() => setAddRackOpen(false)} />
</>
);
}

View File

@@ -0,0 +1,32 @@
import type { ReactNode } from 'react';
import { cn } from '../../lib/utils';
interface BadgeProps {
children: ReactNode;
variant?: 'default' | 'blue' | 'green' | 'red' | 'yellow' | 'purple' | 'slate';
className?: string;
}
const variants = {
default: 'bg-slate-700 text-slate-300',
blue: 'bg-blue-900/60 text-blue-300 border border-blue-700/50',
green: 'bg-green-900/60 text-green-300 border border-green-700/50',
red: 'bg-red-900/60 text-red-300 border border-red-700/50',
yellow: 'bg-yellow-900/60 text-yellow-300 border border-yellow-700/50',
purple: 'bg-purple-900/60 text-purple-300 border border-purple-700/50',
slate: 'bg-slate-800 text-slate-400 border border-slate-700',
};
export function Badge({ children, variant = 'default', className }: BadgeProps) {
return (
<span
className={cn(
'inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium',
variants[variant],
className
)}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,61 @@
import type { ButtonHTMLAttributes } from 'react';
import { cn } from '../../lib/utils';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
}
export function Button({
variant = 'primary',
size = 'md',
loading = false,
disabled,
className,
children,
...props
}: ButtonProps) {
const base =
'inline-flex items-center justify-center gap-2 font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-slate-900 disabled:opacity-50 disabled:pointer-events-none';
const variants = {
primary: 'bg-blue-600 hover:bg-blue-500 text-white focus:ring-blue-500',
secondary:
'bg-slate-700 hover:bg-slate-600 text-slate-100 border border-slate-600 focus:ring-slate-500',
ghost: 'hover:bg-slate-700 text-slate-300 hover:text-slate-100 focus:ring-slate-500',
danger: 'bg-red-700 hover:bg-red-600 text-white focus:ring-red-500',
};
const sizes = {
sm: 'px-3 py-1.5 text-xs',
md: 'px-4 py-2 text-sm',
lg: 'px-5 py-2.5 text-base',
};
return (
<button
disabled={disabled || loading}
className={cn(base, variants[variant], sizes[size], className)}
{...props}
>
{loading && (
<svg
className="animate-spin h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
)}
{children}
</button>
);
}

View File

@@ -0,0 +1,36 @@
import { Modal } from './Modal';
import { Button } from './Button';
interface ConfirmDialogProps {
open: boolean;
onClose: () => void;
onConfirm: () => void | Promise<void>;
title: string;
message: string;
confirmLabel?: string;
loading?: boolean;
}
export function ConfirmDialog({
open,
onClose,
onConfirm,
title,
message,
confirmLabel = 'Delete',
loading = false,
}: ConfirmDialogProps) {
return (
<Modal open={open} onClose={onClose} title={title} size="sm">
<p className="text-sm text-slate-300 mb-5">{message}</p>
<div className="flex justify-end gap-3">
<Button variant="secondary" size="sm" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button variant="danger" size="sm" onClick={onConfirm} loading={loading}>
{confirmLabel}
</Button>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,90 @@
import { type ReactNode, useEffect, useRef } from 'react';
import { X } from 'lucide-react';
import { cn } from '../../lib/utils';
interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl';
className?: string;
}
const sizeClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-2xl',
};
export function Modal({ open, onClose, title, children, size = 'md', className }: ModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
// Close on Escape
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [open, onClose]);
// Trap focus — scroll lock
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [open]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
{/* Panel */}
<div
ref={dialogRef}
className={cn(
'relative w-full bg-slate-800 border border-slate-700 rounded-xl shadow-2xl',
sizeClasses[size],
className
)}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-700">
<h2 id="modal-title" className="text-base font-semibold text-slate-100">
{title}
</h2>
<button
onClick={onClose}
aria-label="Close modal"
className="p-1 rounded hover:bg-slate-700 text-slate-400 hover:text-slate-200 transition-colors"
>
<X size={16} />
</button>
</div>
{/* Body */}
<div className="px-5 py-4">{children}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { cn } from '../../lib/utils';
interface SkeletonProps {
className?: string;
}
export function Skeleton({ className }: SkeletonProps) {
return (
<div
className={cn('animate-pulse rounded bg-slate-700/60', className)}
aria-hidden="true"
/>
);
}
export function RackSkeleton() {
return (
<div className="flex gap-4 p-4">
{[1, 2].map((i) => (
<div key={i} className="w-48 flex flex-col gap-1">
<Skeleton className="h-6 w-full mb-2" />
{Array.from({ length: 12 }).map((_, j) => (
<Skeleton key={j} className="h-7 w-full" />
))}
</div>
))}
</div>
);
}