Initial scaffold: full-stack RackMapper application
Complete project scaffold with working auth, REST API, Prisma/SQLite schema, Docker config, and React frontend for both Rack Planner and Service Mapper modules. Both server and client pass TypeScript strict mode with zero errors. Initial migration applied. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
148
client/src/components/rack/ModuleBlock.tsx
Normal file
148
client/src/components/rack/ModuleBlock.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useState } from 'react';
|
||||
import { Trash2 } 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 { ConfirmDialog } from '../ui/ConfirmDialog';
|
||||
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 { removeModuleLocal } = useRackStore();
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [deletingLoading, setDeletingLoading] = useState(false);
|
||||
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;
|
||||
|
||||
const hasPorts = module.ports.length > 0;
|
||||
|
||||
async function handleDelete() {
|
||||
setDeletingLoading(true);
|
||||
try {
|
||||
await apiClient.modules.delete(module.id);
|
||||
removeModuleLocal(module.id);
|
||||
toast.success(`${module.name} removed`);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Delete failed');
|
||||
} finally {
|
||||
setDeletingLoading(false);
|
||||
setConfirmDeleteOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openPort(portId: string) {
|
||||
setSelectedPortId(portId);
|
||||
setPortModalOpen(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
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',
|
||||
colors.bg,
|
||||
colors.border,
|
||||
hovered && 'brightness-110'
|
||||
)}
|
||||
style={{ height }}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onClick={() => setEditOpen(true)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Edit ${module.name}`}
|
||||
onKeyDown={(e) => e.key === 'Enter' && setEditOpen(true)}
|
||||
>
|
||||
{/* Main content */}
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Port dots — only if module has ports and enough height */}
|
||||
{hasPorts && height >= 28 && (
|
||||
<div
|
||||
className="flex flex-wrap gap-0.5 mt-0.5"
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete button — hover only */}
|
||||
{hovered && (
|
||||
<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) => {
|
||||
e.stopPropagation();
|
||||
setConfirmDeleteOpen(true);
|
||||
}}
|
||||
aria-label={`Delete ${module.name}`}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ModuleEditPanel module={module} open={editOpen} onClose={() => setEditOpen(false)} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDeleteOpen}
|
||||
onClose={() => setConfirmDeleteOpen(false)}
|
||||
onConfirm={handleDelete}
|
||||
title="Remove Module"
|
||||
message={`Remove "${module.name}" from the rack? This will also delete all associated port configuration.`}
|
||||
confirmLabel="Remove"
|
||||
loading={deletingLoading}
|
||||
/>
|
||||
|
||||
{selectedPortId && (
|
||||
<PortConfigModal
|
||||
portId={selectedPortId}
|
||||
open={portModalOpen}
|
||||
onClose={() => {
|
||||
setPortModalOpen(false);
|
||||
setSelectedPortId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user