Enhance MediaEntry to support executable file types with appropriate icons Revamp SearchOverlay to utilize a locate database for improved search functionality and add database management features Update StatusPage to accommodate new DeviceDetailOverlay structure Implement locate-db module for efficient file searching and database handling Adjust Vite configuration to exclude sqlite-wasm from dependency optimization
480 lines
19 KiB
TypeScript
480 lines
19 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { SettingsInput } from './ui/settings-input';
|
|
import { X, Printer, HardDrive, Network, Box, FolderOpen, MoreVertical, RotateCcw } from 'lucide-react';
|
|
import { motion, AnimatePresence } from 'motion/react';
|
|
import { Swiper, SwiperSlide } from 'swiper/react';
|
|
import type { Swiper as SwiperType } from 'swiper';
|
|
import 'swiper/css';
|
|
import { toast } from 'sonner';
|
|
import { fileExists, getFileContents, joinPath, stat } from '../webdav';
|
|
import MediaBrowser from './MediaBrowser';
|
|
import MediaSet, { mediaSetEntryUrl, type MediaSetEntry } from './MediaSet';
|
|
|
|
export interface Device {
|
|
id: string;
|
|
number: string;
|
|
type: 'printer' | 'drive' | 'network' | 'other' | 'meatloaf';
|
|
name?: string;
|
|
enabled: boolean | number;
|
|
url?: string;
|
|
mode?: number;
|
|
physical?: boolean;
|
|
physicalModel?: string;
|
|
}
|
|
|
|
interface DeviceDetailOverlayProps {
|
|
devices: Device[];
|
|
initialIndex: number;
|
|
config: any;
|
|
setConfig: (config: any) => void;
|
|
onClose: () => void;
|
|
onIndexChange?: (index: number) => void;
|
|
}
|
|
|
|
function DeviceIcon({ device }: { device: Device }) {
|
|
const cls = `w-6 h-6`;
|
|
switch (device.type) {
|
|
case 'printer': return <Printer className={cls} />;
|
|
case 'drive': return <HardDrive className={cls} />;
|
|
case 'network': return <Network className={cls} />;
|
|
default: return <Box className={cls} />;
|
|
}
|
|
}
|
|
|
|
// ── Per-device slide content ─────────────────────────────────────────────────
|
|
|
|
interface DeviceCardProps {
|
|
device: Device;
|
|
config: any;
|
|
setConfig: (c: any) => void;
|
|
isActive: boolean;
|
|
}
|
|
|
|
function DeviceCard({ device, config, setConfig, isActive }: DeviceCardProps) {
|
|
const [browsingField, setBrowsingField] = useState<'url' | 'base_url' | 'cache' | null>(null);
|
|
const [mediaSetFiles, setMediaSetFiles] = useState<MediaSetEntry[] | null>(null);
|
|
const detectTokenRef = useRef(0);
|
|
|
|
// Close any open browser when swiped away.
|
|
useEffect(() => {
|
|
if (!isActive) setBrowsingField(null);
|
|
}, [isActive]);
|
|
|
|
const getDevicePath = (): string[] => ['devices', 'iec', device.number];
|
|
|
|
const getDeviceData = () => {
|
|
let current: any = config;
|
|
for (const key of getDevicePath()) current = current?.[key];
|
|
return current || {};
|
|
};
|
|
|
|
const deviceData = getDeviceData();
|
|
|
|
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);
|
|
};
|
|
|
|
useEffect(() => {
|
|
++detectTokenRef.current;
|
|
setMediaSetFiles(null);
|
|
}, [device.number]);
|
|
|
|
useEffect(() => {
|
|
if (Array.isArray(deviceData.media_set) && deviceData.media_set.length > 1) {
|
|
setMediaSetFiles(deviceData.media_set as MediaSetEntry[]);
|
|
}
|
|
}, [deviceData.media_set]);
|
|
|
|
const detectMediaSet = async (url: string) => {
|
|
const token = ++detectTokenRef.current;
|
|
setMediaSetFiles(null);
|
|
const match = url.match(/^(.+?)(\d+)(\.[^.]+)$/);
|
|
if (!match) return;
|
|
const [, prefix, , ext] = match;
|
|
const candidates: string[] = [];
|
|
for (let i = 1; i <= 10; i++) candidates.push(`${prefix}${i}${ext}`);
|
|
const flags = await Promise.all(candidates.map(f => stat(f).then(r => r !== null).catch(() => false)));
|
|
if (detectTokenRef.current !== token) return;
|
|
const found = candidates.filter((_, i) => flags[i]);
|
|
setMediaSetFiles(found.length > 1 ? found : null);
|
|
};
|
|
|
|
const switchMedia = (file: string) => updateDeviceSetting([...getDevicePath(), 'url'], file);
|
|
|
|
const isOutsideBase = (url: string, baseUrl: string): boolean => {
|
|
if (!baseUrl || !url.startsWith('/')) return false;
|
|
const nb = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/';
|
|
return url !== baseUrl && !url.startsWith(nb);
|
|
};
|
|
|
|
const clearBaseAndCache = (dev: any) => {
|
|
if ('base_url' in dev) dev.base_url = '';
|
|
if ('cache' in dev) dev.cache = '';
|
|
};
|
|
|
|
const handleFileSelect = async (selectedPath: string) => {
|
|
const devicePath = getDevicePath();
|
|
const selExt = selectedPath.split('.').pop()?.toLowerCase() ?? '';
|
|
if (selExt === 'lst' || selExt === 'vms') {
|
|
const isVms = selExt === 'vms';
|
|
try {
|
|
const text = await (await getFileContents(selectedPath)).text();
|
|
const dir = selectedPath.split('/').slice(0, -1).join('/') || '/';
|
|
const candidates: MediaSetEntry[] = text.split('\n')
|
|
.map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('#'))
|
|
.map(l => {
|
|
if (isVms) {
|
|
const comma = l.indexOf(',');
|
|
if (comma > 0) {
|
|
const url = l.slice(0, comma).trim();
|
|
const name = l.slice(comma + 1).trim();
|
|
const resolved = url.startsWith('/') ? url : joinPath(dir, url);
|
|
return name ? { url: resolved, name } : resolved;
|
|
}
|
|
}
|
|
return l.startsWith('/') ? l : joinPath(dir, l);
|
|
});
|
|
if (candidates.length === 0) { toast.error('Swap list is empty'); return; }
|
|
const existsArr = await Promise.all(candidates.map(e => fileExists(mediaSetEntryUrl(e)).catch(() => false)));
|
|
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 < 2) { toast.error('A media set needs more than one item'); return; }
|
|
if (files.length < candidates.length)
|
|
toast.warning(`${candidates.length - files.length} missing file(s) skipped from swap list`);
|
|
const newConfig = JSON.parse(JSON.stringify(config));
|
|
let dev = newConfig;
|
|
for (const k of devicePath) dev = dev[k];
|
|
if (isOutsideBase(mediaSetEntryUrl(files[0]), dev.base_url || '')) clearBaseAndCache(dev);
|
|
dev.url = mediaSetEntryUrl(files[0]);
|
|
dev.media_set = files;
|
|
setConfig(newConfig);
|
|
} catch (e: any) {
|
|
toast.error(`Failed to read swap list: ${e?.message ?? e}`);
|
|
}
|
|
} else {
|
|
const newConfig = JSON.parse(JSON.stringify(config));
|
|
let dev = newConfig;
|
|
for (const k of devicePath) dev = dev[k];
|
|
if (isOutsideBase(selectedPath, dev.base_url || '')) clearBaseAndCache(dev);
|
|
dev.url = selectedPath;
|
|
delete dev.media_set;
|
|
setConfig(newConfig);
|
|
void detectMediaSet(selectedPath);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="p-4 space-y-6">
|
|
<div className="space-y-4">
|
|
{!device.physical && (
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-sm text-neutral-500">Enabled</label>
|
|
<button
|
|
onClick={() => updateDeviceSetting([...getDevicePath(), '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>
|
|
)}
|
|
|
|
<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>
|
|
<label className="text-sm text-neutral-500 block mb-2">Device Name</label>
|
|
<SettingsInput
|
|
type="text"
|
|
value={deviceData.name || device.name || `Device ${device.number}`}
|
|
onCommit={(v) => updateDeviceSetting([...getDevicePath(), 'name'], v)}
|
|
onClear={() => updateDeviceSetting([...getDevicePath(), 'name'], '')}
|
|
className="px-3 py-2 border border-neutral-300 rounded-lg"
|
|
containerClassName="w-full"
|
|
/>
|
|
</div>
|
|
|
|
{!device.physical && <>
|
|
<div>
|
|
<label className="text-sm text-neutral-500 block mb-2">Base URL</label>
|
|
<div className="flex gap-2">
|
|
<SettingsInput
|
|
type="text"
|
|
value={deviceData.base_url ?? ''}
|
|
onCommit={(v) => updateDeviceSetting([...getDevicePath(), 'base_url'], v)}
|
|
onClear={() => updateDeviceSetting([...getDevicePath(), 'base_url'], '')}
|
|
containerClassName="flex-1"
|
|
className="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"
|
|
>
|
|
<FolderOpen 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">
|
|
<SettingsInput
|
|
type="text"
|
|
value={deviceData.url ?? ''}
|
|
onCommit={(newUrl) => {
|
|
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;
|
|
delete dev.media_set;
|
|
setConfig(newConfig);
|
|
if (newUrl) void detectMediaSet(newUrl);
|
|
}}
|
|
onClear={() => {
|
|
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);
|
|
++detectTokenRef.current;
|
|
setMediaSetFiles(null);
|
|
}}
|
|
containerClassName="flex-1"
|
|
className="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"
|
|
>
|
|
<FolderOpen 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">
|
|
<SettingsInput
|
|
type="text"
|
|
value={deviceData.cache ?? ''}
|
|
onCommit={(v) => updateDeviceSetting([...getDevicePath(), 'cache'], v)}
|
|
onClear={() => updateDeviceSetting([...getDevicePath(), 'cache'], '')}
|
|
containerClassName="flex-1"
|
|
className="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"
|
|
>
|
|
<FolderOpen className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>}
|
|
|
|
{deviceData.mode !== undefined && (
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-sm text-neutral-500">Mode</label>
|
|
{device.physical
|
|
? <span className="text-sm text-neutral-700 px-3 py-2">{(deviceData.mode ?? 0) === 0 ? 'Read Only' : 'Write Enabled'}</span>
|
|
: <div className="flex rounded-lg border border-neutral-300 overflow-hidden text-sm">
|
|
{([0, 1] as const).map((val, i) => (
|
|
<button
|
|
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>
|
|
)}
|
|
|
|
{deviceData.baud !== undefined && (
|
|
<div>
|
|
<label className="text-sm text-neutral-500 block mb-2">Baud Rate</label>
|
|
<SettingsInput
|
|
type="number"
|
|
value={String(deviceData.baud ?? '')}
|
|
onCommit={(v) => updateDeviceSetting([...getDevicePath(), 'baud'], parseInt(v))}
|
|
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>
|
|
|
|
{browsingField && (
|
|
<MediaBrowser
|
|
currentPath={
|
|
browsingField === 'cache' ? (deviceData.cache || '/') :
|
|
browsingField === 'base_url' ? (deviceData.base_url || '/') :
|
|
(deviceData.url || '/')
|
|
}
|
|
onSelect={(selectedPath: string) => {
|
|
if (browsingField === 'url') {
|
|
void handleFileSelect(selectedPath);
|
|
} else {
|
|
updateDeviceSetting([...getDevicePath(), browsingField], selectedPath);
|
|
}
|
|
setBrowsingField(null);
|
|
}}
|
|
onClose={() => setBrowsingField(null)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ── Overlay shell with Swiper ────────────────────────────────────────────────
|
|
|
|
export default function DeviceDetailOverlay({
|
|
devices,
|
|
initialIndex,
|
|
config,
|
|
setConfig,
|
|
onClose,
|
|
onIndexChange,
|
|
}: DeviceDetailOverlayProps) {
|
|
const [activeIndex, setActiveIndex] = useState(initialIndex);
|
|
const [showCommandMenu, setShowCommandMenu] = useState(false);
|
|
const swiperRef = useRef<SwiperType | null>(null);
|
|
|
|
const activeDevice = devices[activeIndex] ?? devices[0];
|
|
|
|
useEffect(() => { setShowCommandMenu(false); }, [activeIndex]);
|
|
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') { onClose(); return; }
|
|
const tag = (document.activeElement as HTMLElement)?.tagName;
|
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
|
if (e.key === 'ArrowLeft') swiperRef.current?.slidePrev();
|
|
if (e.key === 'ArrowRight') swiperRef.current?.slideNext();
|
|
};
|
|
window.addEventListener('keydown', handler);
|
|
return () => window.removeEventListener('keydown', handler);
|
|
}, [onClose]);
|
|
|
|
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="fixed inset-0 bg-white flex flex-col z-50"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* ── Header ── */}
|
|
<div className="flex-shrink-0 border-b border-neutral-200 relative">
|
|
<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 flex-col items-center gap-0.5 ${
|
|
activeDevice.physical ? 'text-green-600' : activeDevice.enabled ? 'text-blue-600' : 'text-neutral-400'
|
|
}`}>
|
|
<span className="text-sm font-semibold leading-none">{activeDevice.number}</span>
|
|
<DeviceIcon device={activeDevice} />
|
|
{activeDevice.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 className="flex items-center gap-2">
|
|
{devices.length > 1 && (
|
|
<span className="text-xs text-neutral-400 tabular-nums">
|
|
{activeIndex + 1} / {devices.length}
|
|
</span>
|
|
)}
|
|
<button onClick={() => setShowCommandMenu(v => !v)} className="p-2 -m-2">
|
|
<MoreVertical className="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{showCommandMenu && (
|
|
<div className="absolute right-4 top-14 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[200px] z-20">
|
|
<button
|
|
onClick={() => {
|
|
console.log(`Reset device ${activeDevice.number}`);
|
|
setShowCommandMenu(false);
|
|
}}
|
|
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>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Swiper ── */}
|
|
<div className="flex-1 min-h-0">
|
|
<Swiper
|
|
initialSlide={initialIndex}
|
|
onSwiper={(s) => { swiperRef.current = s; }}
|
|
onSlideChange={(s) => {
|
|
setActiveIndex(s.activeIndex);
|
|
onIndexChange?.(s.activeIndex);
|
|
}}
|
|
touchStartPreventDefault={false}
|
|
style={{ height: '100%' }}
|
|
>
|
|
{devices.map((device, i) => (
|
|
<SwiperSlide key={device.id} style={{ overflowY: 'auto', height: '100%' }}>
|
|
<DeviceCard
|
|
device={device}
|
|
config={config}
|
|
setConfig={setConfig}
|
|
isActive={i === activeIndex}
|
|
/>
|
|
</SwiperSlide>
|
|
))}
|
|
</Swiper>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
);
|
|
}
|