Compare commits

..

4 Commits

3 changed files with 194 additions and 136 deletions

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { X, ChevronLeft, ChevronRight, Printer, HardDrive, Network, Box, FolderOpen, MoreVertical, Play, Pause, SkipForward, SkipBack, RotateCcw } from 'lucide-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 { motion, AnimatePresence } from 'motion/react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -14,6 +14,8 @@ interface Device {
enabled: boolean | number; enabled: boolean | number;
url?: string; url?: string;
mode?: number; mode?: number;
physical?: boolean;
physicalModel?: string;
} }
interface DeviceDetailOverlayProps { interface DeviceDetailOverlayProps {
@ -70,10 +72,11 @@ export default function DeviceDetailOverlay({
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
onClose(); onClose();
} else if (e.key === 'ArrowLeft' && hasPrev) { } else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
onNavigate('prev'); const tag = (document.activeElement as HTMLElement)?.tagName;
} else if (e.key === 'ArrowRight' && hasNext) { if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
onNavigate('next'); if (e.key === 'ArrowLeft' && hasPrev) onNavigate('prev');
if (e.key === 'ArrowRight' && hasNext) onNavigate('next');
} }
}; };
@ -123,13 +126,19 @@ export default function DeviceDetailOverlay({
// Prefer an explicit .lst-derived mediaSet stored in config; fall back to pattern detection. // Prefer an explicit .lst-derived mediaSet stored in config; fall back to pattern detection.
const [mediaSetFiles, setMediaSetFiles] = useState<MediaSetEntry[] | null>(null); const [mediaSetFiles, setMediaSetFiles] = useState<MediaSetEntry[] | null>(null);
const prevDeviceNumberRef = useRef(device.number);
useEffect(() => { useEffect(() => {
if (Array.isArray(deviceData.media_set) && deviceData.media_set.length > 0) { const deviceChanged = prevDeviceNumberRef.current !== device.number;
prevDeviceNumberRef.current = device.number;
if (Array.isArray(deviceData.media_set) && deviceData.media_set.length > 1) {
setMediaSetFiles(deviceData.media_set as MediaSetEntry[]); setMediaSetFiles(deviceData.media_set as MediaSetEntry[]);
return; return;
} }
if (!deviceData.url) { setMediaSetFiles(null); return; } // Don't run pattern detection when switching devices — only when the URL
// is actively being changed within the same device.
if (deviceChanged || !deviceData.url) { setMediaSetFiles(null); return; }
const match = (deviceData.url as string).match(/^(.+?)(\d+)(\.[^.]+)$/); const match = (deviceData.url as string).match(/^(.+?)(\d+)(\.[^.]+)$/);
if (!match) { setMediaSetFiles(null); return; } if (!match) { setMediaSetFiles(null); return; }
const [, prefix, , ext] = match; const [, prefix, , ext] = match;
@ -137,10 +146,11 @@ export default function DeviceDetailOverlay({
for (let i = 1; i <= 10; i++) candidates.push(`${prefix}${i}${ext}`); for (let i = 1; i <= 10; i++) candidates.push(`${prefix}${i}${ext}`);
let cancelled = false; let cancelled = false;
Promise.all(candidates.map(f => fileExists(f).catch(() => false))).then(flags => { Promise.all(candidates.map(f => fileExists(f).catch(() => false))).then(flags => {
if (!cancelled) setMediaSetFiles(candidates.filter((_, i) => flags[i])); const found = candidates.filter((_, i) => flags[i]);
if (!cancelled) setMediaSetFiles(found.length > 1 ? found : null);
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [deviceData.url, deviceData.media_set]); }, [device.number, deviceData.url, deviceData.media_set]);
const switchMedia = (file: string) => { const switchMedia = (file: string) => {
const path = getDevicePath(); const path = getDevicePath();
@ -185,6 +195,7 @@ export default function DeviceDetailOverlay({
const existsArr = await Promise.all(candidates.map(e => fileExists(mediaSetEntryUrl(e)).catch(() => false))); const existsArr = await Promise.all(candidates.map(e => fileExists(mediaSetEntryUrl(e)).catch(() => false)));
const files = candidates.filter((_, i) => existsArr[i]); const files = candidates.filter((_, i) => existsArr[i]);
if (files.length === 0) { toast.error('No files in swap list exist on device'); return; } if (files.length === 0) { toast.error('No files in swap list exist on device'); return; }
if (files.length < 2) { toast.error('A media set needs more than one item'); return; }
if (files.length < candidates.length) { if (files.length < candidates.length) {
toast.warning(`${candidates.length - files.length} missing file(s) skipped from swap list`); toast.warning(`${candidates.length - files.length} missing file(s) skipped from swap list`);
} }
@ -240,9 +251,16 @@ export default function DeviceDetailOverlay({
<button onClick={onClose} className="p-2 -m-2"> <button onClick={onClose} className="p-2 -m-2">
<X className="w-6 h-6" /> <X className="w-6 h-6" />
</button> </button>
<div className={`flex flex-col items-center gap-0.5 ${deviceData.enabled ? 'text-blue-600' : 'text-neutral-400'}`}> <div className={`flex flex-col items-center gap-0.5 ${
device.physical ? 'text-green-600' : deviceData.enabled ? 'text-blue-600' : 'text-neutral-400'
}`}>
<span className="text-sm font-semibold leading-none">{device.number}</span> <span className="text-sm font-semibold leading-none">{device.number}</span>
{getDeviceIcon(device.type)} {getDeviceIcon(device.type)}
{device.physical && (
<span className="text-xs text-green-700 px-1 py-0.5 bg-green-50 border border-green-200 rounded leading-none">
Physical
</span>
)}
</div> </div>
<button <button
onClick={() => setShowCommandMenu(!showCommandMenu)} onClick={() => setShowCommandMenu(!showCommandMenu)}
@ -293,24 +311,26 @@ export default function DeviceDetailOverlay({
<div className="p-4 space-y-6"> <div className="p-4 space-y-6">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> {!device.physical && (
<label className="text-sm text-neutral-500">Enabled</label> <div className="flex items-center justify-between">
<button <label className="text-sm text-neutral-500">Enabled</label>
onClick={() => { <button
const path = getDevicePath(); onClick={() => {
updateDeviceSetting([...path, 'enabled'], deviceData.enabled ? 0 : 1); 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' 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
</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>
)}
<div> <div>
<label className="text-sm text-neutral-500 block mb-2">Type</label> <label className="text-sm text-neutral-500 block mb-2">Type</label>
@ -332,124 +352,148 @@ export default function DeviceDetailOverlay({
/> />
</div> </div>
<div> {!device.physical && <>
<label className="text-sm text-neutral-500 block mb-2">Base URL</label>
<div className="flex gap-2">
<input
type="text"
value={deviceData.base_url ?? ''}
onChange={(e) => {
const path = getDevicePath();
updateDeviceSetting([...path, 'base_url'], e.target.value);
}}
className="flex-1 px-3 py-2 border border-neutral-300 rounded-lg"
/>
<button
onClick={() => setBrowsingField('base_url')}
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
title="Browse"
>
<FolderOpen className="w-5 h-5" />
</button>
<button
onClick={() => {
const path = getDevicePath();
updateDeviceSetting([...path, 'base_url'], '');
}}
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-red-50 hover:border-red-300 hover:text-red-500"
title="Clear Base URL"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
<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 newUrl = e.target.value;
const devicePath = getDevicePath();
const newConfig = JSON.parse(JSON.stringify(config));
let dev = newConfig;
for (const k of devicePath) dev = dev[k];
if (isOutsideBase(newUrl, dev.base_url || '')) clearBaseAndCache(dev);
dev.url = newUrl;
setConfig(newConfig);
}}
className="flex-1 px-3 py-2 border border-neutral-300 rounded-lg"
/>
<button
onClick={() => setBrowsingField('url')}
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
title="Browse"
>
<FolderOpen className="w-5 h-5" />
</button>
<button
onClick={() => {
const devicePath = getDevicePath();
const newConfig = JSON.parse(JSON.stringify(config));
let dev = newConfig;
for (const k of devicePath) dev = dev[k];
delete dev.url;
delete dev.media_set;
setConfig(newConfig);
}}
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-red-50 hover:border-red-300 hover:text-red-500"
title="Clear URL and media set"
>
<X className="w-5 h-5" />
</button>
</div>
{mediaSetFiles && (
<div className="mt-3">
<MediaSet files={mediaSetFiles} activeUrl={deviceData.url ?? ''} onSwitch={switchMedia} />
</div>
)}
</div>
{(deviceData.cache !== undefined ||
(deviceData.base_url ?? '').includes('://') ||
(deviceData.url ?? '').includes('://')) && (
<div> <div>
<label className="text-sm text-neutral-500 block mb-2">Cache</label> <label className="text-sm text-neutral-500 block mb-2">Base URL</label>
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
type="text" type="text"
value={deviceData.cache ?? ''} value={deviceData.base_url ?? ''}
onChange={(e) => { onChange={(e) => {
const path = getDevicePath(); const path = getDevicePath();
updateDeviceSetting([...path, 'cache'], e.target.value); updateDeviceSetting([...path, 'base_url'], e.target.value);
}} }}
className="flex-1 px-3 py-2 border border-neutral-300 rounded-lg" className="flex-1 px-3 py-2 border border-neutral-300 rounded-lg"
/> />
<button <button
onClick={() => setBrowsingField('cache')} onClick={() => setBrowsingField('base_url')}
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100" className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
title="Browse"
> >
<FolderOpen className="w-5 h-5" /> <FolderOpen className="w-5 h-5" />
</button> </button>
<button
onClick={() => {
const path = getDevicePath();
updateDeviceSetting([...path, 'base_url'], '');
}}
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-red-50 hover:border-red-300 hover:text-red-500"
title="Clear Base URL"
>
<X className="w-5 h-5" />
</button>
</div> </div>
</div> </div>
)}
<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 newUrl = e.target.value;
const devicePath = getDevicePath();
const newConfig = JSON.parse(JSON.stringify(config));
let dev = newConfig;
for (const k of devicePath) dev = dev[k];
if (isOutsideBase(newUrl, dev.base_url || '')) clearBaseAndCache(dev);
dev.url = newUrl;
setConfig(newConfig);
}}
className="flex-1 px-3 py-2 border border-neutral-300 rounded-lg"
/>
<button
onClick={() => setBrowsingField('url')}
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
title="Browse"
>
<FolderOpen className="w-5 h-5" />
</button>
<button
onClick={() => {
const devicePath = getDevicePath();
const newConfig = JSON.parse(JSON.stringify(config));
let dev = newConfig;
for (const k of devicePath) dev = dev[k];
delete dev.url;
delete dev.media_set;
setConfig(newConfig);
}}
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-red-50 hover:border-red-300 hover:text-red-500"
title="Clear URL and media set"
>
<X className="w-5 h-5" />
</button>
</div>
{mediaSetFiles && (
<div className="mt-3">
<MediaSet files={mediaSetFiles} activeUrl={deviceData.url ?? ''} onSwitch={switchMedia} />
</div>
)}
</div>
{(deviceData.cache !== undefined ||
(deviceData.base_url ?? '').includes('://') ||
(deviceData.url ?? '').includes('://')) && (
<div>
<label className="text-sm text-neutral-500 block mb-2">Cache</label>
<div className="flex gap-2">
<input
type="text"
value={deviceData.cache ?? ''}
onChange={(e) => {
const path = getDevicePath();
updateDeviceSetting([...path, 'cache'], e.target.value);
}}
className="flex-1 px-3 py-2 border border-neutral-300 rounded-lg"
/>
<button
onClick={() => setBrowsingField('cache')}
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
title="Browse"
>
<FolderOpen className="w-5 h-5" />
</button>
<button
onClick={() => {
const path = getDevicePath();
updateDeviceSetting([...path, 'cache'], '');
}}
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-red-50 hover:border-red-300 hover:text-red-500"
title="Clear Cache"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
)}
</>}
{deviceData.mode !== undefined && ( {deviceData.mode !== undefined && (
<div> <div className="flex items-center justify-between">
<label className="text-sm text-neutral-500 block mb-2">Mode</label> <label className="text-sm text-neutral-500">Mode</label>
<input {device.physical
type="number" ? <span className="text-sm text-neutral-700 px-3 py-2">
value={deviceData.mode} {(deviceData.mode ?? 0) === 0 ? 'Read Only' : 'Write Enabled'}
onChange={(e) => { </span>
const path = getDevicePath(); : <div className="flex rounded-lg border border-neutral-300 overflow-hidden text-sm">
updateDeviceSetting([...path, 'mode'], parseInt(e.target.value)); {([0, 1] as const).map((val, i) => (
}} <button
className="w-full px-3 py-2 border border-neutral-300 rounded-lg" key={val}
/> onClick={() => updateDeviceSetting([...getDevicePath(), 'mode'], val)}
className={`px-4 py-2 ${i > 0 ? 'border-l border-neutral-300' : ''} ${
(deviceData.mode ?? 0) === val
? 'bg-blue-600 text-white'
: 'bg-white text-neutral-700 hover:bg-neutral-50'
}`}
>
{val === 0 ? 'Read Only' : 'Write Enabled'}
</button>
))}
</div>
}
</div> </div>
)} )}

View File

@ -158,7 +158,7 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
// Auto-open the overlay when the parent passes a device ID // Auto-open the overlay when the parent passes a device ID
useEffect(() => { useEffect(() => {
if (!openDeviceId) return; if (!openDeviceId) return;
const idx = devices.findIndex(d => d.id === openDeviceId); const idx = displayDevices.findIndex(d => d.id === openDeviceId);
if (idx >= 0) setSelectedDeviceIndex(idx); if (idx >= 0) setSelectedDeviceIndex(idx);
onClearOpenDevice?.(); onClearOpenDevice?.();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -195,8 +195,7 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
}; };
const handleDeviceClick = (dd: DisplayDevice) => { const handleDeviceClick = (dd: DisplayDevice) => {
if (dd.physical) return; const idx = displayDevices.findIndex(d => d.id === dd.id);
const idx = devices.findIndex(d => d.number === dd.number);
if (idx >= 0) setSelectedDeviceIndex(idx); if (idx >= 0) setSelectedDeviceIndex(idx);
}; };
@ -206,7 +205,7 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
if (selectedDeviceIndex === null) return; if (selectedDeviceIndex === null) return;
if (direction === 'prev' && selectedDeviceIndex > 0) if (direction === 'prev' && selectedDeviceIndex > 0)
setSelectedDeviceIndex(selectedDeviceIndex - 1); setSelectedDeviceIndex(selectedDeviceIndex - 1);
else if (direction === 'next' && selectedDeviceIndex < devices.length - 1) else if (direction === 'next' && selectedDeviceIndex < displayDevices.length - 1)
setSelectedDeviceIndex(selectedDeviceIndex + 1); setSelectedDeviceIndex(selectedDeviceIndex + 1);
}; };
@ -235,6 +234,19 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
found.sort((a, b) => parseInt(a.number) - parseInt(b.number)); found.sort((a, b) => parseInt(a.number) - parseInt(b.number));
setPhysicalDevices(found); setPhysicalDevices(found);
// Disable any virtual device that now has a physical device at the same address.
const newConfig = JSON.parse(JSON.stringify(config));
let disabledCount = 0;
for (const p of found) {
const virt = newConfig.devices?.iec?.[p.number];
if (virt && virt.enabled) {
virt.enabled = 0;
disabledCount++;
}
}
if (disabledCount > 0) setConfig(newConfig);
setIsScanning(false); setIsScanning(false);
toast.dismiss(toastId); toast.dismiss(toastId);
toast.success(`Found ${found.length} physical device${found.length !== 1 ? 's' : ''} on the bus`); toast.success(`Found ${found.length} physical device${found.length !== 1 ? 's' : ''} on the bus`);
@ -348,8 +360,7 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
{/* Info — clickable for virtual devices */} {/* Info — clickable for virtual devices */}
<button <button
onClick={() => handleDeviceClick(device)} onClick={() => handleDeviceClick(device)}
disabled={device.physical} className="flex-1 min-w-0 text-left"
className="flex-1 min-w-0 text-left disabled:cursor-default"
> >
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className={!device.physical && !device.enabled ? 'text-neutral-400' : 'text-neutral-900'}> <span className={!device.physical && !device.enabled ? 'text-neutral-400' : 'text-neutral-900'}>
@ -401,16 +412,15 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
))} ))}
</div> </div>
{/* DeviceDetailOverlay — virtual devices only */}
{selectedDeviceIndex !== null && ( {selectedDeviceIndex !== null && (
<DeviceDetailOverlay <DeviceDetailOverlay
device={devices[selectedDeviceIndex]} device={displayDevices[selectedDeviceIndex]}
config={config} config={config}
setConfig={setConfig} setConfig={setConfig}
onClose={handleCloseOverlay} onClose={handleCloseOverlay}
onNavigate={handleNavigate} onNavigate={handleNavigate}
hasPrev={selectedDeviceIndex > 0} hasPrev={selectedDeviceIndex > 0}
hasNext={selectedDeviceIndex < devices.length - 1} hasNext={selectedDeviceIndex < displayDevices.length - 1}
/> />
)} )}

View File

@ -668,6 +668,10 @@ export default function MediaManager({ initialPath, rootPath, title, config, set
toast.error(`${mountEntry.name}: no files in swap list exist on device`); toast.error(`${mountEntry.name}: no files in swap list exist on device`);
return; return;
} }
if (files.length < 2) {
toast.error(`${mountEntry.name}: a media set needs more than one item`);
return;
}
if (files.length < candidates.length) { if (files.length < candidates.length) {
toast.warning(`${candidates.length - files.length} missing file(s) skipped from swap list`); toast.warning(`${candidates.length - files.length} missing file(s) skipped from swap list`);
} }