meatloaf-config/src/app/components/DeviceDetailOverlay.tsx

479 lines
18 KiB
TypeScript

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 <Printer className="w-6 h-6" />;
case 'drive':
return <HardDrive className="w-6 h-6" />;
case 'network':
return <Network className="w-6 h-6" />;
default:
return <Box className="w-6 h-6" />;
}
};
// Prefer an explicit .lst-derived mediaSet stored in config; fall back to pattern detection.
const [mediaSetFiles, setMediaSetFiles] = useState<string[] | null>(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 (
<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 overflow-y-auto z-50"
onClick={(e) => e.stopPropagation()}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
>
<div className="sticky top-0 bg-white border-b border-neutral-200 z-10">
<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 items-center gap-2">
<div className={deviceData.enabled ? 'text-blue-600' : 'text-neutral-400'}>
{getDeviceIcon(device.type)}
</div>
<div className="text-center">
<div className="text-xs text-neutral-500 capitalize">{device.type}</div>
<div className="font-medium">#{device.number}</div>
</div>
</div>
<button
onClick={() => setShowCommandMenu(!showCommandMenu)}
className="p-2 -m-2 relative"
>
<MoreVertical className="w-6 h-6" />
</button>
</div>
{showCommandMenu && (
<div className="absolute right-4 top-16 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[200px] z-20">
<button
onClick={() => sendCommand('RESET')}
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 className="flex items-center justify-between px-4 pb-4">
<button
onClick={() => onNavigate('prev')}
disabled={!hasPrev}
className={`p-2 rounded-lg ${
hasPrev
? 'text-blue-600 bg-blue-50'
: 'text-neutral-300 bg-neutral-50'
}`}
>
<ChevronLeft className="w-5 h-5" />
</button>
<span className="text-sm text-neutral-500">Swipe to navigate</span>
<button
onClick={() => onNavigate('next')}
disabled={!hasNext}
className={`p-2 rounded-lg ${
hasNext
? 'text-blue-600 bg-blue-50'
: 'text-neutral-300 bg-neutral-50'
}`}
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
<div className="p-4 space-y-6">
<div className="space-y-4">
<div>
<label className="text-sm text-neutral-500 block mb-2">Device Name</label>
<input
type="text"
value={deviceData.name || device.name || `Device ${device.number}`}
onChange={(e) => {
const path = getDevicePath();
updateDeviceSetting([...path, 'name'], e.target.value);
}}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
/>
</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 className="flex items-center justify-between">
<label className="text-sm text-neutral-500">Enabled</label>
<button
onClick={() => {
const path = getDevicePath();
updateDeviceSetting([...path, '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>
{deviceData.base_url !== undefined && (
<div>
<label className="text-sm text-neutral-500 block mb-2">Base URL</label>
<div className="flex gap-2">
<input
type="text"
value={deviceData.base_url ?? ''}
onChange={(e) => {
const path = getDevicePath();
updateDeviceSetting([...path, 'base_url'], e.target.value);
}}
className="flex-1 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">
<input
type="text"
value={deviceData.url}
onChange={(e) => {
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"
/>
<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 && (
<div>
<label className="text-sm text-neutral-500 block mb-2">Cache</label>
<div className="flex gap-2">
<input
type="text"
value={deviceData.cache ?? ''}
onChange={(e) => {
const path = getDevicePath();
updateDeviceSetting([...path, 'cache'], e.target.value);
}}
className="flex-1 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>
<label className="text-sm text-neutral-500 block mb-2">Mode</label>
<input
type="number"
value={deviceData.mode}
onChange={(e) => {
const path = getDevicePath();
updateDeviceSetting([...path, 'mode'], parseInt(e.target.value));
}}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
/>
</div>
)}
{deviceData.type && (
<div>
<label className="text-sm text-neutral-500 block mb-2">Device Type</label>
<div className="px-3 py-2 bg-neutral-50 border border-neutral-200 rounded-lg text-neutral-700">
{deviceData.type}
</div>
</div>
)}
{deviceData.baud !== undefined && (
<div>
<label className="text-sm text-neutral-500 block mb-2">Baud Rate</label>
<input
type="number"
value={deviceData.baud}
onChange={(e) => {
const path = getDevicePath();
updateDeviceSetting([...path, 'baud'], parseInt(e.target.value));
}}
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 {
const devPath = getDevicePath();
updateDeviceSetting([...devPath, browsingField], selectedPath);
}
setBrowsingField(null);
}}
onClose={() => setBrowsingField(null)}
/>
)}
</motion.div>
</motion.div>
</AnimatePresence>
);
}