Add module resize handle to ModuleBlock
- Drag handle at bottom edge of each module (GripHorizontal icon) - Pointer capture tracks vertical drag delta → U-size delta - Clamped to: minimum 1U, rack bounds, first module below - Shows current U-size label during active resize - On release: PUT /modules/:id with new uSize (server validates collision) - Optimistic store update via updateModuleLocal on success Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string | null>(null);
|
||||
|
||||
const colors = MODULE_TYPE_COLORS[module.type];
|
||||
const height = module.uSize * U_HEIGHT_PX;
|
||||
// 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;
|
||||
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
setDeletingLoading(true);
|
||||
try {
|
||||
@@ -51,23 +118,37 @@ export function ModuleBlock({ module }: ModuleBlockProps) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
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',
|
||||
'relative w-full border-l-2 select-none flex flex-col justify-between px-2 py-0.5 overflow-hidden transition-opacity',
|
||||
colors.bg,
|
||||
colors.border,
|
||||
hovered && 'brightness-110'
|
||||
isDragging ? 'opacity-0' : 'cursor-pointer',
|
||||
!isDragging && hovered && 'brightness-110',
|
||||
previewUSize !== null && 'ring-1 ring-white/30'
|
||||
)}
|
||||
style={{ height }}
|
||||
onMouseEnter={() => 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 */}
|
||||
<div
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className="absolute left-0 top-0 bottom-6 w-4 flex items-start justify-center pt-1.5 cursor-grab active:cursor-grabbing text-white/30 hover:text-white/70 transition-colors z-10 touch-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Drag ${module.name}`}
|
||||
>
|
||||
<GripVertical size={10} />
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<div className="flex items-center gap-1 min-w-0 pl-3">
|
||||
<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]}
|
||||
@@ -78,8 +159,17 @@ export function ModuleBlock({ module }: ModuleBlockProps) {
|
||||
<div className="text-[10px] text-slate-300 font-mono truncate">{module.ipAddress}</div>
|
||||
)}
|
||||
|
||||
{/* U-size preview label during resize */}
|
||||
{previewUSize !== null && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<span className="text-xs font-bold text-white/70 bg-black/30 px-1.5 py-0.5 rounded">
|
||||
{previewUSize}U
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Port dots — only if module has ports and enough height */}
|
||||
{hasPorts && height >= 28 && (
|
||||
{hasPorts && height >= 28 && previewUSize === null && (
|
||||
<div
|
||||
className="flex flex-wrap gap-0.5 mt-0.5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -107,7 +197,7 @@ export function ModuleBlock({ module }: ModuleBlockProps) {
|
||||
)}
|
||||
|
||||
{/* Delete button — hover only */}
|
||||
{hovered && (
|
||||
{hovered && previewUSize === null && (
|
||||
<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) => {
|
||||
@@ -119,6 +209,26 @@ export function ModuleBlock({ module }: ModuleBlockProps) {
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 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={handleResizePointerDown}
|
||||
onPointerMove={handleResizePointerMove}
|
||||
onPointerUp={handleResizePointerUp}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Resize module"
|
||||
title="Drag to resize"
|
||||
>
|
||||
<GripHorizontal size={10} className="text-white/50 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModuleEditPanel module={module} open={editOpen} onClose={() => setEditOpen(false)} />
|
||||
|
||||
Reference in New Issue
Block a user