411 lines
14 KiB
TypeScript
411 lines
14 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { X, ChevronLeft, ChevronRight, Printer, HardDrive, Network, Box, FolderOpen, MoreVertical, Play, Pause, SkipForward, SkipBack, RotateCcw } from 'lucide-react';
|
|
import { motion, AnimatePresence } from 'motion/react';
|
|
import FileBrowser from './FileBrowser';
|
|
|
|
interface Device {
|
|
id: string;
|
|
number: string;
|
|
type: 'printer' | 'drive' | 'network' | 'other' | 'meatloaf';
|
|
name?: string;
|
|
enabled: boolean | number;
|
|
url?: string;
|
|
mode?: number;
|
|
}
|
|
|
|
interface DeviceDetailOverlayProps {
|
|
device: Device;
|
|
config: any;
|
|
setConfig: (config: any) => void;
|
|
onClose: () => void;
|
|
onNavigate: (direction: 'prev' | 'next') => void;
|
|
hasPrev: boolean;
|
|
hasNext: boolean;
|
|
}
|
|
|
|
export default function DeviceDetailOverlay({
|
|
device,
|
|
config,
|
|
setConfig,
|
|
onClose,
|
|
onNavigate,
|
|
hasPrev,
|
|
hasNext
|
|
}: DeviceDetailOverlayProps) {
|
|
const [touchStart, setTouchStart] = useState(0);
|
|
const [touchEnd, setTouchEnd] = useState(0);
|
|
const [showFileBrowser, setShowFileBrowser] = useState(false);
|
|
const [showCommandMenu, setShowCommandMenu] = useState(false);
|
|
const [selectedMediaIndex, setSelectedMediaIndex] = useState(0);
|
|
|
|
const minSwipeDistance = 50;
|
|
|
|
const onTouchStart = (e: React.TouchEvent) => {
|
|
setTouchEnd(0);
|
|
setTouchStart(e.targetTouches[0].clientX);
|
|
};
|
|
|
|
const onTouchMove = (e: React.TouchEvent) => {
|
|
setTouchEnd(e.targetTouches[0].clientX);
|
|
};
|
|
|
|
const onTouchEnd = () => {
|
|
if (!touchStart || !touchEnd) return;
|
|
|
|
const distance = touchStart - touchEnd;
|
|
const isLeftSwipe = distance > minSwipeDistance;
|
|
const isRightSwipe = distance < -minSwipeDistance;
|
|
|
|
if (isLeftSwipe && hasNext) {
|
|
onNavigate('next');
|
|
}
|
|
if (isRightSwipe && hasPrev) {
|
|
onNavigate('prev');
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
onClose();
|
|
} else if (e.key === 'ArrowLeft' && hasPrev) {
|
|
onNavigate('prev');
|
|
} else if (e.key === 'ArrowRight' && hasNext) {
|
|
onNavigate('next');
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [hasPrev, hasNext, onClose, onNavigate]);
|
|
|
|
const updateDeviceSetting = (path: string[], value: any) => {
|
|
const newConfig = JSON.parse(JSON.stringify(config));
|
|
let current = newConfig;
|
|
|
|
for (let i = 0; i < path.length - 1; i++) {
|
|
current = current[path[i]];
|
|
}
|
|
|
|
current[path[path.length - 1]] = value;
|
|
setConfig(newConfig);
|
|
};
|
|
|
|
const getDevicePath = (): string[] => {
|
|
const [type, num] = device.id.split('-');
|
|
return ['iec', 'devices', type, num];
|
|
};
|
|
|
|
const getDeviceData = () => {
|
|
const path = getDevicePath();
|
|
let current = config;
|
|
for (const key of path) {
|
|
current = current?.[key];
|
|
}
|
|
return current || {};
|
|
};
|
|
|
|
const deviceData = getDeviceData();
|
|
|
|
const getDeviceIcon = (type: Device['type']) => {
|
|
switch (type) {
|
|
case 'printer':
|
|
return <Printer className="w-6 h-6" />;
|
|
case 'drive':
|
|
return <HardDrive className="w-6 h-6" />;
|
|
case 'network':
|
|
return <Network className="w-6 h-6" />;
|
|
default:
|
|
return <Box className="w-6 h-6" />;
|
|
}
|
|
};
|
|
|
|
// Detect if URL is part of a media set (e.g., disk1.d64, disk2.d64)
|
|
const detectMediaSet = () => {
|
|
if (!deviceData.url) return null;
|
|
|
|
const match = deviceData.url.match(/^(.+?)(\d+)(\.[^.]+)$/);
|
|
if (!match) return null;
|
|
|
|
const [, prefix, num, ext] = match;
|
|
const currentNum = parseInt(num);
|
|
|
|
// Generate potential media set
|
|
const mediaSet = [];
|
|
for (let i = 1; i <= 10; i++) {
|
|
mediaSet.push(`${prefix}${i}${ext}`);
|
|
}
|
|
|
|
return {
|
|
prefix,
|
|
extension: ext,
|
|
currentIndex: currentNum - 1,
|
|
files: mediaSet
|
|
};
|
|
};
|
|
|
|
const mediaSet = detectMediaSet();
|
|
|
|
const switchMedia = (index: number) => {
|
|
if (!mediaSet) return;
|
|
const path = getDevicePath();
|
|
updateDeviceSetting([...path, 'url'], mediaSet.files[index]);
|
|
setSelectedMediaIndex(index);
|
|
};
|
|
|
|
const sendCommand = (command: string) => {
|
|
console.log(`Sending command to device ${device.number}: ${command}`);
|
|
// In a real app, this would send the command to the device
|
|
setShowCommandMenu(false);
|
|
};
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 bg-black/50 z-50"
|
|
onClick={onClose}
|
|
>
|
|
<motion.div
|
|
initial={{ y: '100%' }}
|
|
animate={{ y: 0 }}
|
|
exit={{ y: '100%' }}
|
|
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
|
className="absolute inset-0 bg-white overflow-y-auto"
|
|
onClick={(e) => e.stopPropagation()}
|
|
onTouchStart={onTouchStart}
|
|
onTouchMove={onTouchMove}
|
|
onTouchEnd={onTouchEnd}
|
|
>
|
|
<div className="sticky top-0 bg-white border-b border-neutral-200 z-10">
|
|
<div className="flex items-center justify-between p-4">
|
|
<button onClick={onClose} className="p-2 -m-2">
|
|
<X className="w-6 h-6" />
|
|
</button>
|
|
<div className="flex items-center gap-2">
|
|
<div className={deviceData.enabled ? 'text-blue-600' : 'text-neutral-400'}>
|
|
{getDeviceIcon(device.type)}
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="text-xs text-neutral-500 capitalize">{device.type}</div>
|
|
<div className="font-medium">#{device.number}</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowCommandMenu(!showCommandMenu)}
|
|
className="p-2 -m-2 relative"
|
|
>
|
|
<MoreVertical className="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
|
|
{showCommandMenu && (
|
|
<div className="absolute right-4 top-16 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[200px] z-20">
|
|
<button
|
|
onClick={() => sendCommand('RESET')}
|
|
className="w-full px-4 py-2 text-left hover:bg-neutral-50 flex items-center gap-2"
|
|
>
|
|
<RotateCcw className="w-4 h-4" />
|
|
Reset Device
|
|
</button>
|
|
<button
|
|
onClick={() => sendCommand('EJECT')}
|
|
className="w-full px-4 py-2 text-left hover:bg-neutral-50 flex items-center gap-2"
|
|
>
|
|
<SkipBack className="w-4 h-4" />
|
|
Eject Media
|
|
</button>
|
|
<button
|
|
onClick={() => sendCommand('MOUNT')}
|
|
className="w-full px-4 py-2 text-left hover:bg-neutral-50 flex items-center gap-2"
|
|
>
|
|
<Play className="w-4 h-4" />
|
|
Mount Media
|
|
</button>
|
|
<button
|
|
onClick={() => sendCommand('STATUS')}
|
|
className="w-full px-4 py-2 text-left hover:bg-neutral-50"
|
|
>
|
|
Get Status
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between px-4 pb-4">
|
|
<button
|
|
onClick={() => onNavigate('prev')}
|
|
disabled={!hasPrev}
|
|
className={`p-2 rounded-lg ${
|
|
hasPrev
|
|
? 'text-blue-600 bg-blue-50'
|
|
: 'text-neutral-300 bg-neutral-50'
|
|
}`}
|
|
>
|
|
<ChevronLeft className="w-5 h-5" />
|
|
</button>
|
|
<span className="text-sm text-neutral-500">Swipe to navigate</span>
|
|
<button
|
|
onClick={() => onNavigate('next')}
|
|
disabled={!hasNext}
|
|
className={`p-2 rounded-lg ${
|
|
hasNext
|
|
? 'text-blue-600 bg-blue-50'
|
|
: 'text-neutral-300 bg-neutral-50'
|
|
}`}
|
|
>
|
|
<ChevronRight className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-4 space-y-6">
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="text-sm text-neutral-500 block mb-2">Device Name</label>
|
|
<input
|
|
type="text"
|
|
value={deviceData.name || device.name || `Device ${device.number}`}
|
|
onChange={(e) => {
|
|
const path = getDevicePath();
|
|
updateDeviceSetting([...path, 'name'], e.target.value);
|
|
}}
|
|
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-sm text-neutral-500 block mb-2">Type</label>
|
|
<div className="px-3 py-2 bg-neutral-50 border border-neutral-200 rounded-lg text-neutral-700">
|
|
{device.type.charAt(0).toUpperCase() + device.type.slice(1)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-sm text-neutral-500">Enabled</label>
|
|
<button
|
|
onClick={() => {
|
|
const path = getDevicePath();
|
|
updateDeviceSetting([...path, 'enabled'], deviceData.enabled ? 0 : 1);
|
|
}}
|
|
className={`relative w-12 h-6 rounded-full transition-colors ${
|
|
deviceData.enabled ? 'bg-blue-600' : 'bg-neutral-300'
|
|
}`}
|
|
>
|
|
<div
|
|
className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${
|
|
deviceData.enabled ? 'translate-x-6' : 'translate-x-0.5'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
{deviceData.url !== undefined && (
|
|
<div>
|
|
<label className="text-sm text-neutral-500 block mb-2">URL</label>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={deviceData.url}
|
|
onChange={(e) => {
|
|
const path = getDevicePath();
|
|
updateDeviceSetting([...path, 'url'], e.target.value);
|
|
}}
|
|
className="flex-1 px-3 py-2 border border-neutral-300 rounded-lg"
|
|
/>
|
|
<button
|
|
onClick={() => setShowFileBrowser(true)}
|
|
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
|
|
>
|
|
<FolderOpen className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{mediaSet && (
|
|
<div className="mt-3">
|
|
<label className="text-sm text-neutral-500 block mb-2">Media Set</label>
|
|
<div className="flex gap-2 flex-wrap">
|
|
{mediaSet.files.slice(0, 5).map((file, index) => (
|
|
<button
|
|
key={index}
|
|
onClick={() => switchMedia(index)}
|
|
className={`px-3 py-1.5 rounded-lg text-sm ${
|
|
deviceData.url === file
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
|
}`}
|
|
>
|
|
Disk {index + 1}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{deviceData.mode !== undefined && (
|
|
<div>
|
|
<label className="text-sm text-neutral-500 block mb-2">Mode</label>
|
|
<input
|
|
type="number"
|
|
value={deviceData.mode}
|
|
onChange={(e) => {
|
|
const path = getDevicePath();
|
|
updateDeviceSetting([...path, 'mode'], parseInt(e.target.value));
|
|
}}
|
|
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{deviceData.type && (
|
|
<div>
|
|
<label className="text-sm text-neutral-500 block mb-2">Device Type</label>
|
|
<div className="px-3 py-2 bg-neutral-50 border border-neutral-200 rounded-lg text-neutral-700">
|
|
{deviceData.type}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{deviceData.baud !== undefined && (
|
|
<div>
|
|
<label className="text-sm text-neutral-500 block mb-2">Baud Rate</label>
|
|
<input
|
|
type="number"
|
|
value={deviceData.baud}
|
|
onChange={(e) => {
|
|
const path = getDevicePath();
|
|
updateDeviceSetting([...path, 'baud'], parseInt(e.target.value));
|
|
}}
|
|
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="pt-4 border-t border-neutral-200">
|
|
<h3 className="text-sm text-neutral-500 mb-2">Device ID</h3>
|
|
<code className="text-xs text-neutral-600 bg-neutral-50 px-2 py-1 rounded">
|
|
{device.id}
|
|
</code>
|
|
</div>
|
|
</div>
|
|
|
|
{showFileBrowser && (
|
|
<FileBrowser
|
|
currentPath={deviceData.url || '/'}
|
|
onSelect={(path) => {
|
|
const devicePath = getDevicePath();
|
|
updateDeviceSetting([...devicePath, 'url'], path);
|
|
}}
|
|
onClose={() => setShowFileBrowser(false)}
|
|
/>
|
|
)}
|
|
</motion.div>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
);
|
|
}
|