Files
mapmaker/public/src/components/map/ItemContextMenu.tsx

388 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef } from 'react';
import { mapItemService } from '../../services/mapItemService';
interface ItemContextMenuProps {
item: any;
position: { x: number; y: number };
onClose: () => void;
onUpdate: () => void;
}
export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemContextMenuProps) {
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [showNotesDialog, setShowNotesDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showPortConfigDialog, setShowPortConfigDialog] = useState(false);
const [newName, setNewName] = useState(item.properties.name || '');
const [notes, setNotes] = useState(item.properties.notes || '');
const [imageData, setImageData] = useState<string | null>(item.properties.image || null);
const [portCount, setPortCount] = useState(item.properties.port_count || 5);
const [deleteConnectedCables, setDeleteConnectedCables] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const isSwitch = item.type === 'switch';
const isDevice = ['switch', 'indoor_ap', 'outdoor_ap'].includes(item.type);
const hasConnections = item.properties.connections && item.properties.connections.length > 0;
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [onClose]);
const handleDelete = async () => {
try {
// If device with connections and user wants to delete connected cables
if (isDevice && deleteConnectedCables && hasConnections) {
// First delete all connected cables
const { mapItemService: itemService } = await import('../../services/mapItemService');
const allItems = await itemService.getMapItems(item.map_id);
// Find all cables connected to this device
const connectedCableIds = item.properties.connections.map((conn: any) => conn.cable_id);
const cablesToDelete = allItems.filter((i: any) =>
i.type === 'cable' && connectedCableIds.includes(i.id)
);
// Delete each cable
for (const cable of cablesToDelete) {
await itemService.deleteMapItem(item.map_id, cable.id);
}
}
// Delete the device/item itself
await mapItemService.deleteMapItem(item.map_id, item.id);
onUpdate();
onClose();
} catch (error) {
console.error('Failed to delete item:', error);
alert('Failed to delete item');
}
};
const handleRename = async () => {
try {
await mapItemService.updateMapItem(item.map_id, item.id, {
properties: {
...item.properties,
name: newName,
},
});
onUpdate();
setShowRenameDialog(false);
onClose();
} catch (error) {
console.error('Failed to rename item:', error);
alert('Failed to rename item');
}
};
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Check file size (max 2MB)
if (file.size > 2 * 1024 * 1024) {
alert('Image too large. Please use an image smaller than 2MB.');
return;
}
// Check file type
if (!file.type.startsWith('image/')) {
alert('Please select an image file.');
return;
}
const reader = new FileReader();
reader.onload = () => {
setImageData(reader.result as string);
};
reader.readAsDataURL(file);
};
const handleRemoveImage = () => {
setImageData(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleSaveNotes = async () => {
try {
await mapItemService.updateMapItem(item.map_id, item.id, {
properties: {
...item.properties,
notes: notes,
image: imageData,
},
});
onUpdate();
setShowNotesDialog(false);
onClose();
} catch (error) {
console.error('Failed to save notes:', error);
alert('Failed to save notes');
}
};
const handleSavePortConfig = async () => {
try {
await mapItemService.updateMapItem(item.map_id, item.id, {
properties: {
...item.properties,
port_count: portCount,
},
});
onUpdate();
setShowPortConfigDialog(false);
onClose();
} catch (error) {
console.error('Failed to save port configuration:', error);
alert('Failed to save port configuration');
}
};
if (showPortConfigDialog) {
return (
<div
ref={menuRef}
className="fixed bg-white rounded-lg shadow-xl border border-gray-200 p-4"
style={{ left: position.x, top: position.y, zIndex: 10000, minWidth: '250px' }}
>
<h3 className="font-semibold mb-2">Configure Ports</h3>
<label className="block text-sm text-gray-600 mb-2">
Total number of ports:
</label>
<input
type="number"
min="1"
max="96"
value={portCount}
onChange={(e) => setPortCount(parseInt(e.target.value) || 1)}
className="w-full px-3 py-2 border border-gray-300 rounded mb-3"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleSavePortConfig();
if (e.key === 'Escape') { setShowPortConfigDialog(false); onClose(); }
}}
/>
<div className="text-xs text-gray-500 mb-3">
Currently used: {item.properties.connections?.length || 0} ports
</div>
<div className="flex gap-2">
<button
onClick={handleSavePortConfig}
className="flex-1 px-3 py-1.5 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Save
</button>
<button
onClick={() => { setShowPortConfigDialog(false); onClose(); }}
className="flex-1 px-3 py-1.5 bg-gray-300 text-gray-700 rounded hover:bg-gray-400"
>
Cancel
</button>
</div>
</div>
);
}
if (showDeleteDialog) {
return (
<div
ref={menuRef}
className="fixed bg-white rounded-lg shadow-xl border border-gray-200 p-4"
style={{ left: position.x, top: position.y, zIndex: 10000, minWidth: '300px' }}
>
<h3 className="font-semibold text-lg mb-2">Delete Item</h3>
<p className="text-gray-600 mb-4">
Are you sure you want to delete <span className="font-semibold">{item.properties.name || item.type}</span>?
{item.type === 'cable' && item.properties.start_device_id && (
<span className="block mt-2 text-sm">This will also remove the connection from the connected devices.</span>
)}
</p>
{/* Checkbox for deleting connected cables */}
{isDevice && hasConnections && (
<label className="flex items-center gap-2 mb-4 cursor-pointer">
<input
type="checkbox"
checked={deleteConnectedCables}
onChange={(e) => setDeleteConnectedCables(e.target.checked)}
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">
Also delete {item.properties.connections.length} connected cable{item.properties.connections.length !== 1 ? 's' : ''}
</span>
</label>
)}
<div className="flex gap-2">
<button
onClick={handleDelete}
className="flex-1 px-3 py-2 bg-red-600 text-white rounded hover:bg-red-700 font-medium"
>
Delete
</button>
<button
onClick={() => { setShowDeleteDialog(false); setDeleteConnectedCables(false); onClose(); }}
className="flex-1 px-3 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400"
>
Cancel
</button>
</div>
</div>
);
}
if (showRenameDialog) {
return (
<div
ref={menuRef}
className="fixed bg-white rounded-lg shadow-xl border border-gray-200 p-4"
style={{ left: position.x, top: position.y, zIndex: 10000, minWidth: '250px' }}
>
<h3 className="font-semibold mb-2">Rename Item</h3>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded mb-3"
placeholder="Enter new name"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleRename();
if (e.key === 'Escape') { setShowRenameDialog(false); onClose(); }
}}
/>
<div className="flex gap-2">
<button
onClick={handleRename}
className="flex-1 px-3 py-1.5 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Save
</button>
<button
onClick={() => { setShowRenameDialog(false); onClose(); }}
className="flex-1 px-3 py-1.5 bg-gray-300 text-gray-700 rounded hover:bg-gray-400"
>
Cancel
</button>
</div>
</div>
);
}
if (showNotesDialog) {
return (
<div
ref={menuRef}
className="fixed bg-white rounded-lg shadow-xl border border-gray-200 p-4"
style={{ left: position.x, top: position.y, zIndex: 10000, minWidth: '350px', maxWidth: '400px' }}
>
<h3 className="font-semibold mb-2">Edit Notes</h3>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded mb-3"
placeholder="Enter notes"
rows={4}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Escape') { setShowNotesDialog(false); onClose(); }
}}
/>
{/* Image upload section */}
<div className="mb-3">
<label className="block text-sm font-medium text-gray-700 mb-2">
Attach Image (optional)
</label>
{imageData ? (
<div className="relative">
<img
src={imageData}
alt="Attached"
className="w-full rounded border border-gray-300 mb-2"
style={{ maxHeight: '200px', objectFit: 'contain' }}
/>
<button
onClick={handleRemoveImage}
className="absolute top-2 right-2 bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-700"
>
×
</button>
</div>
) : (
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm"
/>
)}
<p className="text-xs text-gray-500 mt-1">Max size: 2MB</p>
</div>
<div className="flex gap-2">
<button
onClick={handleSaveNotes}
className="flex-1 px-3 py-1.5 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Save
</button>
<button
onClick={() => { setShowNotesDialog(false); onClose(); }}
className="flex-1 px-3 py-1.5 bg-gray-300 text-gray-700 rounded hover:bg-gray-400"
>
Cancel
</button>
</div>
</div>
);
}
return (
<div
ref={menuRef}
className="fixed bg-white rounded-lg shadow-xl border border-gray-200 py-1"
style={{ left: position.x, top: position.y, zIndex: 10000, minWidth: '180px' }}
>
<button
onClick={() => setShowRenameDialog(true)}
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100"
>
Rename
</button>
<button
onClick={() => setShowNotesDialog(true)}
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100"
>
{item.properties.notes ? 'Edit Notes' : 'Add Notes'}
</button>
{isSwitch && (
<button
onClick={() => setShowPortConfigDialog(true)}
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100"
>
Configure Ports
</button>
)}
<div className="border-t border-gray-200 my-1"></div>
<button
onClick={() => setShowDeleteDialog(true)}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50"
>
Delete
</button>
</div>
);
}