diff --git a/client/src/components/rack/ModuleBlock.tsx b/client/src/components/rack/ModuleBlock.tsx index f6bef78..9d3be26 100644 --- a/client/src/components/rack/ModuleBlock.tsx +++ b/client/src/components/rack/ModuleBlock.tsx @@ -1,5 +1,6 @@ -import { useState } from 'react'; -import { Trash2 } from 'lucide-react'; +import { useState, useCallback, useRef } from 'react'; +import { useDraggable } from '@dnd-kit/core'; +import { Trash2, GripVertical, GripHorizontal } from 'lucide-react'; import { toast } from 'sonner'; import type { Module } from '../../types'; import { cn } from '../../lib/utils'; @@ -16,7 +17,7 @@ interface ModuleBlockProps { } export function ModuleBlock({ module }: ModuleBlockProps) { - const { removeModuleLocal } = useRackStore(); + const { racks, removeModuleLocal, updateModuleLocal } = useRackStore(); const [hovered, setHovered] = useState(false); const [editOpen, setEditOpen] = useState(false); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); @@ -24,11 +25,77 @@ export function ModuleBlock({ module }: ModuleBlockProps) { const [portModalOpen, setPortModalOpen] = useState(false); const [selectedPortId, setSelectedPortId] = useState(null); - const colors = MODULE_TYPE_COLORS[module.type]; - const height = module.uSize * U_HEIGHT_PX; + // 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; + // 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'); + } + } + async function handleDelete() { setDeletingLoading(true); try { @@ -51,23 +118,37 @@ export function ModuleBlock({ module }: ModuleBlockProps) { return ( <>
setHovered(true)} onMouseLeave={() => setHovered(false)} - onClick={() => setEditOpen(true)} + onClick={() => !isDragging && !isResizing.current && setEditOpen(true)} role="button" tabIndex={0} aria-label={`Edit ${module.name}`} onKeyDown={(e) => e.key === 'Enter' && setEditOpen(true)} > + {/* Drag handle */} +
e.stopPropagation()} + aria-label={`Drag ${module.name}`} + > + +
+ {/* Main content */} -
+
{module.name} {MODULE_TYPE_LABELS[module.type]} @@ -78,8 +159,17 @@ export function ModuleBlock({ module }: ModuleBlockProps) {
{module.ipAddress}
)} + {/* U-size preview label during resize */} + {previewUSize !== null && ( +
+ + {previewUSize}U + +
+ )} + {/* Port dots — only if module has ports and enough height */} - {hasPorts && height >= 28 && ( + {hasPorts && height >= 28 && previewUSize === null && (
e.stopPropagation()} @@ -107,7 +197,7 @@ export function ModuleBlock({ module }: ModuleBlockProps) { )} {/* Delete button — hover only */} - {hovered && ( + {hovered && previewUSize === null && ( )} + + {/* Resize handle — bottom edge */} +
e.stopPropagation()} + aria-label="Resize module" + title="Drag to resize" + > + +
setEditOpen(false)} />