import { useEffect, useState } from '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 { toast } from 'sonner'; import { fileExists, getFileContents, joinPath } from '../webdav'; import MediaBrowser from './MediaBrowser'; import MediaSet from './MediaSet'; interface Device { id: string; number: string; type: 'printer' | 'drive' | 'network' | 'other' | 'meatloaf'; name?: string; enabled: boolean | number; url?: string; mode?: number; } interface DeviceDetailOverlayProps { device: Device; config: any; setConfig: (config: any) => void; onClose: () => void; onNavigate: (direction: 'prev' | 'next') => void; hasPrev: boolean; hasNext: boolean; } export default function DeviceDetailOverlay({ device, config, setConfig, onClose, onNavigate, hasPrev, hasNext }: DeviceDetailOverlayProps) { const [touchStart, setTouchStart] = useState(0); const [touchEnd, setTouchEnd] = useState(0); const [browsingField, setBrowsingField] = useState<'url' | 'base_url' | 'cache' | null>(null); const [showCommandMenu, setShowCommandMenu] = useState(false); const minSwipeDistance = 50; const onTouchStart = (e: React.TouchEvent) => { setTouchEnd(0); setTouchStart(e.targetTouches[0].clientX); }; const onTouchMove = (e: React.TouchEvent) => { setTouchEnd(e.targetTouches[0].clientX); }; const onTouchEnd = () => { if (!touchStart || !touchEnd) return; const distance = touchStart - touchEnd; const isLeftSwipe = distance > minSwipeDistance; const isRightSwipe = distance < -minSwipeDistance; if (isLeftSwipe && hasNext) { onNavigate('next'); } if (isRightSwipe && hasPrev) { onNavigate('prev'); } }; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { onClose(); } else if (e.key === 'ArrowLeft' && hasPrev) { onNavigate('prev'); } else if (e.key === 'ArrowRight' && hasNext) { onNavigate('next'); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [hasPrev, hasNext, onClose, onNavigate]); 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); }; const getDevicePath = (): string[] => { const [type, num] = device.id.split('-'); return ['iec', 'devices', type, num]; }; const getDeviceData = () => { const path = getDevicePath(); let current = config; for (const key of path) { current = current?.[key]; } return current || {}; }; const deviceData = getDeviceData(); const getDeviceIcon = (type: Device['type']) => { switch (type) { case 'printer': return ; case 'drive': return ; case 'network': return ; default: return ; } }; // Prefer an explicit .lst-derived mediaSet stored in config; fall back to pattern detection. const [mediaSetFiles, setMediaSetFiles] = useState(null); useEffect(() => { if (Array.isArray(deviceData.media_set) && deviceData.media_set.length > 0) { setMediaSetFiles(deviceData.media_set as string[]); return; } if (!deviceData.url) { setMediaSetFiles(null); return; } const match = (deviceData.url as string).match(/^(.+?)(\d+)(\.[^.]+)$/); if (!match) { setMediaSetFiles(null); return; } const [, prefix, , ext] = match; const candidates: string[] = []; for (let i = 1; i <= 10; i++) candidates.push(`${prefix}${i}${ext}`); let cancelled = false; Promise.all(candidates.map(f => fileExists(f).catch(() => false))).then(flags => { if (!cancelled) setMediaSetFiles(candidates.filter((_, i) => flags[i])); }); return () => { cancelled = true; }; }, [deviceData.url, deviceData.media_set]); const switchMedia = (file: string) => { const path = getDevicePath(); updateDeviceSetting([...path, 'url'], file); }; const isOutsideBase = (url: string, baseUrl: string): boolean => { if (!baseUrl || !url.startsWith('/')) return false; const normalizedBase = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/'; return url !== baseUrl && !url.startsWith(normalizedBase); }; 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(); if (selectedPath.toLowerCase().endsWith('.lst')) { try { const text = await (await getFileContents(selectedPath)).text(); const dir = selectedPath.split('/').slice(0, -1).join('/') || '/'; const candidates = text.split('\n') .map(l => l.trim()) .filter(l => l.length > 0 && !l.startsWith('#')) .map(l => l.startsWith('/') ? l : joinPath(dir, l)); if (candidates.length === 0) { toast.error('Swap list is empty'); return; } const existsArr = await Promise.all(candidates.map(f => fileExists(f).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 < 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(files[0], dev.base_url || '')) clearBaseAndCache(dev); dev.url = 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); } }; const sendCommand = (command: string) => { console.log(`Sending command to device ${device.number}: ${command}`); // In a real app, this would send the command to the device setShowCommandMenu(false); }; return ( e.stopPropagation()} onTouchStart={onTouchStart} onTouchMove={onTouchMove} onTouchEnd={onTouchEnd} >
{getDeviceIcon(device.type)}
{device.type}
#{device.number}
{showCommandMenu && (
)}
Swipe to navigate
{ const path = getDevicePath(); updateDeviceSetting([...path, 'name'], e.target.value); }} className="w-full px-3 py-2 border border-neutral-300 rounded-lg" />
{device.type.charAt(0).toUpperCase() + device.type.slice(1)}
{deviceData.base_url !== undefined && (
{ const path = getDevicePath(); updateDeviceSetting([...path, 'base_url'], e.target.value); }} className="flex-1 px-3 py-2 border border-neutral-300 rounded-lg" />
)}
{ 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" />
{mediaSetFiles && (
)}
{deviceData.cache !== undefined && (
{ const path = getDevicePath(); updateDeviceSetting([...path, 'cache'], e.target.value); }} className="flex-1 px-3 py-2 border border-neutral-300 rounded-lg" />
)} {deviceData.mode !== undefined && (
{ const path = getDevicePath(); updateDeviceSetting([...path, 'mode'], parseInt(e.target.value)); }} className="w-full px-3 py-2 border border-neutral-300 rounded-lg" />
)} {deviceData.type && (
{deviceData.type}
)} {deviceData.baud !== undefined && (
{ const path = getDevicePath(); updateDeviceSetting([...path, 'baud'], parseInt(e.target.value)); }} className="w-full px-3 py-2 border border-neutral-300 rounded-lg" />
)}

Device ID

{device.id}
{browsingField && ( { if (browsingField === 'url') { void handleFileSelect(selectedPath); } else { const devPath = getDevicePath(); updateDeviceSetting([...devPath, browsingField], selectedPath); } setBrowsingField(null); }} onClose={() => setBrowsingField(null)} /> )}
); }