import { useState, useCallback, useRef } from 'react'; import { useDraggable } from '@dnd-kit/core'; import { GripHorizontal } from 'lucide-react'; import { toast } from 'sonner'; import type { Module } from '../../types'; import { cn } from '../../lib/utils'; import { MODULE_TYPE_COLORS, U_HEIGHT_PX, PORTS_PER_ROW } from '../../lib/constants'; 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 { racks, updateModuleLocal, setActiveConfigPortId } = useRackStore(); const [hovered, setHovered] = useState(false); const [editOpen, setEditOpen] = useState(false); // Resize state const [previewUSize, setPreviewUSize] = useState(null); const isResizing = useRef(false); const resizeStartY = useRef(0); const resizeStartUSize = useRef(0); const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: `module-${module.id}`, disabled: isResizing.current, data: { dragType: 'module', moduleId: module.id, fromRackId: module.rackId, fromUPosition: module.uPosition, uSize: module.uSize, label: module.name, }, }); const colors = MODULE_TYPE_COLORS[module.type]; const displayUSize = previewUSize ?? module.uSize; const height = displayUSize * U_HEIGHT_PX; const hasPorts = module.ports.length > 0; // Split ports into rows of PORTS_PER_ROW const portRows: (typeof module.ports)[] = []; for (let i = 0; i < module.ports.length; i += PORTS_PER_ROW) { portRows.push(module.ports.slice(i, i + PORTS_PER_ROW)); } // Only show as many rows as fit within the current height // Each row needs ~14px (10px dot + 4px gap/padding) const availableForPorts = height - 16; // subtract top padding + resize handle const maxRows = Math.max(1, Math.floor(availableForPorts / 14)); const visibleRows = portRows.slice(0, maxRows); const hiddenPortCount = module.ports.length - visibleRows.flat().length; // Compute the maximum allowed uSize for this module (rack bounds + collision) const maxResizeU = useCallback((): number => { const rack = racks.find((r) => r.id === module.rackId); if (!rack) return module.uSize; // Bound by rack totalU const rackMax = rack.totalU - module.uPosition + 1; // Find the first module that starts at uPosition >= module.uPosition + 1 (anything below us) const nextStart = rack.modules .filter((m) => m.id !== module.id && m.uPosition > module.uPosition) .reduce((min, m) => Math.min(min, m.uPosition), rack.totalU + 1); const collisionMax = nextStart - module.uPosition; return Math.min(rackMax, collisionMax); }, [racks, module]); function handleResizePointerDown(e: React.PointerEvent) { e.preventDefault(); e.stopPropagation(); isResizing.current = true; resizeStartY.current = e.clientY; resizeStartUSize.current = module.uSize; (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); } function handleResizePointerMove(e: React.PointerEvent) { if (!isResizing.current) return; const deltaY = e.clientY - resizeStartY.current; const deltaU = Math.round(deltaY / U_HEIGHT_PX); const max = maxResizeU(); const newU = Math.max(1, Math.min(resizeStartUSize.current + deltaU, max)); setPreviewUSize(newU); } async function handleResizePointerUp(e: React.PointerEvent) { if (!isResizing.current) return; isResizing.current = false; (e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId); const finalU = previewUSize ?? module.uSize; setPreviewUSize(null); if (finalU === module.uSize) return; try { await apiClient.modules.update(module.id, { uSize: finalU }); updateModuleLocal(module.id, { uSize: finalU }); } catch (err) { toast.error(err instanceof Error ? err.message : 'Resize failed'); } } function openPort(portId: string) { setActiveConfigPortId(portId); } const { cablingFromPortId, setCablingFromPortId, createConnection } = useRackStore(); async function handlePortClick(e: React.MouseEvent, portId: string) { e.stopPropagation(); // If shift key is pressed, open config modal as before if (e.shiftKey) { openPort(portId); return; } // Toggle cabling mode if (!cablingFromPortId) { setCablingFromPortId(portId); } else if (cablingFromPortId === portId) { setCablingFromPortId(null); } else { // Connect! try { await createConnection(cablingFromPortId, portId); setCablingFromPortId(null); toast.success('Patch cable connected'); } catch (err) { toast.error(err instanceof Error ? err.message : 'Connection failed'); setCablingFromPortId(null); } } } return ( <>
setHovered(true)} onMouseLeave={() => setHovered(false)} onClick={() => !isDragging && !isResizing.current && setEditOpen(true)} role="button" tabIndex={0} aria-label={`Edit ${module.name}`} onKeyDown={(e) => e.key === 'Enter' && setEditOpen(true)} > {/* Port grid โ€” primary face content */} {hasPorts && previewUSize === null ? (
{visibleRows.map((row, rowIdx) => (
{row.map((port) => { const hasVlan = port.vlans.length > 0; const vlanColor = hasVlan ? port.mode === 'ACCESS' ? port.vlans[0]?.vlan?.color || '#10b981' : '#8b5cf6' : '#475569'; const isCablingSource = cablingFromPortId === port.id; return (
))} {hiddenPortCount > 0 && ( +{hiddenPortCount} more )}
) : ( previewUSize === null && (
no ports
) )} {/* Resize preview label */} {previewUSize !== null && (
{previewUSize}U
)} {/* Resize handle โ€” bottom edge */}
{ e.stopPropagation(); handleResizePointerDown(e); }} onPointerMove={handleResizePointerMove} onPointerUp={handleResizePointerUp} onClick={(e) => e.stopPropagation()} aria-label="Resize module" title="Drag to resize" >
setEditOpen(false)} /> ); }