437 lines
17 KiB
TypeScript
437 lines
17 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { Printer, HardDrive, Network, Box, ChevronRight, RefreshCw } from 'lucide-react';
|
|
import DeviceDetailOverlay from './DeviceDetailOverlay';
|
|
import { toast } from 'sonner';
|
|
import { useWs } from '../ws';
|
|
|
|
interface Device {
|
|
id: string;
|
|
number: string;
|
|
type: 'printer' | 'drive' | 'network' | 'other' | 'meatloaf';
|
|
name?: string;
|
|
enabled: boolean | number;
|
|
base_url?: string;
|
|
url?: string;
|
|
mode?: number;
|
|
}
|
|
|
|
interface DevicesPageProps {
|
|
config: any;
|
|
setConfig: (config: any) => void;
|
|
openDeviceId?: string | null;
|
|
onClearOpenDevice?: () => void;
|
|
}
|
|
|
|
export default function DevicesPage({ config, setConfig, openDeviceId, onClearOpenDevice }: DevicesPageProps) {
|
|
// Host Settings update function
|
|
const updateSetting = (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 [selectedDeviceIndex, setSelectedDeviceIndex] = useState<number | null>(null);
|
|
const [isScanning, setIsScanning] = useState(false);
|
|
|
|
const hardware = config.hardware || {};
|
|
const modem = config.modem || {};
|
|
const cassette = config.cassette || {};
|
|
const boip = config.boip || {};
|
|
|
|
const devices: Device[] = [];
|
|
|
|
// Printer devices
|
|
if (config.iec?.devices?.printer) {
|
|
Object.entries(config.iec.devices.printer).forEach(([num, device]: [string, any]) => {
|
|
devices.push({
|
|
id: `printer-${num}`,
|
|
number: num,
|
|
type: 'printer',
|
|
name: device.name,
|
|
enabled: device.enabled
|
|
});
|
|
});
|
|
}
|
|
|
|
// Drive devices
|
|
if (config.iec?.devices?.drive) {
|
|
Object.entries(config.iec.devices.drive).forEach(([key, value]: [string, any]) => {
|
|
if (key !== 'vdrive' && key !== 'rom') {
|
|
devices.push({
|
|
id: `drive-${key}`,
|
|
number: key,
|
|
type: 'drive',
|
|
name: `Drive ${key}`,
|
|
enabled: value.enabled,
|
|
base_url: value.base_url,
|
|
url: value.url,
|
|
mode: value.mode
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Network devices
|
|
if (config.iec?.devices?.network) {
|
|
Object.entries(config.iec.devices.network).forEach(([num, device]: [string, any]) => {
|
|
devices.push({
|
|
id: `network-${num}`,
|
|
number: num,
|
|
type: 'network',
|
|
name: `Network ${num}`,
|
|
enabled: device.enabled,
|
|
base_url: device.base_url,
|
|
url: device.url
|
|
});
|
|
});
|
|
}
|
|
|
|
// Other devices
|
|
if (config.iec?.devices?.other) {
|
|
Object.entries(config.iec.devices.other).forEach(([num, device]: [string, any]) => {
|
|
devices.push({
|
|
id: `other-${num}`,
|
|
number: num,
|
|
type: 'other',
|
|
name: device.name,
|
|
enabled: device.enabled
|
|
});
|
|
});
|
|
}
|
|
|
|
// Meatloaf devices
|
|
if (config.iec?.devices?.meatloaf) {
|
|
Object.entries(config.iec.devices.meatloaf).forEach(([num, device]: [string, any]) => {
|
|
devices.push({
|
|
id: `meatloaf-${num}`,
|
|
number: num,
|
|
type: 'meatloaf',
|
|
name: `Meatloaf ${num}`,
|
|
enabled: device.enabled,
|
|
base_url: device.base_url,
|
|
url: device.url,
|
|
mode: device.mode
|
|
});
|
|
});
|
|
}
|
|
|
|
// Auto-open the overlay when the parent passes a device ID (e.g. from a toast action)
|
|
useEffect(() => {
|
|
if (!openDeviceId) return;
|
|
const idx = devices.findIndex(d => d.id === openDeviceId);
|
|
if (idx >= 0) setSelectedDeviceIndex(idx);
|
|
onClearOpenDevice?.();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [openDeviceId]);
|
|
|
|
const getDeviceIcon = (type: Device['type']) => {
|
|
switch (type) {
|
|
case 'printer':
|
|
return <Printer className="w-5 h-5" />;
|
|
case 'drive':
|
|
return <HardDrive className="w-5 h-5" />;
|
|
case 'network':
|
|
return <Network className="w-5 h-5" />;
|
|
default:
|
|
return <Box className="w-5 h-5" />;
|
|
}
|
|
};
|
|
|
|
const handleDeviceClick = (index: number) => {
|
|
setSelectedDeviceIndex(index);
|
|
};
|
|
|
|
const handleCloseOverlay = () => {
|
|
setSelectedDeviceIndex(null);
|
|
};
|
|
|
|
const handleNavigate = (direction: 'prev' | 'next') => {
|
|
if (selectedDeviceIndex === null) return;
|
|
|
|
if (direction === 'prev' && selectedDeviceIndex > 0) {
|
|
setSelectedDeviceIndex(selectedDeviceIndex - 1);
|
|
} else if (direction === 'next' && selectedDeviceIndex < devices.length - 1) {
|
|
setSelectedDeviceIndex(selectedDeviceIndex + 1);
|
|
}
|
|
};
|
|
|
|
const { send: wsSend } = useWs();
|
|
|
|
const rescanBus = async () => {
|
|
setIsScanning(true);
|
|
wsSend('iec scan');
|
|
toast.loading('Scanning IEC bus...');
|
|
|
|
// Simulate bus scan
|
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
|
|
setIsScanning(false);
|
|
toast.dismiss();
|
|
toast.success(`Found ${devices.length} devices on the bus`);
|
|
};
|
|
|
|
const toggleDeviceEnabled = (device: Device, e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
|
|
const [type, num] = device.id.split('-');
|
|
const path = ['iec', 'devices', type, num, 'enabled'];
|
|
|
|
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]] = device.enabled ? 0 : 1;
|
|
setConfig(newConfig);
|
|
|
|
toast.success(`Device #${device.number} ${device.enabled ? 'disabled' : 'enabled'}`);
|
|
};
|
|
|
|
return (
|
|
<div className="p-4">
|
|
{/* Host Settings section moved from GeneralPage */}
|
|
<h2 className="text-sm text-neutral-500 mb-2">Host Settings</h2>
|
|
<div className="bg-white border border-neutral-200 rounded-lg divide-y divide-neutral-200 mb-6">
|
|
<div className="p-4">
|
|
<label className="text-sm text-neutral-500 block mb-2">Model</label>
|
|
<select
|
|
value={config.host?.model?.split('|')[0] || 'c64'}
|
|
onChange={(e) => updateSetting(['host', 'model'], e.target.value)}
|
|
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
|
>
|
|
<option value="c64">C64</option>
|
|
<option value="c64c">C64C</option>
|
|
<option value="c128">C128</option>
|
|
<option value="sx64">SX64</option>
|
|
<option value="plus4">Plus/4</option>
|
|
<option value="c16">C16</option>
|
|
<option value="cx16">CX16</option>
|
|
<option value="foenix">Foenix</option>
|
|
<option value="dtv">DTV</option>
|
|
<option value="pet">PET</option>
|
|
</select>
|
|
</div>
|
|
<div className="p-4">
|
|
<label className="text-sm text-neutral-500 block mb-2">Video</label>
|
|
<select
|
|
value={config.host?.video?.split('|')[0] || 'ntsc'}
|
|
onChange={(e) => updateSetting(['host', 'video'], e.target.value)}
|
|
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
|
>
|
|
<option value="ntsc">NTSC</option>
|
|
<option value="pal">PAL</option>
|
|
</select>
|
|
</div>
|
|
<div className="p-4">
|
|
<label className="text-sm text-neutral-500 block mb-2">Kernal</label>
|
|
<select
|
|
value={config.host?.kernal?.split('|')[0] || 'stock'}
|
|
onChange={(e) => updateSetting(['host', 'kernal'], e.target.value)}
|
|
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
|
>
|
|
<option value="stock">Stock</option>
|
|
<option value="jiffydos">JiffyDOS</option>
|
|
<option value="dolphindos">DolphinDOS</option>
|
|
<option value="speeddos">SpeedDOS</option>
|
|
</select>
|
|
</div>
|
|
<div className="p-4">
|
|
<label className="text-sm text-neutral-500 block mb-2">BASIC</label>
|
|
<select
|
|
value={config.host?.basic?.split('|')[0] || '2'}
|
|
onChange={(e) => updateSetting(['host', 'basic'], e.target.value)}
|
|
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
|
>
|
|
<option value="2">BASIC 2</option>
|
|
<option value="3">BASIC 3</option>
|
|
<option value="7">BASIC 7</option>
|
|
<option value="10">BASIC 10</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h2 className="text-sm text-neutral-500">IEC Devices</h2>
|
|
<button
|
|
onClick={rescanBus}
|
|
disabled={isScanning}
|
|
className="flex items-center gap-2 px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg disabled:opacity-50"
|
|
>
|
|
<RefreshCw className={`w-4 h-4 ${isScanning ? 'animate-spin' : ''}`} />
|
|
Rescan Bus
|
|
</button>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{devices.map((device, index) => (
|
|
<div
|
|
key={device.id}
|
|
className="w-full bg-white border border-neutral-200 rounded-lg p-4 flex items-center gap-3"
|
|
>
|
|
<div className={`${device.enabled ? 'text-blue-600' : 'text-neutral-400'}`}>
|
|
{getDeviceIcon(device.type)}
|
|
</div>
|
|
<button
|
|
onClick={() => handleDeviceClick(index)}
|
|
className="flex-1 min-w-0 text-left"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className={device.enabled ? 'text-neutral-900' : 'text-neutral-400'}>
|
|
{device.name || `Device ${device.number}`}
|
|
</span>
|
|
<span className="text-xs text-neutral-500 px-2 py-0.5 bg-neutral-100 rounded">
|
|
#{device.number}
|
|
</span>
|
|
</div>
|
|
{(device.base_url || device.url) && (
|
|
<div className="text-sm text-neutral-500 truncate mt-0.5">
|
|
{[device.base_url, device.url].filter(Boolean).join('')}
|
|
</div>
|
|
)}
|
|
</button>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={(e) => toggleDeviceEnabled(device, e)}
|
|
className={`relative w-11 h-6 rounded-full transition-colors ${
|
|
device.enabled ? 'bg-blue-600' : 'bg-neutral-300'
|
|
}`}
|
|
>
|
|
<div
|
|
className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${
|
|
device.enabled ? 'translate-x-5' : 'translate-x-0.5'
|
|
}`}
|
|
/>
|
|
</button>
|
|
<button onClick={() => handleDeviceClick(index)}>
|
|
<ChevronRight className="w-4 h-4 text-neutral-400" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{selectedDeviceIndex !== null && (
|
|
<DeviceDetailOverlay
|
|
device={devices[selectedDeviceIndex]}
|
|
config={config}
|
|
setConfig={setConfig}
|
|
onClose={handleCloseOverlay}
|
|
onNavigate={handleNavigate}
|
|
hasPrev={selectedDeviceIndex > 0}
|
|
hasNext={selectedDeviceIndex < devices.length - 1}
|
|
/>
|
|
)}
|
|
|
|
|
|
{/* ── Cassette ── */}
|
|
<h2 className="text-sm text-neutral-500 pt-4">Cassette</h2>
|
|
<div className="bg-white border border-neutral-200 rounded-lg divide-y divide-neutral-200">
|
|
<div className="p-4 flex items-center justify-between">
|
|
<label className="text-sm text-neutral-500">Enabled</label>
|
|
<button
|
|
onClick={() => updateSetting(['cassette', 'enabled'], cassette.enabled ? 0 : 1)}
|
|
className={`relative w-12 h-6 rounded-full transition-colors ${cassette.enabled ? 'bg-blue-600' : 'bg-neutral-300'}`}
|
|
>
|
|
<div className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${cassette.enabled ? 'translate-x-6' : 'translate-x-0.5'}`} />
|
|
</button>
|
|
</div>
|
|
<div className="p-4">
|
|
<label className="text-sm text-neutral-500 block mb-2">Play/Record</label>
|
|
<input type="text" value={cassette.play_record || ''} onChange={(e) => updateSetting(['cassette', 'play_record'], e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg" />
|
|
</div>
|
|
<div className="p-4">
|
|
<label className="text-sm text-neutral-500 block mb-2">Pulldown</label>
|
|
<input type="text" value={cassette.pulldown || ''} onChange={(e) => updateSetting(['cassette', 'pulldown'], e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg" />
|
|
</div>
|
|
<div className="p-4">
|
|
<label className="text-sm text-neutral-500 block mb-2">URL</label>
|
|
<input type="text" value={cassette.url || ''} onChange={(e) => updateSetting(['cassette', 'url'], e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Hardware ── */}
|
|
<h2 className="text-sm text-neutral-500 pt-4">Hardware</h2>
|
|
<div className="bg-white border border-neutral-200 rounded-lg divide-y divide-neutral-200">
|
|
<div className="p-4 flex items-center justify-between">
|
|
<label className="text-sm text-neutral-500">PS/2</label>
|
|
<button
|
|
onClick={() => updateSetting(['hardware', 'ps2'], hardware.ps2 ? 0 : 1)}
|
|
className={`relative w-12 h-6 rounded-full transition-colors ${hardware.ps2 ? 'bg-blue-600' : 'bg-neutral-300'}`}
|
|
>
|
|
<div className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${hardware.ps2 ? 'translate-x-6' : 'translate-x-0.5'}`} />
|
|
</button>
|
|
</div>
|
|
<div className="p-4 flex items-center justify-between">
|
|
<label className="text-sm text-neutral-500">User Port Enabled</label>
|
|
<button
|
|
onClick={() => updateSetting(['hardware', 'userport', 'enabled'], hardware.userport?.enabled ? 0 : 1)}
|
|
className={`relative w-12 h-6 rounded-full transition-colors ${hardware.userport?.enabled ? 'bg-blue-600' : 'bg-neutral-300'}`}
|
|
>
|
|
<div className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${hardware.userport?.enabled ? 'translate-x-6' : 'translate-x-0.5'}`} />
|
|
</button>
|
|
</div>
|
|
<div className="p-4">
|
|
<label className="text-sm text-neutral-500 block mb-2">User Port Mode</label>
|
|
<select
|
|
value={hardware.userport?.mode?.split('|')[0] || 'serial'}
|
|
onChange={(e) => updateSetting(['hardware', 'userport', 'mode'], e.target.value)}
|
|
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
|
>
|
|
<option value="serial">Serial</option>
|
|
<option value="parallel">Parallel</option>
|
|
<option value="IEEE-488">IEEE-488</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Modem ── */}
|
|
<h2 className="text-sm text-neutral-500 pt-4">Modem</h2>
|
|
<div className="bg-white border border-neutral-200 rounded-lg divide-y divide-neutral-200">
|
|
<div className="p-4 flex items-center justify-between">
|
|
<label className="text-sm text-neutral-500">Modem Enabled</label>
|
|
<button
|
|
onClick={() => updateSetting(['modem', 'modem_enabled'], modem.modem_enabled ? 0 : 1)}
|
|
className={`relative w-12 h-6 rounded-full transition-colors ${modem.modem_enabled ? 'bg-blue-600' : 'bg-neutral-300'}`}
|
|
>
|
|
<div className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${modem.modem_enabled ? 'translate-x-6' : 'translate-x-0.5'}`} />
|
|
</button>
|
|
</div>
|
|
<div className="p-4 flex items-center justify-between">
|
|
<label className="text-sm text-neutral-500">Sniffer Enabled</label>
|
|
<button
|
|
onClick={() => updateSetting(['modem', 'sniffer_enabled'], modem.sniffer_enabled ? 0 : 1)}
|
|
className={`relative w-12 h-6 rounded-full transition-colors ${modem.sniffer_enabled ? 'bg-blue-600' : 'bg-neutral-300'}`}
|
|
>
|
|
<div className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${modem.sniffer_enabled ? 'translate-x-6' : 'translate-x-0.5'}`} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── BOIP ── */}
|
|
<h2 className="text-sm text-neutral-500 pt-4">BOIP</h2>
|
|
<div className="bg-white border border-neutral-200 rounded-lg divide-y divide-neutral-200">
|
|
<div className="p-4 flex items-center justify-between">
|
|
<label className="text-sm text-neutral-500">Enabled</label>
|
|
<button
|
|
onClick={() => updateSetting(['boip', 'enabled'], boip.enabled ? 0 : 1)}
|
|
className={`relative w-12 h-6 rounded-full transition-colors ${boip.enabled ? 'bg-blue-600' : 'bg-neutral-300'}`}
|
|
>
|
|
<div className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${boip.enabled ? 'translate-x-6' : 'translate-x-0.5'}`} />
|
|
</button>
|
|
</div>
|
|
<div className="p-4">
|
|
<label className="text-sm text-neutral-500 block mb-2">Host</label>
|
|
<input type="text" value={boip.host || ''} onChange={(e) => updateSetting(['boip', 'host'], e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg" />
|
|
</div>
|
|
<div className="p-4">
|
|
<label className="text-sm text-neutral-500 block mb-2">Port</label>
|
|
<input type="text" value={boip.port || ''} onChange={(e) => updateSetting(['boip', 'port'], e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|