Compare commits
No commits in common. "e4a5eac6765af18fd9b393d34afacddc9e2e435c" and "d9f95c6864c21f9bb6c252f6040da3114f058740" have entirely different histories.
e4a5eac676
...
d9f95c6864
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, 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,8 +14,6 @@ interface Device {
|
||||||
enabled: boolean | number;
|
enabled: boolean | number;
|
||||||
url?: string;
|
url?: string;
|
||||||
mode?: number;
|
mode?: number;
|
||||||
physical?: boolean;
|
|
||||||
physicalModel?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DeviceDetailOverlayProps {
|
interface DeviceDetailOverlayProps {
|
||||||
|
|
@ -72,11 +70,10 @@ 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' || e.key === 'ArrowRight') {
|
} else if (e.key === 'ArrowLeft' && hasPrev) {
|
||||||
const tag = (document.activeElement as HTMLElement)?.tagName;
|
onNavigate('prev');
|
||||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
} else if (e.key === 'ArrowRight' && hasNext) {
|
||||||
if (e.key === 'ArrowLeft' && hasPrev) onNavigate('prev');
|
onNavigate('next');
|
||||||
if (e.key === 'ArrowRight' && hasNext) onNavigate('next');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -126,19 +123,13 @@ 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(() => {
|
||||||
const deviceChanged = prevDeviceNumberRef.current !== device.number;
|
if (Array.isArray(deviceData.media_set) && deviceData.media_set.length > 0) {
|
||||||
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;
|
||||||
}
|
}
|
||||||
// Don't run pattern detection when switching devices — only when the URL
|
if (!deviceData.url) { setMediaSetFiles(null); return; }
|
||||||
// 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;
|
||||||
|
|
@ -146,11 +137,10 @@ 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 => {
|
||||||
const found = candidates.filter((_, i) => flags[i]);
|
if (!cancelled) setMediaSetFiles(candidates.filter((_, i) => flags[i]));
|
||||||
if (!cancelled) setMediaSetFiles(found.length > 1 ? found : null);
|
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [device.number, deviceData.url, deviceData.media_set]);
|
}, [deviceData.url, deviceData.media_set]);
|
||||||
|
|
||||||
const switchMedia = (file: string) => {
|
const switchMedia = (file: string) => {
|
||||||
const path = getDevicePath();
|
const path = getDevicePath();
|
||||||
|
|
@ -195,7 +185,6 @@ 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`);
|
||||||
}
|
}
|
||||||
|
|
@ -251,16 +240,9 @@ 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 ${
|
<div className={`flex flex-col items-center gap-0.5 ${deviceData.enabled ? 'text-blue-600' : 'text-neutral-400'}`}>
|
||||||
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)}
|
||||||
|
|
@ -311,7 +293,6 @@ 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">
|
||||||
{!device.physical && (
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-sm text-neutral-500">Enabled</label>
|
<label className="text-sm text-neutral-500">Enabled</label>
|
||||||
<button
|
<button
|
||||||
|
|
@ -330,7 +311,6 @@ export default function DeviceDetailOverlay({
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
@ -352,7 +332,6 @@ export default function DeviceDetailOverlay({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!device.physical && <>
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-neutral-500 block mb-2">Base URL</label>
|
<label className="text-sm text-neutral-500 block mb-2">Base URL</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|
@ -452,48 +431,25 @@ export default function DeviceDetailOverlay({
|
||||||
<button
|
<button
|
||||||
onClick={() => setBrowsingField('cache')}
|
onClick={() => setBrowsingField('cache')}
|
||||||
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, '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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>}
|
|
||||||
|
|
||||||
{deviceData.mode !== undefined && (
|
{deviceData.mode !== undefined && (
|
||||||
<div className="flex items-center justify-between">
|
<div>
|
||||||
<label className="text-sm text-neutral-500">Mode</label>
|
<label className="text-sm text-neutral-500 block mb-2">Mode</label>
|
||||||
{device.physical
|
<input
|
||||||
? <span className="text-sm text-neutral-700 px-3 py-2">
|
type="number"
|
||||||
{(deviceData.mode ?? 0) === 0 ? 'Read Only' : 'Write Enabled'}
|
value={deviceData.mode}
|
||||||
</span>
|
onChange={(e) => {
|
||||||
: <div className="flex rounded-lg border border-neutral-300 overflow-hidden text-sm">
|
const path = getDevicePath();
|
||||||
{([0, 1] as const).map((val, i) => (
|
updateDeviceSetting([...path, 'mode'], parseInt(e.target.value));
|
||||||
<button
|
}}
|
||||||
key={val}
|
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = displayDevices.findIndex(d => d.id === openDeviceId);
|
const idx = devices.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,7 +195,8 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeviceClick = (dd: DisplayDevice) => {
|
const handleDeviceClick = (dd: DisplayDevice) => {
|
||||||
const idx = displayDevices.findIndex(d => d.id === dd.id);
|
if (dd.physical) return;
|
||||||
|
const idx = devices.findIndex(d => d.number === dd.number);
|
||||||
if (idx >= 0) setSelectedDeviceIndex(idx);
|
if (idx >= 0) setSelectedDeviceIndex(idx);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -205,7 +206,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 < displayDevices.length - 1)
|
else if (direction === 'next' && selectedDeviceIndex < devices.length - 1)
|
||||||
setSelectedDeviceIndex(selectedDeviceIndex + 1);
|
setSelectedDeviceIndex(selectedDeviceIndex + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -234,19 +235,6 @@ 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`);
|
||||||
|
|
@ -360,7 +348,8 @@ 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)}
|
||||||
className="flex-1 min-w-0 text-left"
|
disabled={device.physical}
|
||||||
|
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'}>
|
||||||
|
|
@ -412,15 +401,16 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* DeviceDetailOverlay — virtual devices only */}
|
||||||
{selectedDeviceIndex !== null && (
|
{selectedDeviceIndex !== null && (
|
||||||
<DeviceDetailOverlay
|
<DeviceDetailOverlay
|
||||||
device={displayDevices[selectedDeviceIndex]}
|
device={devices[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 < displayDevices.length - 1}
|
hasNext={selectedDeviceIndex < devices.length - 1}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -668,10 +668,6 @@ 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`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user