2026-03-21 22:03:42 -05:00
|
|
|
import { useState, useCallback, useRef } from 'react';
|
|
|
|
|
import { useDraggable } from '@dnd-kit/core';
|
2026-03-21 23:10:12 -05:00
|
|
|
import { GripHorizontal } from 'lucide-react';
|
2026-03-21 21:48:56 -05:00
|
|
|
import { toast } from 'sonner';
|
|
|
|
|
import type { Module } from '../../types';
|
|
|
|
|
import { cn } from '../../lib/utils';
|
2026-03-21 23:03:10 -05:00
|
|
|
import { MODULE_TYPE_COLORS, U_HEIGHT_PX, PORTS_PER_ROW } from '../../lib/constants';
|
2026-03-21 21:48:56 -05:00
|
|
|
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) {
|
2026-03-21 23:10:12 -05:00
|
|
|
const { racks, updateModuleLocal } = useRackStore();
|
2026-03-21 21:48:56 -05:00
|
|
|
const [hovered, setHovered] = useState(false);
|
|
|
|
|
const [editOpen, setEditOpen] = useState(false);
|
|
|
|
|
const [portModalOpen, setPortModalOpen] = useState(false);
|
|
|
|
|
const [selectedPortId, setSelectedPortId] = useState<string | null>(null);
|
|
|
|
|
|
2026-03-21 22:03:42 -05:00
|
|
|
// 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,
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-03-21 21:48:56 -05:00
|
|
|
|
2026-03-21 22:03:42 -05:00
|
|
|
const colors = MODULE_TYPE_COLORS[module.type];
|
|
|
|
|
const displayUSize = previewUSize ?? module.uSize;
|
|
|
|
|
const height = displayUSize * U_HEIGHT_PX;
|
2026-03-21 21:48:56 -05:00
|
|
|
const hasPorts = module.ports.length > 0;
|
|
|
|
|
|
2026-03-21 23:03:10 -05:00
|
|
|
// 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;
|
|
|
|
|
|
2026-03-21 22:03:42 -05:00
|
|
|
// 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');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-21 21:48:56 -05:00
|
|
|
function openPort(portId: string) {
|
|
|
|
|
setSelectedPortId(portId);
|
|
|
|
|
setPortModalOpen(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<div
|
2026-03-21 22:03:42 -05:00
|
|
|
ref={setNodeRef}
|
2026-03-21 23:10:12 -05:00
|
|
|
{...listeners}
|
|
|
|
|
{...attributes}
|
2026-03-21 21:48:56 -05:00
|
|
|
className={cn(
|
2026-03-21 23:03:10 -05:00
|
|
|
'relative w-full border-l-4 select-none overflow-hidden transition-opacity',
|
2026-03-21 21:48:56 -05:00
|
|
|
colors.bg,
|
|
|
|
|
colors.border,
|
2026-03-21 23:10:12 -05:00
|
|
|
isDragging ? 'opacity-0' : 'cursor-grab active:cursor-grabbing',
|
2026-03-21 22:03:42 -05:00
|
|
|
!isDragging && hovered && 'brightness-110',
|
|
|
|
|
previewUSize !== null && 'ring-1 ring-white/30'
|
2026-03-21 21:48:56 -05:00
|
|
|
)}
|
|
|
|
|
style={{ height }}
|
2026-03-21 23:03:10 -05:00
|
|
|
title={`${module.name}${module.ipAddress ? ` — ${module.ipAddress}` : ''}`}
|
2026-03-21 21:48:56 -05:00
|
|
|
onMouseEnter={() => setHovered(true)}
|
|
|
|
|
onMouseLeave={() => setHovered(false)}
|
2026-03-21 22:03:42 -05:00
|
|
|
onClick={() => !isDragging && !isResizing.current && setEditOpen(true)}
|
2026-03-21 21:48:56 -05:00
|
|
|
role="button"
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
aria-label={`Edit ${module.name}`}
|
|
|
|
|
onKeyDown={(e) => e.key === 'Enter' && setEditOpen(true)}
|
|
|
|
|
>
|
2026-03-21 23:03:10 -05:00
|
|
|
{/* Port grid — primary face content */}
|
|
|
|
|
{hasPorts && previewUSize === null ? (
|
2026-03-21 23:10:12 -05:00
|
|
|
<div className="flex flex-col gap-[3px] px-2 pt-[5px]">
|
2026-03-21 23:03:10 -05:00
|
|
|
{visibleRows.map((row, rowIdx) => (
|
|
|
|
|
<div key={rowIdx} className="flex gap-[3px]">
|
|
|
|
|
{row.map((port) => {
|
|
|
|
|
const hasVlan = port.vlans.length > 0;
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={port.id}
|
2026-03-21 23:10:12 -05:00
|
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
|
|
|
onClick={(e) => { e.stopPropagation(); openPort(port.id); }}
|
2026-03-21 23:03:10 -05:00
|
|
|
aria-label={`Port ${port.portNumber}`}
|
|
|
|
|
title={`Port ${port.portNumber}${port.label ? ` · ${port.label}` : ''}`}
|
|
|
|
|
className={cn(
|
|
|
|
|
'w-2.5 h-2.5 rounded-sm border transition-colors shrink-0',
|
|
|
|
|
hasVlan
|
|
|
|
|
? 'bg-green-400 border-green-500 hover:bg-green-300'
|
|
|
|
|
: 'bg-slate-600 border-slate-500 hover:bg-slate-400'
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
{hiddenPortCount > 0 && (
|
|
|
|
|
<span className="text-[9px] text-white/40 leading-none">+{hiddenPortCount} more</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
previewUSize === null && (
|
2026-03-21 23:10:12 -05:00
|
|
|
<div className="px-2 pt-1.5 text-[10px] text-white/30 italic select-none">no ports</div>
|
2026-03-21 23:03:10 -05:00
|
|
|
)
|
2026-03-21 21:48:56 -05:00
|
|
|
)}
|
|
|
|
|
|
2026-03-21 23:03:10 -05:00
|
|
|
{/* Resize preview label */}
|
2026-03-21 22:03:42 -05:00
|
|
|
{previewUSize !== null && (
|
|
|
|
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
2026-03-21 23:03:10 -05:00
|
|
|
<span className="text-xs font-bold text-white/80 bg-black/40 px-2 py-0.5 rounded">
|
2026-03-21 22:03:42 -05:00
|
|
|
{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',
|
2026-03-21 23:03:10 -05:00
|
|
|
hovered || previewUSize !== null ? 'opacity-100' : 'opacity-0 hover:opacity-100',
|
2026-03-21 22:03:42 -05:00
|
|
|
'transition-opacity'
|
|
|
|
|
)}
|
2026-03-21 23:10:12 -05:00
|
|
|
onPointerDown={(e) => { e.stopPropagation(); handleResizePointerDown(e); }}
|
2026-03-21 22:03:42 -05:00
|
|
|
onPointerMove={handleResizePointerMove}
|
|
|
|
|
onPointerUp={handleResizePointerUp}
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
aria-label="Resize module"
|
|
|
|
|
title="Drag to resize"
|
|
|
|
|
>
|
2026-03-21 23:03:10 -05:00
|
|
|
<GripHorizontal size={9} className="text-white/40 pointer-events-none" />
|
2026-03-21 22:03:42 -05:00
|
|
|
</div>
|
2026-03-21 21:48:56 -05:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<ModuleEditPanel module={module} open={editOpen} onClose={() => setEditOpen(false)} />
|
|
|
|
|
|
|
|
|
|
{selectedPortId && (
|
|
|
|
|
<PortConfigModal
|
|
|
|
|
portId={selectedPortId}
|
|
|
|
|
open={portModalOpen}
|
|
|
|
|
onClose={() => {
|
|
|
|
|
setPortModalOpen(false);
|
|
|
|
|
setSelectedPortId(null);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|