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

463 lines
19 KiB
TypeScript

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 <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;
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<MediaSetEntry[] | null>(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 (
<>
<div className="p-4 space-y-5">
<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-xs font-medium text-neutral-400 uppercase tracking-wide block mb-1.5">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.5 bg-neutral-100 border-0 rounded-xl text-sm"
containerClassName="w-full"
/>
</div>
{!device.physical && <>
<div>
<label className="text-xs font-medium text-neutral-400 uppercase tracking-wide block mb-1.5">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.5 bg-neutral-100 border-0 rounded-xl text-sm"
/>
<button
onClick={() => setBrowsingField('base_url')}
className="px-3 py-2.5 bg-neutral-100 hover:bg-neutral-200 rounded-xl transition-colors"
>
<FolderOpen className="w-5 h-5 text-neutral-500" />
</button>
</div>
</div>
<div>
<label className="text-xs font-medium text-neutral-400 uppercase tracking-wide block mb-1.5">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.5 bg-neutral-100 border-0 rounded-xl text-sm"
/>
<button
onClick={() => setBrowsingField('url')}
className="px-3 py-2.5 bg-neutral-100 hover:bg-neutral-200 rounded-xl transition-colors"
>
<FolderOpen className="w-5 h-5 text-neutral-500" />
</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-xs font-medium text-neutral-400 uppercase tracking-wide block mb-1.5">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.5 bg-neutral-100 border-0 rounded-xl text-sm"
/>
<button
onClick={() => setBrowsingField('cache')}
className="px-3 py-2.5 bg-neutral-100 hover:bg-neutral-200 rounded-xl transition-colors"
>
<FolderOpen className="w-5 h-5 text-neutral-500" />
</button>
</div>
</div>
)}
</>}
{deviceData.mode !== undefined && (
<div className="flex items-center justify-between">
<label className="text-sm text-neutral-500">Write Enabled</label>
{device.physical
? <span className={`text-sm ${(deviceData.mode ?? 0) === 1 ? 'text-blue-600' : 'text-neutral-400'}`}>
{(deviceData.mode ?? 0) === 1 ? 'Yes' : 'No'}
</span>
: <button
onClick={() => updateDeviceSetting([...getDevicePath(), 'mode'], (deviceData.mode ?? 0) === 0 ? 1 : 0)}
className={`relative w-12 h-6 rounded-full transition-colors ${(deviceData.mode ?? 0) === 1 ? 'bg-blue-600' : 'bg-neutral-300'}`}
>
<div className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${(deviceData.mode ?? 0) === 1 ? 'translate-x-6' : 'translate-x-0.5'}`} />
</button>
}
</div>
)}
{deviceData.baud !== undefined && (
<div>
<label className="text-xs font-medium text-neutral-400 uppercase tracking-wide block mb-1.5">Baud Rate</label>
<SettingsInput
type="number"
value={String(deviceData.baud ?? '')}
onCommit={(v) => updateDeviceSetting([...getDevicePath(), 'baud'], parseInt(v))}
className="w-full px-3 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm"
/>
</div>
)}
</div>
<div className="pt-4 border-t border-neutral-100">
<h3 className="text-xs font-medium text-neutral-400 uppercase tracking-wide mb-1.5">Device ID</h3>
<code className="text-xs text-neutral-500 bg-neutral-100 px-2.5 py-1.5 rounded-lg">{device.id}</code>
</div>
</div>
{browsingField && createPortal(
<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)}
/>,
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<SwiperType | null>(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 (
<AnimatePresence>
<div className="fixed inset-0 z-50">
<motion.div
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
className="fixed inset-0 bg-white/80 backdrop-blur-md flex flex-col overflow-hidden"
>
{/* ── Header ── */}
<div className="flex-shrink-0 border-b border-neutral-200/70 relative">
<div className="flex items-center gap-3 px-4 py-3">
<div className={`flex flex-col items-center gap-0.5 flex-shrink-0 ${
activeDevice.physical ? 'text-green-600' : activeDevice.enabled ? 'text-blue-600' : 'text-neutral-400'
}`}>
<span className="text-xs font-semibold leading-none tabular-nums">{activeDevice.number}</span>
<DeviceIcon device={activeDevice} />
{activeDevice.physical && (
<span className="text-[10px] text-green-700 px-1 py-0.5 bg-green-50 border border-green-200 rounded leading-none">
Physical
</span>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-base font-semibold text-neutral-800 truncate leading-tight">
{activeDevice.name ?? `Device ${activeDevice.number}`}
</p>
<p className="text-xs text-neutral-400 capitalize">{activeDevice.type}</p>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{devices.length > 1 && (
<span className="text-xs text-neutral-400 tabular-nums px-1">
{activeIndex + 1} / {devices.length}
</span>
)}
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-neutral-100 text-neutral-500 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
</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: isBrowsing && i === activeIndex ? 'hidden' : 'auto', height: '100%' }}>
<DeviceCard
device={device}
config={config}
setConfig={setConfig}
isActive={i === activeIndex}
onBrowsingChange={i === activeIndex ? setIsBrowsing : undefined}
/>
</SwiperSlide>
))}
</Swiper>
</div>
</motion.div>
</div>
</AnimatePresence>
);
}