import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { SettingsInput } from './ui/settings-input'; import { X, Printer, HardDrive, Network, Box, FolderOpen } 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 ; case 'drive': return ; case 'network': return ; default: return ; } } // ── Per-device slide content ───────────────────────────────────────────────── interface DeviceCardProps { device: Device; config: any; setConfig: (c: any) => void; isActive: boolean; onBrowsingChange?: (browsing: boolean) => void; } function DeviceCard({ device, config, setConfig, isActive, onBrowsingChange }: DeviceCardProps) { const [browsingField, setBrowsingField] = useState<'url' | 'base_url' | 'cache' | null>(null); const [mediaSetFiles, setMediaSetFiles] = useState(null); const detectTokenRef = useRef(0); // Notify parent when browser opens/closes so it can lock slide scroll. useEffect(() => { onBrowsingChange?.(browsingField !== null); }, [browsingField, onBrowsingChange]); // 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 ( <>
{!device.physical && (
)}
updateDeviceSetting([...getDevicePath(), 'name'], v)} onClear={() => updateDeviceSetting([...getDevicePath(), 'name'], '')} className="px-3 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm" containerClassName="w-full" />
{!device.physical && <>
updateDeviceSetting([...getDevicePath(), 'base_url'], v)} onClear={() => updateDeviceSetting([...getDevicePath(), 'base_url'], '')} containerClassName="flex-1" className="px-3 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm" />
{ 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.5 bg-neutral-100 border-0 rounded-xl text-sm" />
{mediaSetFiles && (
)}
{(deviceData.cache !== undefined || (deviceData.base_url ?? '').includes('://') || (deviceData.url ?? '').includes('://')) && (
updateDeviceSetting([...getDevicePath(), 'cache'], v)} onClear={() => updateDeviceSetting([...getDevicePath(), 'cache'], '')} containerClassName="flex-1" className="px-3 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm" />
)} } {deviceData.mode !== undefined && (
{device.physical ? {(deviceData.mode ?? 0) === 1 ? 'Yes' : 'No'} : }
)} {deviceData.baud !== undefined && (
updateDeviceSetting([...getDevicePath(), 'baud'], parseInt(v))} className="w-full px-3 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm" />
)}

Device ID

{device.id}
{browsingField && createPortal( { if (browsingField === 'url') { void handleFileSelect(selectedPath); } else { updateDeviceSetting([...getDevicePath(), browsingField], selectedPath); } setBrowsingField(null); }} onClose={() => setBrowsingField(null)} />, document.body )} ); } // ── Overlay shell with Swiper ──────────────────────────────────────────────── export default function DeviceDetailOverlay({ devices, initialIndex, config, setConfig, onClose, onIndexChange, }: DeviceDetailOverlayProps) { const [activeIndex, setActiveIndex] = useState(initialIndex); const [isBrowsing, setIsBrowsing] = useState(false); const swiperRef = useRef(null); const activeDevice = devices[activeIndex] ?? devices[0]; 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 (
{/* ── Header ── */}
{activeDevice.number} {activeDevice.physical && ( Physical )}

{activeDevice.name ?? `Device ${activeDevice.number}`}

{activeDevice.type}

{devices.length > 1 && ( {activeIndex + 1} / {devices.length} )}
{/* ── Swiper ── */}
{ swiperRef.current = s; }} onSlideChange={(s) => { setActiveIndex(s.activeIndex); onIndexChange?.(s.activeIndex); }} touchStartPreventDefault={false} style={{ height: '100%' }} > {devices.map((device, i) => ( ))}
); }