Refactor DevicesPage to simplify device navigation and enhance DeviceDetailOverlay integration
Enhance MediaEntry to support executable file types with appropriate icons Revamp SearchOverlay to utilize a locate database for improved search functionality and add database management features Update StatusPage to accommodate new DeviceDetailOverlay structure Implement locate-db module for efficient file searching and database handling Adjust Vite configuration to exclude sqlite-wasm from dependency optimization
This commit is contained in:
parent
5ec57a1b27
commit
10c9a13340
|
|
@ -50,6 +50,7 @@
|
||||||
"@radix-ui/react-toggle": "1.1.2",
|
"@radix-ui/react-toggle": "1.1.2",
|
||||||
"@radix-ui/react-toggle-group": "1.1.2",
|
"@radix-ui/react-toggle-group": "1.1.2",
|
||||||
"@radix-ui/react-tooltip": "1.1.8",
|
"@radix-ui/react-tooltip": "1.1.8",
|
||||||
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
||||||
"@types/parallax-js": "^3.1.3",
|
"@types/parallax-js": "^3.1.3",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@uiw/react-codemirror": "^4.25.10",
|
"@uiw/react-codemirror": "^4.25.10",
|
||||||
|
|
@ -80,6 +81,7 @@
|
||||||
"recharts": "2.15.2",
|
"recharts": "2.15.2",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "2.0.3",
|
"sonner": "2.0.3",
|
||||||
|
"swiper": "^12.2.0",
|
||||||
"tailwind-merge": "3.2.0",
|
"tailwind-merge": "3.2.0",
|
||||||
"three": "^0.160.0",
|
"three": "^0.160.0",
|
||||||
"tw-animate-css": "1.3.8",
|
"tw-animate-css": "1.3.8",
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { SettingsInput } from './ui/settings-input';
|
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 { X, Printer, HardDrive, Network, Box, FolderOpen, MoreVertical, RotateCcw } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/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 { toast } from 'sonner';
|
||||||
import { fileExists, getFileContents, joinPath, stat } from '../webdav';
|
import { fileExists, getFileContents, joinPath, stat } from '../webdav';
|
||||||
import MediaBrowser from './MediaBrowser';
|
import MediaBrowser from './MediaBrowser';
|
||||||
import MediaSet, { mediaSetEntryUrl, type MediaSetEntry } from './MediaSet';
|
import MediaSet, { mediaSetEntryUrl, type MediaSetEntry } from './MediaSet';
|
||||||
|
|
||||||
interface Device {
|
export interface Device {
|
||||||
id: string;
|
id: string;
|
||||||
number: string;
|
number: string;
|
||||||
type: 'printer' | 'drive' | 'network' | 'other' | 'meatloaf';
|
type: 'printer' | 'drive' | 'network' | 'other' | 'meatloaf';
|
||||||
|
|
@ -20,130 +23,72 @@ interface Device {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DeviceDetailOverlayProps {
|
interface DeviceDetailOverlayProps {
|
||||||
device: Device;
|
devices: Device[];
|
||||||
|
initialIndex: number;
|
||||||
config: any;
|
config: any;
|
||||||
setConfig: (config: any) => void;
|
setConfig: (config: any) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onNavigate: (direction: 'prev' | 'next') => void;
|
onIndexChange?: (index: number) => void;
|
||||||
hasPrev: boolean;
|
|
||||||
hasNext: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DeviceDetailOverlay({
|
function DeviceIcon({ device }: { device: Device }) {
|
||||||
device,
|
const cls = `w-6 h-6`;
|
||||||
config,
|
switch (device.type) {
|
||||||
setConfig,
|
case 'printer': return <Printer className={cls} />;
|
||||||
onClose,
|
case 'drive': return <HardDrive className={cls} />;
|
||||||
onNavigate,
|
case 'network': return <Network className={cls} />;
|
||||||
hasPrev,
|
default: return <Box className={cls} />;
|
||||||
hasNext
|
}
|
||||||
}: DeviceDetailOverlayProps) {
|
}
|
||||||
const [touchStart, setTouchStart] = useState(0);
|
|
||||||
const [touchEnd, setTouchEnd] = useState(0);
|
// ── Per-device slide content ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface DeviceCardProps {
|
||||||
|
device: Device;
|
||||||
|
config: any;
|
||||||
|
setConfig: (c: any) => void;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceCard({ device, config, setConfig, isActive }: DeviceCardProps) {
|
||||||
const [browsingField, setBrowsingField] = useState<'url' | 'base_url' | 'cache' | null>(null);
|
const [browsingField, setBrowsingField] = useState<'url' | 'base_url' | 'cache' | null>(null);
|
||||||
const [showCommandMenu, setShowCommandMenu] = useState(false);
|
const [mediaSetFiles, setMediaSetFiles] = useState<MediaSetEntry[] | null>(null);
|
||||||
|
const detectTokenRef = useRef(0);
|
||||||
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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Close any open browser when swiped away.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
if (!isActive) setBrowsingField(null);
|
||||||
if (e.key === 'Escape') {
|
}, [isActive]);
|
||||||
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);
|
const getDevicePath = (): string[] => ['devices', 'iec', device.number];
|
||||||
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 getDeviceData = () => {
|
||||||
const path = getDevicePath();
|
let current: any = config;
|
||||||
let current = config;
|
for (const key of getDevicePath()) current = current?.[key];
|
||||||
for (const key of path) {
|
|
||||||
current = current?.[key];
|
|
||||||
}
|
|
||||||
return current || {};
|
return current || {};
|
||||||
};
|
};
|
||||||
|
|
||||||
const deviceData = getDeviceData();
|
const deviceData = getDeviceData();
|
||||||
|
|
||||||
const getDeviceIcon = (type: Device['type']) => {
|
const updateDeviceSetting = (path: string[], value: any) => {
|
||||||
switch (type) {
|
const newConfig = JSON.parse(JSON.stringify(config));
|
||||||
case 'printer':
|
let current = newConfig;
|
||||||
return <Printer className="w-6 h-6" />;
|
for (let i = 0; i < path.length - 1; i++) current = current[path[i]];
|
||||||
case 'drive':
|
current[path[path.length - 1]] = value;
|
||||||
return <HardDrive className="w-6 h-6" />;
|
setConfig(newConfig);
|
||||||
case 'network':
|
|
||||||
return <Network className="w-6 h-6" />;
|
|
||||||
default:
|
|
||||||
return <Box className="w-6 h-6" />;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const [mediaSetFiles, setMediaSetFiles] = useState<MediaSetEntry[] | null>(null);
|
|
||||||
const detectTokenRef = useRef(0);
|
|
||||||
|
|
||||||
// Cancel any in-flight detection and reset when navigating to a different device.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
++detectTokenRef.current;
|
++detectTokenRef.current;
|
||||||
setMediaSetFiles(null);
|
setMediaSetFiles(null);
|
||||||
}, [device.number]);
|
}, [device.number]);
|
||||||
|
|
||||||
// Sync config-backed media_set to display state whenever it is set explicitly
|
|
||||||
// (e.g. after a playlist is mounted).
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Array.isArray(deviceData.media_set) && deviceData.media_set.length > 1) {
|
if (Array.isArray(deviceData.media_set) && deviceData.media_set.length > 1) {
|
||||||
setMediaSetFiles(deviceData.media_set as MediaSetEntry[]);
|
setMediaSetFiles(deviceData.media_set as MediaSetEntry[]);
|
||||||
}
|
}
|
||||||
}, [deviceData.media_set]);
|
}, [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 detectMediaSet = async (url: string) => {
|
||||||
const token = ++detectTokenRef.current;
|
const token = ++detectTokenRef.current;
|
||||||
setMediaSetFiles(null);
|
setMediaSetFiles(null);
|
||||||
|
|
@ -152,23 +97,18 @@ export default function DeviceDetailOverlay({
|
||||||
const [, prefix, , ext] = match;
|
const [, prefix, , ext] = match;
|
||||||
const candidates: string[] = [];
|
const candidates: string[] = [];
|
||||||
for (let i = 1; i <= 10; i++) candidates.push(`${prefix}${i}${ext}`);
|
for (let i = 1; i <= 10; i++) candidates.push(`${prefix}${i}${ext}`);
|
||||||
const flags = await Promise.all(
|
const flags = await Promise.all(candidates.map(f => stat(f).then(r => r !== null).catch(() => false)));
|
||||||
candidates.map(f => stat(f).then(r => r !== null).catch(() => false))
|
|
||||||
);
|
|
||||||
if (detectTokenRef.current !== token) return;
|
if (detectTokenRef.current !== token) return;
|
||||||
const found = candidates.filter((_, i) => flags[i]);
|
const found = candidates.filter((_, i) => flags[i]);
|
||||||
setMediaSetFiles(found.length > 1 ? found : null);
|
setMediaSetFiles(found.length > 1 ? found : null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const switchMedia = (file: string) => {
|
const switchMedia = (file: string) => updateDeviceSetting([...getDevicePath(), 'url'], file);
|
||||||
const path = getDevicePath();
|
|
||||||
updateDeviceSetting([...path, 'url'], file);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isOutsideBase = (url: string, baseUrl: string): boolean => {
|
const isOutsideBase = (url: string, baseUrl: string): boolean => {
|
||||||
if (!baseUrl || !url.startsWith('/')) return false;
|
if (!baseUrl || !url.startsWith('/')) return false;
|
||||||
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/';
|
const nb = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/';
|
||||||
return url !== baseUrl && !url.startsWith(normalizedBase);
|
return url !== baseUrl && !url.startsWith(nb);
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearBaseAndCache = (dev: any) => {
|
const clearBaseAndCache = (dev: any) => {
|
||||||
|
|
@ -185,8 +125,7 @@ export default function DeviceDetailOverlay({
|
||||||
const text = await (await getFileContents(selectedPath)).text();
|
const text = await (await getFileContents(selectedPath)).text();
|
||||||
const dir = selectedPath.split('/').slice(0, -1).join('/') || '/';
|
const dir = selectedPath.split('/').slice(0, -1).join('/') || '/';
|
||||||
const candidates: MediaSetEntry[] = text.split('\n')
|
const candidates: MediaSetEntry[] = text.split('\n')
|
||||||
.map(l => l.trim())
|
.map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('#'))
|
||||||
.filter(l => l.length > 0 && !l.startsWith('#'))
|
|
||||||
.map(l => {
|
.map(l => {
|
||||||
if (isVms) {
|
if (isVms) {
|
||||||
const comma = l.indexOf(',');
|
const comma = l.indexOf(',');
|
||||||
|
|
@ -204,9 +143,8 @@ export default function DeviceDetailOverlay({
|
||||||
const files = candidates.filter((_, i) => existsArr[i]);
|
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 === 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 < 2) { toast.error('A media set needs more than one item'); return; }
|
||||||
if (files.length < candidates.length) {
|
if (files.length < candidates.length)
|
||||||
toast.warning(`${candidates.length - files.length} missing file(s) skipped from swap list`);
|
toast.warning(`${candidates.length - files.length} missing file(s) skipped from swap list`);
|
||||||
}
|
|
||||||
const newConfig = JSON.parse(JSON.stringify(config));
|
const newConfig = JSON.parse(JSON.stringify(config));
|
||||||
let dev = newConfig;
|
let dev = newConfig;
|
||||||
for (const k of devicePath) dev = dev[k];
|
for (const k of devicePath) dev = dev[k];
|
||||||
|
|
@ -229,114 +167,18 @@ export default function DeviceDetailOverlay({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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 (
|
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 flex-col items-center gap-0.5 ${
|
|
||||||
device.physical ? 'text-green-600' : deviceData.enabled ? 'text-blue-600' : 'text-neutral-400'
|
|
||||||
}`}>
|
|
||||||
<span className="text-sm font-semibold leading-none">{device.number}</span>
|
|
||||||
{getDeviceIcon(device.type)}
|
|
||||||
{device.physical && (
|
|
||||||
<span className="text-xs text-green-700 px-1 py-0.5 bg-green-50 border border-green-200 rounded leading-none">
|
|
||||||
Physical
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</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="p-4 space-y-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{!device.physical && (
|
{!device.physical && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-sm text-neutral-500">Enabled</label>
|
<label className="text-sm text-neutral-500">Enabled</label>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => updateDeviceSetting([...getDevicePath(), 'enabled'], deviceData.enabled ? 0 : 1)}
|
||||||
const path = getDevicePath();
|
className={`relative w-12 h-6 rounded-full transition-colors ${deviceData.enabled ? 'bg-blue-600' : 'bg-neutral-300'}`}
|
||||||
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
|
<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'}`} />
|
||||||
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -353,10 +195,7 @@ export default function DeviceDetailOverlay({
|
||||||
<SettingsInput
|
<SettingsInput
|
||||||
type="text"
|
type="text"
|
||||||
value={deviceData.name || device.name || `Device ${device.number}`}
|
value={deviceData.name || device.name || `Device ${device.number}`}
|
||||||
onCommit={(v) => {
|
onCommit={(v) => updateDeviceSetting([...getDevicePath(), 'name'], v)}
|
||||||
const path = getDevicePath();
|
|
||||||
updateDeviceSetting([...path, 'name'], v);
|
|
||||||
}}
|
|
||||||
onClear={() => updateDeviceSetting([...getDevicePath(), 'name'], '')}
|
onClear={() => updateDeviceSetting([...getDevicePath(), 'name'], '')}
|
||||||
className="px-3 py-2 border border-neutral-300 rounded-lg"
|
className="px-3 py-2 border border-neutral-300 rounded-lg"
|
||||||
containerClassName="w-full"
|
containerClassName="w-full"
|
||||||
|
|
@ -370,10 +209,7 @@ export default function DeviceDetailOverlay({
|
||||||
<SettingsInput
|
<SettingsInput
|
||||||
type="text"
|
type="text"
|
||||||
value={deviceData.base_url ?? ''}
|
value={deviceData.base_url ?? ''}
|
||||||
onCommit={(v) => {
|
onCommit={(v) => updateDeviceSetting([...getDevicePath(), 'base_url'], v)}
|
||||||
const path = getDevicePath();
|
|
||||||
updateDeviceSetting([...path, 'base_url'], v);
|
|
||||||
}}
|
|
||||||
onClear={() => updateDeviceSetting([...getDevicePath(), 'base_url'], '')}
|
onClear={() => updateDeviceSetting([...getDevicePath(), 'base_url'], '')}
|
||||||
containerClassName="flex-1"
|
containerClassName="flex-1"
|
||||||
className="px-3 py-2 border border-neutral-300 rounded-lg"
|
className="px-3 py-2 border border-neutral-300 rounded-lg"
|
||||||
|
|
@ -381,7 +217,6 @@ export default function DeviceDetailOverlay({
|
||||||
<button
|
<button
|
||||||
onClick={() => setBrowsingField('base_url')}
|
onClick={() => setBrowsingField('base_url')}
|
||||||
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
|
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
|
||||||
title="Browse"
|
|
||||||
>
|
>
|
||||||
<FolderOpen className="w-5 h-5" />
|
<FolderOpen className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -422,12 +257,10 @@ export default function DeviceDetailOverlay({
|
||||||
<button
|
<button
|
||||||
onClick={() => setBrowsingField('url')}
|
onClick={() => setBrowsingField('url')}
|
||||||
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
|
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
|
||||||
title="Browse"
|
|
||||||
>
|
>
|
||||||
<FolderOpen className="w-5 h-5" />
|
<FolderOpen className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mediaSetFiles && (
|
{mediaSetFiles && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<MediaSet files={mediaSetFiles} activeUrl={deviceData.url ?? ''} onSwitch={switchMedia} />
|
<MediaSet files={mediaSetFiles} activeUrl={deviceData.url ?? ''} onSwitch={switchMedia} />
|
||||||
|
|
@ -444,10 +277,7 @@ export default function DeviceDetailOverlay({
|
||||||
<SettingsInput
|
<SettingsInput
|
||||||
type="text"
|
type="text"
|
||||||
value={deviceData.cache ?? ''}
|
value={deviceData.cache ?? ''}
|
||||||
onCommit={(v) => {
|
onCommit={(v) => updateDeviceSetting([...getDevicePath(), 'cache'], v)}
|
||||||
const path = getDevicePath();
|
|
||||||
updateDeviceSetting([...path, 'cache'], v);
|
|
||||||
}}
|
|
||||||
onClear={() => updateDeviceSetting([...getDevicePath(), 'cache'], '')}
|
onClear={() => updateDeviceSetting([...getDevicePath(), 'cache'], '')}
|
||||||
containerClassName="flex-1"
|
containerClassName="flex-1"
|
||||||
className="px-3 py-2 border border-neutral-300 rounded-lg"
|
className="px-3 py-2 border border-neutral-300 rounded-lg"
|
||||||
|
|
@ -455,7 +285,6 @@ export default function DeviceDetailOverlay({
|
||||||
<button
|
<button
|
||||||
onClick={() => setBrowsingField('cache')}
|
onClick={() => setBrowsingField('cache')}
|
||||||
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
|
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
|
||||||
title="Browse"
|
|
||||||
>
|
>
|
||||||
<FolderOpen className="w-5 h-5" />
|
<FolderOpen className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -468,19 +297,13 @@ export default function DeviceDetailOverlay({
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-sm text-neutral-500">Mode</label>
|
<label className="text-sm text-neutral-500">Mode</label>
|
||||||
{device.physical
|
{device.physical
|
||||||
? <span className="text-sm text-neutral-700 px-3 py-2">
|
? <span className="text-sm text-neutral-700 px-3 py-2">{(deviceData.mode ?? 0) === 0 ? 'Read Only' : 'Write Enabled'}</span>
|
||||||
{(deviceData.mode ?? 0) === 0 ? 'Read Only' : 'Write Enabled'}
|
|
||||||
</span>
|
|
||||||
: <div className="flex rounded-lg border border-neutral-300 overflow-hidden text-sm">
|
: <div className="flex rounded-lg border border-neutral-300 overflow-hidden text-sm">
|
||||||
{([0, 1] as const).map((val, i) => (
|
{([0, 1] as const).map((val, i) => (
|
||||||
<button
|
<button
|
||||||
key={val}
|
key={val}
|
||||||
onClick={() => updateDeviceSetting([...getDevicePath(), 'mode'], val)}
|
onClick={() => updateDeviceSetting([...getDevicePath(), 'mode'], val)}
|
||||||
className={`px-4 py-2 ${i > 0 ? 'border-l border-neutral-300' : ''} ${
|
className={`px-4 py-2 ${i > 0 ? 'border-l border-neutral-300' : ''} ${(deviceData.mode ?? 0) === val ? 'bg-blue-600 text-white' : 'bg-white text-neutral-700 hover:bg-neutral-50'}`}
|
||||||
(deviceData.mode ?? 0) === val
|
|
||||||
? 'bg-blue-600 text-white'
|
|
||||||
: 'bg-white text-neutral-700 hover:bg-neutral-50'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{val === 0 ? 'Read Only' : 'Write Enabled'}
|
{val === 0 ? 'Read Only' : 'Write Enabled'}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -496,10 +319,7 @@ export default function DeviceDetailOverlay({
|
||||||
<SettingsInput
|
<SettingsInput
|
||||||
type="number"
|
type="number"
|
||||||
value={String(deviceData.baud ?? '')}
|
value={String(deviceData.baud ?? '')}
|
||||||
onCommit={(v) => {
|
onCommit={(v) => updateDeviceSetting([...getDevicePath(), 'baud'], parseInt(v))}
|
||||||
const path = getDevicePath();
|
|
||||||
updateDeviceSetting([...path, 'baud'], parseInt(v));
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -508,9 +328,7 @@ export default function DeviceDetailOverlay({
|
||||||
|
|
||||||
<div className="pt-4 border-t border-neutral-200">
|
<div className="pt-4 border-t border-neutral-200">
|
||||||
<h3 className="text-sm text-neutral-500 mb-2">Device ID</h3>
|
<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">
|
<code className="text-xs text-neutral-600 bg-neutral-50 px-2 py-1 rounded">{device.id}</code>
|
||||||
{device.id}
|
|
||||||
</code>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -525,14 +343,135 @@ export default function DeviceDetailOverlay({
|
||||||
if (browsingField === 'url') {
|
if (browsingField === 'url') {
|
||||||
void handleFileSelect(selectedPath);
|
void handleFileSelect(selectedPath);
|
||||||
} else {
|
} else {
|
||||||
const devPath = getDevicePath();
|
updateDeviceSetting([...getDevicePath(), browsingField], selectedPath);
|
||||||
updateDeviceSetting([...devPath, browsingField], selectedPath);
|
|
||||||
}
|
}
|
||||||
setBrowsingField(null);
|
setBrowsingField(null);
|
||||||
}}
|
}}
|
||||||
onClose={() => setBrowsingField(null)}
|
onClose={() => setBrowsingField(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Overlay shell with Swiper ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function DeviceDetailOverlay({
|
||||||
|
devices,
|
||||||
|
initialIndex,
|
||||||
|
config,
|
||||||
|
setConfig,
|
||||||
|
onClose,
|
||||||
|
onIndexChange,
|
||||||
|
}: DeviceDetailOverlayProps) {
|
||||||
|
const [activeIndex, setActiveIndex] = useState(initialIndex);
|
||||||
|
const [showCommandMenu, setShowCommandMenu] = useState(false);
|
||||||
|
const swiperRef = useRef<SwiperType | null>(null);
|
||||||
|
|
||||||
|
const activeDevice = devices[activeIndex] ?? devices[0];
|
||||||
|
|
||||||
|
useEffect(() => { setShowCommandMenu(false); }, [activeIndex]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
<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 flex flex-col z-50"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div className="flex-shrink-0 border-b border-neutral-200 relative">
|
||||||
|
<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 flex-col items-center gap-0.5 ${
|
||||||
|
activeDevice.physical ? 'text-green-600' : activeDevice.enabled ? 'text-blue-600' : 'text-neutral-400'
|
||||||
|
}`}>
|
||||||
|
<span className="text-sm font-semibold leading-none">{activeDevice.number}</span>
|
||||||
|
<DeviceIcon device={activeDevice} />
|
||||||
|
{activeDevice.physical && (
|
||||||
|
<span className="text-xs text-green-700 px-1 py-0.5 bg-green-50 border border-green-200 rounded leading-none">
|
||||||
|
Physical
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{devices.length > 1 && (
|
||||||
|
<span className="text-xs text-neutral-400 tabular-nums">
|
||||||
|
{activeIndex + 1} / {devices.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button onClick={() => setShowCommandMenu(v => !v)} className="p-2 -m-2">
|
||||||
|
<MoreVertical className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCommandMenu && (
|
||||||
|
<div className="absolute right-4 top-14 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[200px] z-20">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
console.log(`Reset device ${activeDevice.number}`);
|
||||||
|
setShowCommandMenu(false);
|
||||||
|
}}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* ── 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: 'auto', height: '100%' }}>
|
||||||
|
<DeviceCard
|
||||||
|
device={device}
|
||||||
|
config={config}
|
||||||
|
setConfig={setConfig}
|
||||||
|
isActive={i === activeIndex}
|
||||||
|
/>
|
||||||
|
</SwiperSlide>
|
||||||
|
))}
|
||||||
|
</Swiper>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
|
||||||
|
|
@ -201,13 +201,6 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
|
||||||
|
|
||||||
const handleCloseOverlay = () => setSelectedDeviceIndex(null);
|
const handleCloseOverlay = () => setSelectedDeviceIndex(null);
|
||||||
|
|
||||||
const handleNavigate = (direction: 'prev' | 'next') => {
|
|
||||||
if (selectedDeviceIndex === null) return;
|
|
||||||
if (direction === 'prev' && selectedDeviceIndex > 0)
|
|
||||||
setSelectedDeviceIndex(selectedDeviceIndex - 1);
|
|
||||||
else if (direction === 'next' && selectedDeviceIndex < displayDevices.length - 1)
|
|
||||||
setSelectedDeviceIndex(selectedDeviceIndex + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const { send: wsSend } = useWs();
|
const { send: wsSend } = useWs();
|
||||||
|
|
||||||
|
|
@ -414,13 +407,12 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
|
||||||
|
|
||||||
{selectedDeviceIndex !== null && (
|
{selectedDeviceIndex !== null && (
|
||||||
<DeviceDetailOverlay
|
<DeviceDetailOverlay
|
||||||
device={displayDevices[selectedDeviceIndex]}
|
devices={displayDevices}
|
||||||
|
initialIndex={selectedDeviceIndex}
|
||||||
config={config}
|
config={config}
|
||||||
setConfig={setConfig}
|
setConfig={setConfig}
|
||||||
onClose={handleCloseOverlay}
|
onClose={handleCloseOverlay}
|
||||||
onNavigate={handleNavigate}
|
onIndexChange={setSelectedDeviceIndex}
|
||||||
hasPrev={selectedDeviceIndex > 0}
|
|
||||||
hasNext={selectedDeviceIndex < displayDevices.length - 1}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
Save,
|
Save,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Terminal,
|
Terminal,
|
||||||
|
AppWindow,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { humanFileSize, type EntryInfo } from '../webdav';
|
import { humanFileSize, type EntryInfo } from '../webdav';
|
||||||
|
|
||||||
|
|
@ -42,6 +43,7 @@ export const DISC_EXTS = new Set(['iso', 'img', 'cue']);
|
||||||
export const HD_EXTS = new Set(['d1m', 'd2m', 'd4m', 'd90', 'dhd', 'hdd', 'bbt', 'd8b', 'dfi']);
|
export const HD_EXTS = new Set(['d1m', 'd2m', 'd4m', 'd90', 'dhd', 'hdd', 'bbt', 'd8b', 'dfi']);
|
||||||
export const ARCHIVE_EXTS = new Set(['zip', '7z', 'tar', 'gz', 'bz2', 'xz', 'rar', 'arj', 'lzh', 'ace', 'z', 'lha', 'cab', 'lbr', 'arc', 'ark', 'lnx']);
|
export const ARCHIVE_EXTS = new Set(['zip', '7z', 'tar', 'gz', 'bz2', 'xz', 'rar', 'arj', 'lzh', 'ace', 'z', 'lha', 'cab', 'lbr', 'arc', 'ark', 'lnx']);
|
||||||
export const COMIC_EXTS = new Set(['cbz', 'cbr', 'cb7', 'cbt', 'cbz']);
|
export const COMIC_EXTS = new Set(['cbz', 'cbr', 'cb7', 'cbt', 'cbz']);
|
||||||
|
export const EXEC_EXTS = new Set(['exe', 'com', 'bat', 'cmd', 'msi', 'app', 'apk', 'bin', 'run', 'sh', 'vbs', 'ps1']);
|
||||||
export const CONFIG_EXTS = new Set(['config']);
|
export const CONFIG_EXTS = new Set(['config']);
|
||||||
|
|
||||||
// ─── EntryIcon ────────────────────────────────────────────────────────────────
|
// ─── EntryIcon ────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -66,6 +68,7 @@ export function EntryIcon({ entry }: { entry: EntryInfo }) {
|
||||||
if (CODE_EXTS.has(ext)) return <Terminal className="w-5 h-5 text-green-600 flex-shrink-0" />;
|
if (CODE_EXTS.has(ext)) return <Terminal className="w-5 h-5 text-green-600 flex-shrink-0" />;
|
||||||
if (TEXT_EXTS.has(ext)) return <FileText className="w-5 h-5 text-green-600 flex-shrink-0" />;
|
if (TEXT_EXTS.has(ext)) return <FileText className="w-5 h-5 text-green-600 flex-shrink-0" />;
|
||||||
if (COMIC_EXTS.has(ext)) return <BookImage className="w-5 h-5 text-pink-500 flex-shrink-0" />;
|
if (COMIC_EXTS.has(ext)) return <BookImage className="w-5 h-5 text-pink-500 flex-shrink-0" />;
|
||||||
|
if (EXEC_EXTS.has(ext)) return <AppWindow className="w-5 h-5 text-red-400 flex-shrink-0" />;
|
||||||
return <File className="w-5 h-5 text-neutral-400 flex-shrink-0" />;
|
return <File className="w-5 h-5 text-neutral-400 flex-shrink-0" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { X, Search, HardDrive, Loader2 } from 'lucide-react';
|
import { flushSync } from 'react-dom';
|
||||||
|
import { X, Search, HardDrive, Loader2, RefreshCw } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { humanFileSize } from '../webdav';
|
||||||
import {
|
import {
|
||||||
humanFileSize,
|
openLocateDb,
|
||||||
listDirectory,
|
searchLocate,
|
||||||
type EntryInfo,
|
isLocateDbLoaded,
|
||||||
} from '../webdav';
|
resetLocateDb,
|
||||||
|
type LocateEntry,
|
||||||
|
} from '../locate-db';
|
||||||
|
|
||||||
interface SearchOverlayProps {
|
interface SearchOverlayProps {
|
||||||
config: any;
|
config: any;
|
||||||
|
|
@ -28,16 +32,14 @@ const HARDWARE_FILE_EXTS = new Set([
|
||||||
|
|
||||||
function fileExtension(p: string): string {
|
function fileExtension(p: string): string {
|
||||||
const dot = p.lastIndexOf('.');
|
const dot = p.lastIndexOf('.');
|
||||||
if (dot < 0) return '';
|
return dot < 0 ? '' : p.slice(dot + 1).toLowerCase();
|
||||||
return p.slice(dot + 1).toLowerCase();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectType(entry: EntryInfo): string {
|
function entryToResult(e: LocateEntry): SearchResult {
|
||||||
if (entry.type === 'folder') return 'DIR';
|
if (e.is_dir) return { name: e.name, path: e.path, type: 'DIR', size: e.size, sizeText: '—' };
|
||||||
const ext = fileExtension(entry.name);
|
const ext = fileExtension(e.name);
|
||||||
if (!ext) return 'FILE';
|
const type = ext ? (HARDWARE_FILE_EXTS.has(ext) ? ext.toUpperCase() : ext.toUpperCase()) : 'FILE';
|
||||||
if (HARDWARE_FILE_EXTS.has(ext)) return ext.toUpperCase();
|
return { name: e.name, path: e.path, type, size: e.size, sizeText: humanFileSize(e.size) };
|
||||||
return ext.toUpperCase();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SearchOverlay({ config, setConfig, onClose }: SearchOverlayProps) {
|
export default function SearchOverlay({ config, setConfig, onClose }: SearchOverlayProps) {
|
||||||
|
|
@ -50,75 +52,61 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
||||||
const [hasSearched, setHasSearched] = useState(false);
|
const [hasSearched, setHasSearched] = useState(false);
|
||||||
const [showDeviceMenu, setShowDeviceMenu] = useState<number | null>(null);
|
const [showDeviceMenu, setShowDeviceMenu] = useState<number | null>(null);
|
||||||
const [searchError, setSearchError] = useState<string | null>(null);
|
const [searchError, setSearchError] = useState<string | null>(null);
|
||||||
|
const [dbBytes, setDbBytes] = useState<number | null>(null);
|
||||||
|
const [dbPhase, setDbPhase] = useState<'idle' | 'downloading' | 'ready'>('idle');
|
||||||
|
|
||||||
|
void systemType; void videoStandard; void language;
|
||||||
|
|
||||||
|
// Show "ready" phase if DB was already loaded from a previous search.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLocateDbLoaded()) setDbPhase('ready');
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSearch = async () => {
|
const handleSearch = async () => {
|
||||||
if (!query.trim()) {
|
if (!query.trim()) { toast.error('Please enter a search term'); return; }
|
||||||
toast.error('Please enter a search term');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
setHasSearched(true);
|
setHasSearched(true);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setSearchError(null);
|
setSearchError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const found: SearchResult[] = [];
|
if (!isLocateDbLoaded()) {
|
||||||
const needle = query.trim().toLowerCase();
|
setDbPhase('downloading');
|
||||||
const max = 500; // safety cap
|
setDbBytes(null);
|
||||||
|
await openLocateDb(bytes => flushSync(() => setDbBytes(bytes)));
|
||||||
// BFS through the WebDAV tree.
|
setDbPhase('ready');
|
||||||
const queue: string[] = ['/'];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
while (queue.length > 0 && found.length < max) {
|
|
||||||
const dir = queue.shift()!;
|
|
||||||
if (seen.has(dir)) continue;
|
|
||||||
seen.add(dir);
|
|
||||||
let items;
|
|
||||||
try {
|
|
||||||
items = await listDirectory(dir);
|
|
||||||
} catch {
|
|
||||||
// Skip directories we cannot read (permission/404/etc.) and keep going.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (const it of items) {
|
|
||||||
if (it.type === 'folder') {
|
|
||||||
queue.push(it.path);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
it.name.toLowerCase().includes(needle) ||
|
|
||||||
it.path.toLowerCase().includes(needle)
|
|
||||||
) {
|
|
||||||
found.push({
|
|
||||||
name: it.name,
|
|
||||||
path: it.path,
|
|
||||||
type: detectType(it),
|
|
||||||
size: it.size,
|
|
||||||
sizeText: humanFileSize(it.size),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort results: closest match by name first, then by path length, then alpha.
|
const needle = query.trim();
|
||||||
found.sort((a, b) => {
|
const entries = searchLocate(needle);
|
||||||
const an = a.name.toLowerCase();
|
|
||||||
const bn = b.name.toLowerCase();
|
// Sort: name starts with query first, then by path depth (shorter = closer to root).
|
||||||
const aStarts = an.startsWith(needle) ? 0 : 1;
|
const lower = needle.toLowerCase();
|
||||||
const bStarts = bn.startsWith(needle) ? 0 : 1;
|
entries.sort((a, b) => {
|
||||||
if (aStarts !== bStarts) return aStarts - bStarts;
|
const aStart = a.name.toLowerCase().startsWith(lower) ? 0 : 1;
|
||||||
if (a.path.length !== b.path.length) return a.path.length - b.path.length;
|
const bStart = b.name.toLowerCase().startsWith(lower) ? 0 : 1;
|
||||||
return an.localeCompare(bn);
|
if (aStart !== bStart) return aStart - bStart;
|
||||||
|
return a.path.length - b.path.length;
|
||||||
});
|
});
|
||||||
|
|
||||||
setResults(found);
|
setResults(entries.map(entryToResult));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setSearchError((e && e.message) || 'Search failed');
|
setSearchError(e?.message ?? 'Search failed');
|
||||||
|
resetLocateDb();
|
||||||
|
setDbPhase('idle');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRefreshDb = async () => {
|
||||||
|
resetLocateDb();
|
||||||
|
setDbPhase('idle');
|
||||||
|
setHasSearched(false);
|
||||||
|
setResults([]);
|
||||||
|
toast.info('Database will be reloaded on next search');
|
||||||
|
};
|
||||||
|
|
||||||
const handleMount = (deviceNum: string, result: SearchResult) => {
|
const handleMount = (deviceNum: string, result: SearchResult) => {
|
||||||
const newConfig = JSON.parse(JSON.stringify(config));
|
const newConfig = JSON.parse(JSON.stringify(config));
|
||||||
if (newConfig.devices?.iec?.[deviceNum]) {
|
if (newConfig.devices?.iec?.[deviceNum]) {
|
||||||
|
|
@ -142,12 +130,12 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
||||||
return devices;
|
return devices;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Suppress unused-var warnings for fields the UI exposes but doesn't yet
|
|
||||||
// map to a real filter (the WebDAV server doesn't carry these as metadata).
|
|
||||||
void systemType; void videoStandard; void language;
|
|
||||||
|
|
||||||
const availableDevices = getAvailableDevices();
|
const availableDevices = getAvailableDevices();
|
||||||
|
|
||||||
|
const loadingLabel = dbPhase === 'downloading'
|
||||||
|
? (dbBytes === null ? 'Loading database…' : `Loading database… ${humanFileSize(dbBytes)}`)
|
||||||
|
: 'Searching…';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
@ -168,10 +156,21 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
||||||
<div className="p-4 sm:p-6 flex-1 flex flex-col">
|
<div className="p-4 sm:p-6 flex-1 flex flex-col">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h2 className="text-xl font-medium">Search</h2>
|
<h2 className="text-xl font-medium">Search</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{dbPhase === 'ready' && (
|
||||||
|
<button
|
||||||
|
onClick={handleRefreshDb}
|
||||||
|
className="p-2 -m-2 hover:bg-neutral-100 rounded-lg text-neutral-400"
|
||||||
|
title="Reload database"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button onClick={onClose} className="p-2 -m-2 hover:bg-neutral-100 rounded-lg">
|
<button onClick={onClose} className="p-2 -m-2 hover:bg-neutral-100 rounded-lg">
|
||||||
<X className="w-6 h-6" />
|
<X className="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|
@ -198,11 +197,7 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
||||||
<div className="grid grid-cols-3 gap-3 mb-6">
|
<div className="grid grid-cols-3 gap-3 mb-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-neutral-500 block mb-1">System Type</label>
|
<label className="text-sm text-neutral-500 block mb-1">System Type</label>
|
||||||
<select
|
<select value={systemType} onChange={(e) => setSystemType(e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg text-sm">
|
||||||
value={systemType}
|
|
||||||
onChange={(e) => setSystemType(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg text-sm"
|
|
||||||
>
|
|
||||||
<option value="all">All Systems</option>
|
<option value="all">All Systems</option>
|
||||||
<option value="c64">C64</option>
|
<option value="c64">C64</option>
|
||||||
<option value="c128">C128</option>
|
<option value="c128">C128</option>
|
||||||
|
|
@ -212,11 +207,7 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-neutral-500 block mb-1">Video Standard</label>
|
<label className="text-sm text-neutral-500 block mb-1">Video Standard</label>
|
||||||
<select
|
<select value={videoStandard} onChange={(e) => setVideoStandard(e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg text-sm">
|
||||||
value={videoStandard}
|
|
||||||
onChange={(e) => setVideoStandard(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg text-sm"
|
|
||||||
>
|
|
||||||
<option value="all">All Standards</option>
|
<option value="all">All Standards</option>
|
||||||
<option value="ntsc">NTSC</option>
|
<option value="ntsc">NTSC</option>
|
||||||
<option value="pal">PAL</option>
|
<option value="pal">PAL</option>
|
||||||
|
|
@ -224,11 +215,7 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-neutral-500 block mb-1">Language</label>
|
<label className="text-sm text-neutral-500 block mb-1">Language</label>
|
||||||
<select
|
<select value={language} onChange={(e) => setLanguage(e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg text-sm">
|
||||||
value={language}
|
|
||||||
onChange={(e) => setLanguage(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg text-sm"
|
|
||||||
>
|
|
||||||
<option value="all">All Languages</option>
|
<option value="all">All Languages</option>
|
||||||
<option value="en">English</option>
|
<option value="en">English</option>
|
||||||
<option value="es">Spanish</option>
|
<option value="es">Spanish</option>
|
||||||
|
|
@ -241,14 +228,12 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
||||||
{isSearching && (
|
{isSearching && (
|
||||||
<div className="flex flex-col items-center justify-center py-12">
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
<Loader2 className="w-12 h-12 text-blue-600 animate-spin mb-4" />
|
<Loader2 className="w-12 h-12 text-blue-600 animate-spin mb-4" />
|
||||||
<div className="text-neutral-600">Searching…</div>
|
<div className="text-neutral-600">{loadingLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isSearching && searchError && (
|
{!isSearching && searchError && (
|
||||||
<div className="text-center py-12 text-red-600">
|
<div className="text-center py-12 text-red-600">Search failed: {searchError}</div>
|
||||||
Search failed: {searchError}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isSearching && !searchError && hasSearched && (
|
{!isSearching && !searchError && hasSearched && (
|
||||||
|
|
@ -295,9 +280,7 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{availableDevices.length === 0 && (
|
{availableDevices.length === 0 && (
|
||||||
<div className="px-4 py-2 text-sm text-neutral-500">
|
<div className="px-4 py-2 text-sm text-neutral-500">No enabled devices</div>
|
||||||
No enabled devices
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -317,7 +300,10 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
||||||
{!hasSearched && (
|
{!hasSearched && (
|
||||||
<div className="text-center py-12 text-neutral-400">
|
<div className="text-center py-12 text-neutral-400">
|
||||||
<Search className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
<Search className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||||
<div>Enter a search term to find files on the WebDAV server</div>
|
<div>Enter a search term to find files on the device</div>
|
||||||
|
{dbPhase === 'ready' && (
|
||||||
|
<div className="text-xs mt-2 text-green-600">Database loaded</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -249,21 +249,19 @@ export default function StatusPage({ config, setConfig, onOpenFileManager }: Sta
|
||||||
|
|
||||||
{showDeviceOverlay && (
|
{showDeviceOverlay && (
|
||||||
<DeviceDetailOverlay
|
<DeviceDetailOverlay
|
||||||
device={{
|
devices={[{
|
||||||
id: `drive-${activeDevice.number}`,
|
id: `drive-${activeDevice.number}`,
|
||||||
number: activeDevice.number,
|
number: activeDevice.number,
|
||||||
type: 'drive',
|
type: 'drive',
|
||||||
name: activeDevice.name,
|
name: activeDevice.name,
|
||||||
enabled: activeDevice.enabled,
|
enabled: activeDevice.enabled,
|
||||||
url: activeDevice.url,
|
url: activeDevice.url,
|
||||||
mode: activeDevice.mode
|
mode: activeDevice.mode,
|
||||||
}}
|
}]}
|
||||||
|
initialIndex={0}
|
||||||
config={config}
|
config={config}
|
||||||
setConfig={setConfig}
|
setConfig={setConfig}
|
||||||
onClose={() => setShowDeviceOverlay(false)}
|
onClose={() => setShowDeviceOverlay(false)}
|
||||||
onNavigate={() => {}}
|
|
||||||
hasPrev={false}
|
|
||||||
hasNext={false}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
110
src/app/locate-db.ts
Normal file
110
src/app/locate-db.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
|
||||||
|
import { getWebDAVBaseUrl, basename } from './webdav';
|
||||||
|
|
||||||
|
const LOCATE_PATH = '/sd/.locate';
|
||||||
|
|
||||||
|
// Memoize the module init — loading the WASM binary is expensive.
|
||||||
|
let _sqlite3Promise: Promise<any> | null = null;
|
||||||
|
function getSqlite3(): Promise<any> {
|
||||||
|
if (!_sqlite3Promise) {
|
||||||
|
_sqlite3Promise = sqlite3InitModule({ print: () => {}, printErr: () => {} });
|
||||||
|
}
|
||||||
|
return _sqlite3Promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _db: any | null = null;
|
||||||
|
|
||||||
|
export function isLocateDbLoaded(): boolean {
|
||||||
|
return _db !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetLocateDb(): void {
|
||||||
|
try { _db?.close(); } catch { /* ignore */ }
|
||||||
|
_db = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch /sd/.locate and open it as an in-memory SQLite database.
|
||||||
|
* Calling again when already loaded is a no-op unless you call resetLocateDb() first.
|
||||||
|
* onProgress receives raw bytes received so far during the download.
|
||||||
|
*/
|
||||||
|
export async function openLocateDb(onProgress?: (bytes: number) => void): Promise<void> {
|
||||||
|
if (_db) return;
|
||||||
|
|
||||||
|
const sqlite3 = await getSqlite3();
|
||||||
|
|
||||||
|
const url = getWebDAVBaseUrl() + LOCATE_PATH;
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) throw new Error(`Cannot fetch locate database: ${response.status} ${response.statusText}`);
|
||||||
|
|
||||||
|
// Stream the response body so onProgress gets called per chunk.
|
||||||
|
let bytes: Uint8Array;
|
||||||
|
if (response.body) {
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let received = 0;
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
chunks.push(value);
|
||||||
|
received += value.byteLength;
|
||||||
|
onProgress?.(received);
|
||||||
|
}
|
||||||
|
const combined = new Uint8Array(received);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.byteLength; }
|
||||||
|
bytes = combined;
|
||||||
|
} else {
|
||||||
|
bytes = new Uint8Array(await response.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate the bytes in WASM heap, then deserialize into a fresh in-memory DB.
|
||||||
|
const p = sqlite3.wasm.allocFromTypedArray(bytes);
|
||||||
|
const db = new sqlite3.oo1.DB(':memory:', 'ct');
|
||||||
|
const rc = sqlite3.capi.sqlite3_deserialize(
|
||||||
|
db.pointer,
|
||||||
|
'main',
|
||||||
|
p,
|
||||||
|
bytes.length,
|
||||||
|
bytes.length,
|
||||||
|
1 | 2, // SQLITE_DESERIALIZE_FREEONCLOSE | SQLITE_DESERIALIZE_RESIZEABLE
|
||||||
|
);
|
||||||
|
if (rc !== 0) {
|
||||||
|
db.close();
|
||||||
|
throw new Error(`sqlite3_deserialize failed (code ${rc})`);
|
||||||
|
}
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocateEntry {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
mtime: number;
|
||||||
|
is_dir: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a case-insensitive substring search against the loaded locate database.
|
||||||
|
* openLocateDb() must have been called (and resolved) before calling this.
|
||||||
|
*/
|
||||||
|
export function searchLocate(query: string, limit = 500): LocateEntry[] {
|
||||||
|
if (!_db) throw new Error('Locate database is not loaded');
|
||||||
|
const needle = `%${query}%`;
|
||||||
|
const rows: LocateEntry[] = [];
|
||||||
|
_db.exec({
|
||||||
|
sql: 'SELECT path, size, mtime, is_dir FROM files WHERE path LIKE ? LIMIT ?',
|
||||||
|
bind: [needle, limit],
|
||||||
|
rowMode: 'array',
|
||||||
|
callback: (row: any[]) => {
|
||||||
|
rows.push({
|
||||||
|
path: row[0] as string,
|
||||||
|
name: basename(row[0] as string) || (row[0] as string),
|
||||||
|
size: row[1] as number,
|
||||||
|
mtime: row[2] as number,
|
||||||
|
is_dir: (row[3] as number) !== 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
@ -27,10 +27,12 @@ export default defineConfig({
|
||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
// Alias @ to the src directory
|
|
||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ['@sqlite.org/sqlite-wasm'],
|
||||||
|
},
|
||||||
|
|
||||||
// File types to support raw imports. Never add .css, .tsx, or .ts files to this.
|
// File types to support raw imports. Never add .css, .tsx, or .ts files to this.
|
||||||
assetsInclude: ['**/*.svg', '**/*.csv'],
|
assetsInclude: ['**/*.svg', '**/*.csv'],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user