Files
GhostGrid/src/components/DeviceInventory.tsx
2026-06-03 15:20:06 +02:00

630 lines
29 KiB
TypeScript

/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useMemo } from 'react';
import { Device, DeviceType } from '../types';
import {
Server, Search, Plus, Trash, Edit2, MapPin, Info,
BookOpen, Save, X, ExternalLink, Gauge
} from 'lucide-react';
// Built-in device class presets shown in the dropdown.
const DEVICE_CLASS_PRESETS = ['Switch', 'Firewall', 'Access-Point', 'Controller'];
interface DeviceInventoryProps {
devices: Device[];
onAddDevice: (device: Omit<Device, 'id'>) => void;
onUpdateDevice: (device: Device) => void;
onDeleteDevice: (id: string) => void;
}
export default function DeviceInventory({
devices,
onAddDevice,
onUpdateDevice,
onDeleteDevice,
}: DeviceInventoryProps) {
// Filters & State
const [searchTerm, setSearchTerm] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(devices[0]?.id || null);
// Always derived from prop so edits reflect immediately in the detail panel
const selectedDevice = useMemo(
() => devices.find(d => d.id === selectedDeviceId) ?? null,
[devices, selectedDeviceId]
);
// Create / Edit modal state
const [isEditing, setIsEditing] = useState(false);
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
// True when the user is defining a device class outside the presets.
const [isCustomType, setIsCustomType] = useState(false);
const [formData, setFormData] = useState<{
id?: string;
hostname: string;
ip: string;
location: string;
notes: string;
type: DeviceType;
emergencySheet: string;
checkMkUrl: string;
}>({
hostname: '',
ip: '',
location: '',
notes: '',
type: 'Switch',
emergencySheet: '',
checkMkUrl: ''
});
// Effective status: nothing is known until CheckMK is linked and reports a state.
const effectiveStatus = (d: Device): 'online' | 'offline' | 'unknown' =>
d.checkMkUrl ? (d.status === 'online' || d.status === 'offline' ? d.status : 'unknown') : 'unknown';
const statusMeta = (s: 'online' | 'offline' | 'unknown') => {
if (s === 'online') return { label: 'online', badge: 'bg-emerald-950/60 border-emerald-900/80 text-emerald-400', dot: 'bg-emerald-400' };
if (s === 'offline') return { label: 'offline', badge: 'bg-rose-950/60 border-rose-900/80 text-rose-400', dot: 'bg-rose-400' };
return { label: 'unknown', badge: 'bg-slate-800/60 border-slate-700 text-slate-400', dot: 'bg-slate-500' };
};
// Filtered devices list
const filteredDevices = devices.filter(dev => {
const matchesSearch =
dev.hostname.toLowerCase().includes(searchTerm.toLowerCase()) ||
dev.ip.includes(searchTerm) ||
dev.location.toLowerCase().includes(searchTerm.toLowerCase());
const matchesType = typeFilter === 'all' || dev.type === typeFilter;
return matchesSearch && matchesType;
});
const handleOpenAdd = () => {
setFormMode('add');
setIsCustomType(false);
setFormData({
hostname: '',
ip: '172.16.',
location: '',
notes: '',
type: 'Switch',
checkMkUrl: '',
emergencySheet: `### EMERGENCY MANUAL [HOSTNAME]
**Device Type:** [Enter Model]
**Serial Number:** [Enter Serial Number]
#### 1. Out-of-Band Console Connection
* **Baud Rate:** 115200
* **Data Bits:** 8
* **Parity:** None
* **Stop Bits:** 1
#### 2. Recovery / Hard Reset
1. Press and hold down the physical reset micro-button on the front panel.
2. Cycle power, wait 10 seconds, then release.`
});
setIsEditing(true);
};
const handleOpenEdit = (dev: Device) => {
setFormMode('edit');
setIsCustomType(!DEVICE_CLASS_PRESETS.includes(dev.type));
setFormData({
id: dev.id,
hostname: dev.hostname,
ip: dev.ip,
location: dev.location,
notes: dev.notes,
type: dev.type,
checkMkUrl: dev.checkMkUrl ?? '',
emergencySheet: dev.emergencySheet
});
setIsEditing(true);
};
const handleSave = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.hostname.trim() || !formData.ip.trim() || !formData.type.trim()) return;
if (formMode === 'add') {
onAddDevice({
hostname: formData.hostname,
ip: formData.ip,
location: formData.location,
notes: formData.notes,
type: formData.type,
status: 'unknown',
emergencySheet: formData.emergencySheet,
checkMkUrl: formData.checkMkUrl
});
} else if (formMode === 'edit' && formData.id) {
const match = devices.find(d => d.id === formData.id);
if (match) {
onUpdateDevice({
...match,
hostname: formData.hostname,
ip: formData.ip,
location: formData.location,
notes: formData.notes,
type: formData.type,
emergencySheet: formData.emergencySheet,
checkMkUrl: formData.checkMkUrl
});
}
}
setIsEditing(false);
};
// Safe internal renderer for markdown headings and code blocks to support the emergency sheet visually
const renderEmergencySheetHtml = (text: string) => {
if (!text) return <p className="text-slate-400 italic text-xs font-sans">No emergency manual entry registered for this device node.</p>;
const lines = text.split('\n');
return lines.map((line, idx) => {
// Headers
if (line.startsWith('### ')) {
return <h4 key={idx} className="text-sm font-bold text-slate-100 mt-4 mb-2 border-b border-slate-800 pb-1 font-sans">{line.replace('### ', '')}</h4>;
}
if (line.startsWith('#### ')) {
return <h5 key={idx} className="text-xs font-bold text-emerald-400 mt-3 mb-1 font-sans">{line.replace('#### ', '')}</h5>;
}
if (line.startsWith('**') && line.endsWith('**')) {
return <p key={idx} className="text-xs font-semibold text-slate-200 mt-2 font-sans">{line.replace(/\*\*/g, '')}</p>;
}
// Bullet lists
if (line.startsWith('* ') || line.startsWith('- ')) {
return <div key={idx} className="flex gap-2 text-xs text-slate-350 ml-4 font-sans align-top mb-1">
<span className="text-emerald-500"></span>
<span>{line.replace(/^[\*\-]\s+/, '')}</span>
</div>;
}
// Numeric lists
if (/^\d+\s*\.\s/.test(line)) {
return <div key={idx} className="flex gap-2 text-xs text-slate-350 ml-4 font-sans align-top mb-1">
<span className="text-emerald-400 font-bold">{line.match(/^\d+/)?.[0]}.</span>
<span>{line.replace(/^\d+\s*\.\s+/, '')}</span>
</div>;
}
// Codeblocks
if (line.startsWith('`') && line.endsWith('`')) {
return <code key={idx} className="block bg-slate-950 p-2 rounded text-[10px] font-mono text-emerald-300 my-2 border border-slate-900 overflow-x-auto">{line.replace(/\`/g, '')}</code>;
}
if (line.trim() === '```bash' || line.trim() === '```') {
return null;
}
// Inline formatting fallback
if (line.includes('**')) {
return (
<p key={idx} className="text-xs text-slate-300 my-1 font-sans">
{line.split('**').map((tok, ti) => {
return ti % 2 === 1 ? <strong key={ti} className="text-slate-100">{tok}</strong> : tok;
})}
</p>
);
}
if (line.includes('`')) {
return (
<p key={idx} className="text-xs text-slate-300 my-1 font-sans">
{line.split('`').map((tok, ti) => {
return ti % 2 === 1 ? <code key={ti} className="bg-slate-950 px-1 py-0.5 rounded text-[10px] text-emerald-300 font-mono">{tok}</code> : tok;
})}
</p>
);
}
return line.trim() === '' ? <div key={idx} className="h-2" /> : <p key={idx} className="text-xs text-slate-300 my-0.5 font-sans">{line}</p>;
});
};
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6" id="device-inventory-root">
{/* LEFT COLUMN: Device List & Controls */}
<div className="lg:col-span-7 bg-[#1E293B] border border-slate-800 rounded-xl p-5 flex flex-col h-full" id="inventory-list-container">
{/* Title */}
<div className="flex justify-between items-center mb-4">
<div>
<h2 className="text-base font-bold text-white flex items-center gap-2 font-sans">
<Server className="w-5 h-5 text-emerald-400" />
Inventory
</h2>
<p className="text-xs text-slate-400 font-sans">Every switch, firewall and AP we own. the source of truth, until someone forgets to update it.</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleOpenAdd}
className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-sans font-semibold rounded-lg text-xs transition-colors hover:cursor-pointer"
id="btn-add-device"
>
<Plus className="w-4 h-4 text-slate-950 stroke-[3]" />
Add Device
</button>
</div>
</div>
{/* Filter Toolbar */}
<div className="flex flex-col sm:flex-row gap-3 mb-4 p-3 bg-slate-900/60 border border-slate-800 rounded-lg">
<div className="relative flex-1">
<span className="absolute left-3 top-2.5 text-slate-400">
<Search className="w-4 h-4" />
</span>
<input
type="text"
placeholder="Search by hostname, IP address, rack location..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded-lg pl-9 pr-4 py-1.5 text-xs focus:outline-none focus:border-emerald-500 transition-colors placeholder:text-slate-500"
/>
</div>
<div className="flex gap-1.5 shrink-0 font-sans text-xs">
{['all', 'Switch', 'Firewall', 'Access-Point', 'Controller'].map((type) => (
<button
key={type}
onClick={() => setTypeFilter(type)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
typeFilter === type
? 'bg-emerald-500/15 border border-emerald-500 text-emerald-400'
: 'bg-slate-950 text-slate-400 border border-slate-850 hover:bg-slate-850 hover:text-white'
}`}
>
{type === 'all' ? 'All' : type}
</button>
))}
</div>
</div>
{/* Device Listing Card Table */}
<div className="flex-1 overflow-y-auto space-y-2.5 max-h-[600px] pr-1">
{filteredDevices.length === 0 ? (
<div className="text-center py-12 text-slate-500 text-xs font-sans">
grep came back empty. no boxes match that filter.
</div>
) : (
filteredDevices.map((device) => {
const isSelected = selectedDevice?.id === device.id;
return (
<div
key={device.id}
onClick={() => setSelectedDeviceId(device.id)}
className={`p-4 border rounded-xl cursor-pointer transition-all flex justify-between items-center ${
isSelected
? 'bg-slate-900 border-emerald-500/80 shadow-[0_4px_12px_rgba(16,185,129,0.06)]'
: 'bg-slate-900/40 border-slate-800 hover:border-slate-700 hover:bg-slate-900/60'
}`}
>
<div className="flex items-start gap-3.5">
{/* Device Icon Circle */}
<div className={`p-2 rounded-lg border text-base ${
device.type === 'Firewall' ? 'bg-rose-950/20 border-rose-900/60 text-rose-400' :
device.type === 'Access-Point' ? 'bg-amber-950/20 border-amber-900/60 text-amber-400' :
device.type === 'Controller' ? 'bg-cyan-950/20 border-cyan-900/60 text-cyan-400' :
'bg-teal-950/20 border-teal-900/60 text-teal-400'
}`}>
<Server className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-mono font-bold text-white text-sm">{device.hostname}</span>
<span className="text-[10px] px-2 py-0.5 font-sans rounded-full font-medium bg-slate-800 border border-slate-700 text-slate-400">{device.type}</span>
</div>
<div className="flex flex-col gap-0.5 mt-1 font-sans">
<span className="text-xs font-mono text-emerald-400">{device.ip}</span>
<span className="text-[10px] text-slate-400 flex items-center gap-1">
<MapPin className="w-3 h-3 text-slate-500" />
{device.location}
</span>
</div>
</div>
</div>
{/* Right: Actions and Status */}
<div className="flex items-center gap-3" onClick={(e) => e.stopPropagation()}>
{/* CheckMK Monitoring Badge */}
{(() => { const m = statusMeta(effectiveStatus(device)); return (
<div className="flex flex-col items-end gap-1 font-sans">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium border capitalize ${m.badge}`}>
<span className={`w-1.5 h-1.5 rounded-full ${m.dot}`} />
{m.label}
</span>
<span className="text-[9px] font-mono text-slate-500">{device.checkMkUrl ? 'via CheckMK' : 'not linked'}</span>
</div>
); })()}
{/* Action Panel */}
<div className="flex items-center gap-1.5 border-l border-slate-800/80 pl-3">
{device.checkMkUrl && (
<a
href={device.checkMkUrl}
target="_blank"
rel="noopener noreferrer"
className="p-1 px-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-cyan-400 transition-colors"
title="Open in CheckMK"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
<button
onClick={() => handleOpenEdit(device)}
className="p-1 px-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-indigo-400 transition-colors"
title="Edit specifications"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<button
onClick={() => {
if (confirm(`Are you sure you want to permanently delete device node "${device.hostname}" from the inventory? Existing topology mappings will become invalid.`)) {
onDeleteDevice(device.id);
}
}}
className="p-1 px-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-rose-400 transition-colors"
title="Delete device"
>
<Trash className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
);
})
)}
</div>
</div>
{/* RIGHT COLUMN: Notfallhandbuch & Technical Specs Details */}
<div className="lg:col-span-5 flex flex-col gap-6" id="inventory-details-container">
{selectedDevice ? (
<>
{/* Header Spec Block */}
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm">
<span className="text-[10px] font-mono uppercase bg-slate-900 border border-slate-800 px-2.5 py-0.5 rounded text-amber-400 font-semibold">
SPECS ID: {selectedDevice.id.toUpperCase()}
</span>
<h3 className="text-lg font-bold text-slate-100 mt-2 font-mono flex items-center justify-between">
<span>{selectedDevice.hostname}</span>
<span className="text-xs font-sans text-slate-400 font-normal">Active Link State</span>
</h3>
<p className="text-xs text-slate-400 font-mono mt-0.5 bg-slate-950 p-2.5 rounded border border-slate-850 mt-2 leading-relaxed">
Hostname: <span className="text-slate-200">{selectedDevice.hostname}</span><br />
IP Address: <span className="text-emerald-400 font-bold">{selectedDevice.ip}</span><br />
Location: <span className="text-slate-200">{selectedDevice.location}</span><br />
Node Class: <span className="text-slate-200">{selectedDevice.type}</span>
</p>
<div className="mt-4 font-sans">
<h4 className="text-xs font-semibold text-slate-300">Description & Technical Notes:</h4>
<div className="mt-1 bg-slate-900/50 rounded p-2.5 border border-slate-800/80 text-xs text-slate-300 leading-relaxed">
{selectedDevice.notes || 'No description notes registered.'}
</div>
</div>
{/* CheckMK Monitoring Panel */}
<div className="mt-4 pt-4 border-t border-slate-800 space-y-2.5">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-400 font-sans font-medium flex items-center gap-1.5">
<Gauge className="w-3.5 h-3.5 text-cyan-400" />
CheckMK Monitoring
</span>
{(() => { const m = statusMeta(effectiveStatus(selectedDevice)); return (
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium border capitalize ${m.badge}`}>
<span className={`w-1.5 h-1.5 rounded-full ${m.dot}`} />
{m.label}
</span>
); })()}
</div>
{selectedDevice.checkMkUrl ? (
<a
href={selectedDevice.checkMkUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 px-3 py-1.5 bg-slate-900 border border-slate-700 text-slate-200 hover:text-cyan-400 hover:border-cyan-500 rounded text-xs transition-colors font-mono"
>
<ExternalLink className="w-3.5 h-3.5" />
Open host in CheckMK
</a>
) : (
<p className="text-[11px] text-slate-500 italic bg-slate-900/50 border border-slate-800 rounded p-2 leading-relaxed">
No CheckMK host linked yet. Add the host URL in the modify window - live status will be pulled from the CheckMK API.
</p>
)}
</div>
</div>
{/* Emergency rescue guidelines sheet */}
<div className="bg-[#1D2432] border border-amber-900/40 rounded-xl p-5 shadow-sm overflow-hidden relative">
<div className="absolute top-0 right-0 left-0 h-1 bg-gradient-to-r from-amber-500 to-rose-500" />
<div className="flex items-center justify-between border-b border-amber-900/30 pb-3 mb-4">
<div className="flex items-center gap-2">
<BookOpen className="w-5 h-5 text-amber-500" />
<h3 className="font-bold text-sm text-slate-100 font-sans">
Emergency Sheet & Disaster Recovery
</h3>
</div>
<span className="text-[9px] font-mono font-bold bg-amber-950 text-amber-300 px-2 py-0.5 rounded border border-amber-900/50">
RESCUE SHEET
</span>
</div>
{/* Markdown Content box */}
<div className="max-h-[350px] overflow-y-auto bg-slate-950/80 p-4 rounded-lg border border-slate-900 leading-relaxed font-sans scrollbar-thin">
{renderEmergencySheetHtml(selectedDevice.emergencySheet)}
</div>
<div className="mt-4 flex items-center gap-2 text-[10px] text-slate-400 bg-slate-900/60 p-2.5 rounded border border-slate-800">
<Info className="w-4 h-4 text-amber-400 shrink-0" />
<span>Console baud rates, break-glass logins and the reset-button dance - for when SSH is a distant memory.</span>
</div>
</div>
</>
) : (
<div className="bg-slate-900 border border-slate-800 rounded-xl p-10 text-center text-slate-500 text-xs font-sans">
Pick a box from the list to see its specs and break-glass playbook.
</div>
)}
</div>
{/* FORM MODAL: Add / Edit Equipment */}
{isEditing && (
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-[#1E293B] border border-slate-700 w-full max-w-lg rounded-xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150">
<div className="bg-slate-900 px-5 py-4 border-b border-slate-700 flex items-center justify-between font-sans">
<h3 className="text-sm font-bold text-white flex items-center gap-2">
<Server className="w-4 h-4 text-emerald-400" />
{formMode === 'add' ? 'Register New Hardware Node' : 'Modify Hardware Specifications'}
</h3>
<button
onClick={() => setIsEditing(false)}
className="text-slate-400 hover:text-white"
>
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={handleSave} className="p-5 space-y-4 font-sans text-xs">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-slate-300 font-semibold mb-1">Hostname</label>
<input
type="text"
required
value={formData.hostname}
onChange={(e) => setFormData({ ...formData, hostname: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500 font-mono"
placeholder="SW-CORE-03"
/>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">IP Address</label>
<input
type="text"
required
value={formData.ip}
onChange={(e) => setFormData({ ...formData, ip: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500 font-mono"
placeholder="172.16.x.x"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-slate-300 font-semibold mb-1">Physical Location</label>
<input
type="text"
required
value={formData.location}
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
placeholder="Server Room R02, Rack C4..."
/>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Device Class</label>
<select
value={isCustomType ? '__custom__' : formData.type}
onChange={(e) => {
if (e.target.value === '__custom__') {
setIsCustomType(true);
setFormData({ ...formData, type: '' });
} else {
setIsCustomType(false);
setFormData({ ...formData, type: e.target.value as DeviceType });
}
}}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
>
<option value="Switch">Switch (L2/L3 Routing-Distribution)</option>
<option value="Firewall">Firewall / Security Appliance</option>
<option value="Access-Point">Access-Point (WLAN Node)</option>
<option value="Controller">Wireless Controller (WLC Engine)</option>
<option value="__custom__">+ Define new class</option>
</select>
{isCustomType && (
<input
type="text"
required
autoFocus
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as DeviceType })}
className="w-full mt-2 bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
placeholder="Enter new device class (e.g. Router, Load-Balancer)"
/>
)}
</div>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Technical Notes / Patching Mappings</label>
<textarea
rows={2}
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
placeholder="Serial numbers, module slots, connected uplinks, license status..."
/>
</div>
{/* CheckMK Monitoring integration */}
<div className="bg-slate-900/60 border border-cyan-900/40 rounded-lg p-3.5 space-y-3">
<div className="flex items-center gap-2 text-cyan-400 font-semibold">
<Gauge className="w-4 h-4" />
CheckMK Monitoring
</div>
<p className="text-[11px] text-slate-400 leading-relaxed flex gap-1.5">
<Info className="w-3.5 h-3.5 text-cyan-400 shrink-0 mt-0.5" />
Link this host to CheckMK. Connectivity is monitored there and pulled in via the CheckMK API - no in-app ping checks.
</p>
<div>
<label className="block text-slate-300 font-semibold mb-1">CheckMK Host URL</label>
<input
type="text"
value={formData.checkMkUrl}
onChange={(e) => setFormData({ ...formData, checkMkUrl: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-cyan-500 font-mono"
placeholder="https://checkmk.internal/site/check_mk/index.py?host=SW-CORE-03"
/>
<p className="text-[10px] text-slate-500 mt-1.5 italic">Without a linked CheckMK host the status stays unknown.</p>
</div>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Disaster Recovery Guide (Markdown formatting supported)</label>
<textarea
rows={6}
value={formData.emergencySheet}
onChange={(e) => setFormData({ ...formData, emergencySheet: e.target.value })}
className="w-full bg-slate-950 text-slate-250 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500 font-mono text-[11px] leading-tight"
placeholder="### EMERGENCY DETAILS..."
/>
</div>
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2.5">
<button
type="button"
onClick={() => setIsEditing(false)}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded font-semibold text-xs"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold rounded text-xs flex items-center gap-1.5"
>
<Save className="w-3.5 h-3.5 text-slate-950" />
Save
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}