Files
rack-planner/client/src/components/rack/ModuleBlock.tsx

239 lines
9.2 KiB
TypeScript
Raw Normal View History

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<number | null>(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<HTMLDivElement>) {
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<HTMLDivElement>) {
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<HTMLDivElement>) {
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 (
<>
<div
ref={setNodeRef}
{...listeners}
{...attributes}
className={cn(
'module-block relative w-full border-l-4 select-none overflow-hidden transition-opacity',
colors.bg,
colors.border,
isDragging ? 'opacity-0 pointer-events-none' : 'cursor-grab active:cursor-grabbing',
!isDragging && hovered && 'brightness-110',
previewUSize !== null && 'ring-1 ring-white/30'
)}
style={{ height }}
title={`${module.name}${module.ipAddress ? `${module.ipAddress}` : ''}`}
onMouseEnter={() => 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 ? (
<div className="flex flex-col gap-[3px] px-2 pt-[5px]">
{visibleRows.map((row, rowIdx) => (
<div key={rowIdx} className="flex gap-[3px]">
{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 (
<button
key={port.id}
data-port-id={port.id}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => handlePortClick(e, port.id)}
aria-label={`Port ${port.portNumber}`}
title={`Port ${port.portNumber}${port.label ? ` · ${port.label}` : ''}${
hasVlan ? ` (VLAN ${port.vlans.map((v) => v.vlan?.vlanId).filter(Boolean).join(',')})` : ''
}\nShift+Click for settings`}
style={{ backgroundColor: vlanColor, borderColor: 'rgba(0,0,0,0.2)' }}
className={cn(
'w-2.5 h-2.5 rounded-sm border transition-all shrink-0 hover:scale-110 hover:brightness-125',
isCablingSource &&
'ring-2 ring-blue-400 ring-offset-1 ring-offset-slate-900 animate-pulse'
)}
/>
);
})}
</div>
))}
{hiddenPortCount > 0 && (
<span className="text-[9px] text-white/40 leading-none">+{hiddenPortCount} more</span>
)}
</div>
) : (
previewUSize === null && (
<div className="px-2 pt-1.5 text-[10px] text-white/30 italic select-none">no ports</div>
)
)}
{/* Resize preview label */}
{previewUSize !== null && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="text-xs font-bold text-white/80 bg-black/40 px-2 py-0.5 rounded">
{previewUSize}U
</span>
</div>
)}
{/* Resize handle — bottom edge */}
<div
className={cn(
'absolute bottom-0 left-0 right-0 h-3 flex items-center justify-center z-20',
'cursor-ns-resize touch-none',
hovered || previewUSize !== null ? 'opacity-100' : 'opacity-0 hover:opacity-100',
'transition-opacity'
)}
onPointerDown={(e) => { e.stopPropagation(); handleResizePointerDown(e); }}
onPointerMove={handleResizePointerMove}
onPointerUp={handleResizePointerUp}
onClick={(e) => e.stopPropagation()}
aria-label="Resize module"
title="Drag to resize"
>
<GripHorizontal size={9} className="text-white/40 pointer-events-none" />
</div>
</div>
<ModuleEditPanel module={module} open={editOpen} onClose={() => setEditOpen(false)} />
</>
);
}