Redesign rack module face: ports-first layout, wider column, taller U-slots
Problems fixed:
- Name label + type badge were eating all horizontal space in 1U modules,
pushing 24 port dots into a cramped overflow that was barely visible
- U_HEIGHT_PX=28 was too tight to show a full port row at all
- Column width (192px) was too narrow to fit 24x10px dots + gaps (286px needed)
Changes:
- U_HEIGHT_PX: 28 -> 44px (enough room for ports + resize handle)
- RackColumn: w-48 (192px) -> w-80 (320px), min-w-[200px] -> min-w-[320px]
- PORTS_PER_ROW = 24 constant added to constants.ts
- ModuleBlock face redesigned:
* Removed name <span> and type <Badge> from the visible face
* Module name + IP now shown as a native title tooltip on hover
* Port dots are the primary face content (24 per row, gap-[3px])
* Multiple rows rendered for multi-U modules (up to available height)
* Hidden port overflow shown as "+N more" below the rows
* Drag handle slimmed to 12px; delete/resize handles unchanged
* Type still communicated via background color
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,8 +4,7 @@ import { Trash2, GripVertical, GripHorizontal } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { Module } from '../../types';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { MODULE_TYPE_COLORS, MODULE_TYPE_LABELS, U_HEIGHT_PX } from '../../lib/constants';
|
||||
import { Badge } from '../ui/Badge';
|
||||
import { MODULE_TYPE_COLORS, U_HEIGHT_PX, PORTS_PER_ROW } from '../../lib/constants';
|
||||
import { ConfirmDialog } from '../ui/ConfirmDialog';
|
||||
import { ModuleEditPanel } from '../modals/ModuleEditPanel';
|
||||
import { PortConfigModal } from '../modals/PortConfigModal';
|
||||
@@ -49,6 +48,18 @@ export function ModuleBlock({ module }: ModuleBlockProps) {
|
||||
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);
|
||||
@@ -120,7 +131,7 @@ export function ModuleBlock({ module }: ModuleBlockProps) {
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
'relative w-full border-l-2 select-none flex flex-col justify-between px-2 py-0.5 overflow-hidden transition-opacity',
|
||||
'relative w-full border-l-4 select-none overflow-hidden transition-opacity',
|
||||
colors.bg,
|
||||
colors.border,
|
||||
isDragging ? 'opacity-0' : 'cursor-pointer',
|
||||
@@ -128,6 +139,7 @@ export function ModuleBlock({ module }: ModuleBlockProps) {
|
||||
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)}
|
||||
@@ -136,77 +148,75 @@ export function ModuleBlock({ module }: ModuleBlockProps) {
|
||||
aria-label={`Edit ${module.name}`}
|
||||
onKeyDown={(e) => e.key === 'Enter' && setEditOpen(true)}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
{/* Drag handle — slim left strip */}
|
||||
<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"
|
||||
className="absolute left-0 top-0 bottom-3 w-3 flex items-center justify-center cursor-grab active:cursor-grabbing text-white/20 hover:text-white/60 transition-colors z-10 touch-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Drag ${module.name}`}
|
||||
>
|
||||
<GripVertical size={10} />
|
||||
<GripVertical size={9} />
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<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]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{module.ipAddress && (
|
||||
<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 && previewUSize === null && (
|
||||
{/* Port grid — primary face content */}
|
||||
{hasPorts && previewUSize === null ? (
|
||||
<div
|
||||
className="flex flex-wrap gap-0.5 mt-0.5"
|
||||
className="flex flex-col gap-[3px] pl-4 pr-6 pt-[5px]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{module.ports.slice(0, 32).map((port) => {
|
||||
const hasVlan = port.vlans.length > 0;
|
||||
return (
|
||||
<button
|
||||
key={port.id}
|
||||
onClick={() => openPort(port.id)}
|
||||
aria-label={`Port ${port.portNumber}`}
|
||||
className={cn(
|
||||
'w-2.5 h-2.5 rounded-sm border transition-colors',
|
||||
hasVlan
|
||||
? 'bg-green-400 border-green-500 hover:bg-green-300'
|
||||
: 'bg-slate-600 border-slate-500 hover:bg-slate-400'
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{module.ports.length > 32 && (
|
||||
<span className="text-[9px] text-slate-400">+{module.ports.length - 32}</span>
|
||||
{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}
|
||||
onClick={() => openPort(port.id)}
|
||||
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>
|
||||
) : (
|
||||
/* No ports or resizing — show nothing (color communicates type) */
|
||||
previewUSize === null && (
|
||||
<div className="pl-4 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>
|
||||
)}
|
||||
|
||||
{/* Delete button — hover only */}
|
||||
{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"
|
||||
className="absolute top-0.5 right-0.5 p-0.5 rounded bg-red-900/80 hover:bg-red-600 text-white transition-colors z-10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmDeleteOpen(true);
|
||||
}}
|
||||
aria-label={`Delete ${module.name}`}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -215,9 +225,7 @@ export function ModuleBlock({ module }: ModuleBlockProps) {
|
||||
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',
|
||||
hovered || previewUSize !== null ? 'opacity-100' : 'opacity-0 hover:opacity-100',
|
||||
'transition-opacity'
|
||||
)}
|
||||
onPointerDown={handleResizePointerDown}
|
||||
@@ -227,7 +235,7 @@ export function ModuleBlock({ module }: ModuleBlockProps) {
|
||||
aria-label="Resize module"
|
||||
title="Drag to resize"
|
||||
>
|
||||
<GripHorizontal size={10} className="text-white/50 pointer-events-none" />
|
||||
<GripHorizontal size={9} className="text-white/40 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ export function RackColumn({ rack, draggingModuleId }: RackColumnProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={setNodeRef} style={style} className="flex flex-col min-w-[200px] w-48 shrink-0">
|
||||
<div ref={setNodeRef} style={style} className="flex flex-col min-w-[320px] w-80 shrink-0">
|
||||
{/* Rack header — drag handle for reorder */}
|
||||
<div className="flex items-center gap-1 bg-slate-700 border border-slate-600 rounded-t-lg px-2 py-1.5 group">
|
||||
{/* Drag handle */}
|
||||
|
||||
@@ -70,7 +70,10 @@ export const MODULE_TYPE_COLORS: Record<ModuleType, { bg: string; border: string
|
||||
};
|
||||
|
||||
// ---- U-slot height in px (used for layout calculations) ----
|
||||
export const U_HEIGHT_PX = 28;
|
||||
export const U_HEIGHT_PX = 44;
|
||||
|
||||
// ---- Ports rendered per row in ModuleBlock ----
|
||||
export const PORTS_PER_ROW = 24;
|
||||
|
||||
// ---- Default rack size ----
|
||||
export const DEFAULT_RACK_U = 42;
|
||||
|
||||
Reference in New Issue
Block a user