import { useEffect, useRef, useState } from 'react'; import { SettingsInput } from './ui/settings-input'; 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, stat } from '../webdav'; import MediaBrowser from './MediaBrowser'; import MediaSet, { mediaSetEntryUrl, type MediaSetEntry } from './MediaSet'; 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 { 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' || e.key === 'ArrowRight') { const tag = (document.activeElement as HTMLElement)?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; if (e.key === 'ArrowLeft' && hasPrev) onNavigate('prev'); 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[] => { return ['devices', 'iec', device.number]; }; 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 ; } }; const [mediaSetFiles, setMediaSetFiles] = useState(null); const detectTokenRef = useRef(0); // Cancel any in-flight detection and reset when navigating to a different device. useEffect(() => { ++detectTokenRef.current; setMediaSetFiles(null); }, [device.number]); // Sync config-backed media_set to display state whenever it is set explicitly // (e.g. after a playlist is mounted). useEffect(() => { if (Array.isArray(deviceData.media_set) && deviceData.media_set.length > 1) { setMediaSetFiles(deviceData.media_set as MediaSetEntry[]); } }, [deviceData.media_set]); // Pattern-detect sibling numbered files for the given URL. Called only when // the user actively sets a URL — never on overlay load or device switch. 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) => { 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(); 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); } }; 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} >
{device.number} {getDeviceIcon(device.type)} {device.physical && ( Physical )}
{showCommandMenu && (
)}
Swipe to navigate
{!device.physical && (
)}
{device.type.charAt(0).toUpperCase() + device.type.slice(1)}
{ const path = getDevicePath(); updateDeviceSetting([...path, 'name'], v); }} onClear={() => updateDeviceSetting([...getDevicePath(), 'name'], '')} className="px-3 py-2 border border-neutral-300 rounded-lg" containerClassName="w-full" />
{!device.physical && <>
{ const path = getDevicePath(); updateDeviceSetting([...path, 'base_url'], v); }} onClear={() => updateDeviceSetting([...getDevicePath(), 'base_url'], '')} containerClassName="flex-1" className="px-3 py-2 border border-neutral-300 rounded-lg" />
{ 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" />
{mediaSetFiles && (
)}
{(deviceData.cache !== undefined || (deviceData.base_url ?? '').includes('://') || (deviceData.url ?? '').includes('://')) && (
{ const path = getDevicePath(); updateDeviceSetting([...path, 'cache'], v); }} onClear={() => updateDeviceSetting([...getDevicePath(), 'cache'], '')} containerClassName="flex-1" className="px-3 py-2 border border-neutral-300 rounded-lg" />
)} } {deviceData.mode !== undefined && (
{device.physical ? {(deviceData.mode ?? 0) === 0 ? 'Read Only' : 'Write Enabled'} :
{([0, 1] as const).map((val, i) => ( ))}
}
)} {deviceData.baud !== undefined && (
{ const path = getDevicePath(); updateDeviceSetting([...path, 'baud'], parseInt(v)); }} 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)} /> )}
); }