meatloaf-config/src/app/components/DevicesPage.tsx

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>
);
}