Compare commits
5 Commits
f280ad2ee9
...
afd7156a36
| Author | SHA1 | Date | |
|---|---|---|---|
| afd7156a36 | |||
| 588895d851 | |||
| 30c96b8428 | |||
| 76962cc9bd | |||
| c7ad02dbe4 |
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Search, Loader2, HardDrive, ChevronRight, ChevronDown, Trophy, Calendar, Users, RefreshCw, HelpCircle, X, SlidersHorizontal, MoreVertical, FolderOpen } from 'lucide-react';
|
import { Search, Loader2, HardDrive, ChevronRight, ChevronDown, Trophy, Calendar, Users, RefreshCw, HelpCircle, X, SlidersHorizontal, MoreVertical, FolderOpen } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { humanFileSize, basename, joinPath, putFileContents } from '../webdav';
|
import { humanFileSize, basename, clearDirectory, joinPath, putFileContents, streamFetch, type FetchProgress } from '../webdav';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
||||||
import { MarqueeText } from './ui/marquee-text';
|
import { MarqueeText } from './ui/marquee-text';
|
||||||
import { EntryIcon } from './MediaEntry';
|
import { EntryIcon } from './MediaEntry';
|
||||||
|
|
@ -10,7 +10,7 @@ import { EntryIcon } from './MediaEntry';
|
||||||
|
|
||||||
const LEET_BASE = 'https://hackerswithstyle.se/leet';
|
const LEET_BASE = 'https://hackerswithstyle.se/leet';
|
||||||
const PAGE_SIZE = 1000;
|
const PAGE_SIZE = 1000;
|
||||||
const DOWNLOAD_DIR = '/sd/downloads';
|
const DOWNLOAD_DIR = '/sd/downloads/a64';
|
||||||
|
|
||||||
function leetFetch(path: string, query?: Record<string, string>) {
|
function leetFetch(path: string, query?: Record<string, string>) {
|
||||||
const url = new URL(LEET_BASE + path);
|
const url = new URL(LEET_BASE + path);
|
||||||
|
|
@ -175,6 +175,7 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
|
||||||
const [actionItem, setActionItem] = useState<ContentItem | null>(null);
|
const [actionItem, setActionItem] = useState<ContentItem | null>(null);
|
||||||
const [mountEntry, setMountEntry] = useState<{ item: ContentItem; entry: ContentEntry } | null>(null);
|
const [mountEntry, setMountEntry] = useState<{ item: ContentItem; entry: ContentEntry } | null>(null);
|
||||||
const [isMounting, setIsMounting] = useState(false);
|
const [isMounting, setIsMounting] = useState(false);
|
||||||
|
const [mountProgress, setMountProgress] = useState<FetchProgress & { phase: 'fetching' | 'saving' | 'image' } | null>(null);
|
||||||
|
|
||||||
const [showFilter, setShowFilter] = useState(() => _store.showFilter);
|
const [showFilter, setShowFilter] = useState(() => _store.showFilter);
|
||||||
const [filterText, setFilterText] = useState(() => _store.filterText);
|
const [filterText, setFilterText] = useState(() => _store.filterText);
|
||||||
|
|
@ -311,12 +312,29 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
|
||||||
if (!mountEntry) return;
|
if (!mountEntry) return;
|
||||||
const { item, entry } = mountEntry;
|
const { item, entry } = mountEntry;
|
||||||
setIsMounting(true);
|
setIsMounting(true);
|
||||||
|
setMountProgress({ received: 0, total: 0, phase: 'fetching' });
|
||||||
try {
|
try {
|
||||||
|
await clearDirectory(DOWNLOAD_DIR);
|
||||||
const res = await leetFetch(`/search/bin/${item.id}/${item.category}/${entry.id}`);
|
const res = await leetFetch(`/search/bin/${item.id}/${item.category}/${entry.id}`);
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const data = await res.arrayBuffer();
|
const data = await streamFetch(res, p => setMountProgress({ ...p, phase: 'fetching' }));
|
||||||
const dest = joinPath(DOWNLOAD_DIR, entryFilename(entry));
|
const mainFname = entryFilename(entry);
|
||||||
|
const dest = joinPath(DOWNLOAD_DIR, mainFname);
|
||||||
|
setMountProgress(p => p && { ...p, phase: 'saving' });
|
||||||
await putFileContents(dest, data);
|
await putFileContents(dest, data);
|
||||||
|
const imgEntry = (entries ?? []).find(e => e.id !== entry.id && /\.(png|jpg|jpeg|gif|bmp)$/i.test(e.path));
|
||||||
|
if (imgEntry) {
|
||||||
|
setMountProgress(p => p && { ...p, phase: 'image' });
|
||||||
|
try {
|
||||||
|
const imgRes = await leetFetch(`/search/bin/${item.id}/${item.category}/${imgEntry.id}`);
|
||||||
|
if (imgRes.ok) {
|
||||||
|
const imgData = await imgRes.arrayBuffer();
|
||||||
|
const mainBase = mainFname.replace(/\.[^.]+$/, '');
|
||||||
|
const imgExt = imgEntry.path.split('.').pop()?.toLowerCase() ?? 'png';
|
||||||
|
await putFileContents(joinPath(DOWNLOAD_DIR, `${mainBase}.${imgExt}`), imgData);
|
||||||
|
}
|
||||||
|
} catch { /* non-fatal */ }
|
||||||
|
}
|
||||||
const newConfig = JSON.parse(JSON.stringify(config));
|
const newConfig = JSON.parse(JSON.stringify(config));
|
||||||
if (!newConfig.devices) newConfig.devices = {};
|
if (!newConfig.devices) newConfig.devices = {};
|
||||||
if (!newConfig.devices.iec) newConfig.devices.iec = {};
|
if (!newConfig.devices.iec) newConfig.devices.iec = {};
|
||||||
|
|
@ -333,6 +351,7 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
|
||||||
toast.error(`Mount failed: ${e?.message ?? e}`);
|
toast.error(`Mount failed: ${e?.message ?? e}`);
|
||||||
} finally {
|
} finally {
|
||||||
setIsMounting(false);
|
setIsMounting(false);
|
||||||
|
setMountProgress(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -365,7 +384,10 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
|
||||||
<div className="flex-shrink-0 px-4 pt-3 pb-3 border-b border-neutral-200/70">
|
<div className="flex-shrink-0 px-4 pt-3 pb-3 border-b border-neutral-200/70">
|
||||||
<div className="flex items-center gap-1.5 mb-2">
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400 pointer-events-none" />
|
{isSearching
|
||||||
|
? <Loader2 className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-blue-500 animate-spin pointer-events-none" />
|
||||||
|
: <button onClick={handleSearch} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-blue-500 transition-colors" tabIndex={-1} title="Search"><Search className="w-4 h-4" /></button>
|
||||||
|
}
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -378,25 +400,23 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
|
||||||
className="w-full pl-9 pr-9 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-colors"
|
className="w-full pl-9 pr-9 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-colors"
|
||||||
disabled={isSearching}
|
disabled={isSearching}
|
||||||
/>
|
/>
|
||||||
|
{query && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAqlHelp(true)}
|
onClick={() => { setQuery(''); setResults([]); setHasSearched(false); setSearchError(null); setOffset(0); setHasMore(false); setCategoryFilter(null); }}
|
||||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 transition-colors"
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 transition-colors"
|
||||||
title="AQL search help"
|
title="Clear"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<HelpCircle className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleSearch}
|
onClick={() => setShowAqlHelp(true)}
|
||||||
disabled={isSearching}
|
className="p-1.5 rounded-lg hover:bg-neutral-100 transition-colors flex-shrink-0 text-neutral-400 hover:text-neutral-600"
|
||||||
className="p-1.5 rounded-lg hover:bg-neutral-100 disabled:opacity-40 transition-colors flex-shrink-0 text-neutral-400 hover:text-blue-600"
|
title="AQL search help"
|
||||||
title="Search"
|
|
||||||
aria-label="Search"
|
|
||||||
>
|
>
|
||||||
{isSearching
|
<HelpCircle className="w-4 h-4" />
|
||||||
? <Loader2 className="w-5 h-5 animate-spin text-blue-500" />
|
|
||||||
: <Search className="w-5 h-5" />}
|
|
||||||
</button>
|
</button>
|
||||||
{hasSearched && (
|
{hasSearched && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -676,7 +696,6 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
|
||||||
<div className="text-sm font-medium text-neutral-800 truncate">{fname}</div>
|
<div className="text-sm font-medium text-neutral-800 truncate">{fname}</div>
|
||||||
<div className="text-xs text-neutral-400">{humanFileSize(entry.size)}</div>
|
<div className="text-xs text-neutral-400">{humanFileSize(entry.size)}</div>
|
||||||
</div>
|
</div>
|
||||||
{isMounted && <span className="text-xs text-blue-600 flex-shrink-0">Mounted</span>}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -697,9 +716,27 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="overflow-y-auto flex-1 min-h-0">
|
<div className="overflow-y-auto flex-1 min-h-0">
|
||||||
{isMounting && (
|
{isMounting && (
|
||||||
<div className="flex flex-col items-center justify-center py-10 gap-3">
|
<div className="flex flex-col items-center justify-center py-10 gap-4">
|
||||||
|
{mountProgress?.phase === 'saving' || mountProgress?.phase === 'image' ? (
|
||||||
|
<>
|
||||||
<Loader2 className="w-7 h-7 text-blue-500 animate-spin" />
|
<Loader2 className="w-7 h-7 text-blue-500 animate-spin" />
|
||||||
<p className="text-sm text-neutral-500">Downloading…</p>
|
<p className="text-sm text-neutral-500">{mountProgress.phase === 'image' ? 'Saving cover image…' : 'Saving to device…'}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm font-medium text-neutral-600">
|
||||||
|
{mountProgress?.total
|
||||||
|
? `${humanFileSize(mountProgress.received)} / ${humanFileSize(mountProgress.total)}`
|
||||||
|
: mountProgress?.received ? humanFileSize(mountProgress.received) : 'Downloading…'}
|
||||||
|
</p>
|
||||||
|
<div className="w-56 h-1.5 bg-neutral-200 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 rounded-full transition-all duration-75"
|
||||||
|
style={{ width: mountProgress?.total ? `${Math.min(100, mountProgress.received / mountProgress.total * 100)}%` : '0%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isMounting && (() => {
|
{!isMounting && (() => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Search, Loader2, HardDrive, ChevronRight, X, SlidersHorizontal, MoreVertical, FolderOpen } from 'lucide-react';
|
import { Search, Loader2, HardDrive, ChevronRight, X, SlidersHorizontal, MoreVertical, FolderOpen } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { basename, joinPath, putFileContents } from '../webdav';
|
import { basename, clearDirectory, humanFileSize, joinPath, putFileContents, streamFetch, type FetchProgress } from '../webdav';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
||||||
import { MarqueeText } from './ui/marquee-text';
|
import { MarqueeText } from './ui/marquee-text';
|
||||||
import { EntryIcon } from './MediaEntry';
|
import { EntryIcon } from './MediaEntry';
|
||||||
|
|
@ -10,7 +10,7 @@ import { EntryIcon } from './MediaEntry';
|
||||||
|
|
||||||
const CSDB_API = 'https://api.idolpx.com/csdb';
|
const CSDB_API = 'https://api.idolpx.com/csdb';
|
||||||
const CSDB_DOWNLOAD = 'https://csdb.idolpx.com/data';
|
const CSDB_DOWNLOAD = 'https://csdb.idolpx.com/data';
|
||||||
const DOWNLOAD_DIR = '/sd/downloads';
|
const DOWNLOAD_DIR = '/sd/downloads/csdbng';
|
||||||
|
|
||||||
function resolveLink(link: string): string {
|
function resolveLink(link: string): string {
|
||||||
return CSDB_DOWNLOAD + link;
|
return CSDB_DOWNLOAD + link;
|
||||||
|
|
@ -94,6 +94,7 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
|
||||||
const [actionRow, setActionRow] = useState<CsdbRow | null>(null);
|
const [actionRow, setActionRow] = useState<CsdbRow | null>(null);
|
||||||
const [mountEntry, setMountEntry] = useState<{ row: CsdbRow; link: CsdbDownloadLink } | null>(null);
|
const [mountEntry, setMountEntry] = useState<{ row: CsdbRow; link: CsdbDownloadLink } | null>(null);
|
||||||
const [isMounting, setIsMounting] = useState(false);
|
const [isMounting, setIsMounting] = useState(false);
|
||||||
|
const [mountProgress, setMountProgress] = useState<FetchProgress & { phase: 'fetching' | 'saving' | 'image' } | null>(null);
|
||||||
|
|
||||||
const [showFilter, setShowFilter] = useState(() => _store.showFilter);
|
const [showFilter, setShowFilter] = useState(() => _store.showFilter);
|
||||||
const [filterText, setFilterText] = useState(() => _store.filterText);
|
const [filterText, setFilterText] = useState(() => _store.filterText);
|
||||||
|
|
@ -160,14 +161,31 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
|
||||||
if (!mountEntry) return;
|
if (!mountEntry) return;
|
||||||
const { row, link } = mountEntry;
|
const { row, link } = mountEntry;
|
||||||
setIsMounting(true);
|
setIsMounting(true);
|
||||||
|
setMountProgress({ received: 0, total: 0, phase: 'fetching' });
|
||||||
try {
|
try {
|
||||||
|
await clearDirectory(DOWNLOAD_DIR);
|
||||||
const url = resolveLink(link.Link);
|
const url = resolveLink(link.Link);
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const data = await res.arrayBuffer();
|
const data = await streamFetch(res, p => setMountProgress({ ...p, phase: 'fetching' }));
|
||||||
const fname = link.Filename || basename(link.Link) || row.name;
|
const fname = link.Filename || basename(link.Link) || row.name;
|
||||||
const dest = joinPath(DOWNLOAD_DIR, fname);
|
const dest = joinPath(DOWNLOAD_DIR, fname);
|
||||||
|
setMountProgress(p => p && { ...p, phase: 'saving' });
|
||||||
await putFileContents(dest, data);
|
await putFileContents(dest, data);
|
||||||
|
if (release?.ScreenShot?.[0]) {
|
||||||
|
setMountProgress(p => p && { ...p, phase: 'image' });
|
||||||
|
try {
|
||||||
|
let imgPath = release.ScreenShot[0];
|
||||||
|
try { imgPath = new URL(imgPath).pathname; } catch { /* already a path */ }
|
||||||
|
const imgRes = await fetch(resolveLink(imgPath));
|
||||||
|
if (imgRes.ok) {
|
||||||
|
const imgData = await imgRes.arrayBuffer();
|
||||||
|
const mainBase = fname.replace(/\.[^.]+$/, '');
|
||||||
|
const imgExt = imgPath.split('.').pop()?.split('?')[0]?.toLowerCase() ?? 'png';
|
||||||
|
await putFileContents(joinPath(DOWNLOAD_DIR, `${mainBase}.${imgExt}`), imgData);
|
||||||
|
}
|
||||||
|
} catch { /* non-fatal */ }
|
||||||
|
}
|
||||||
const newConfig = JSON.parse(JSON.stringify(config));
|
const newConfig = JSON.parse(JSON.stringify(config));
|
||||||
if (!newConfig.devices) newConfig.devices = {};
|
if (!newConfig.devices) newConfig.devices = {};
|
||||||
if (!newConfig.devices.iec) newConfig.devices.iec = {};
|
if (!newConfig.devices.iec) newConfig.devices.iec = {};
|
||||||
|
|
@ -184,6 +202,7 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
|
||||||
toast.error(`Mount failed: ${e?.message ?? e}`);
|
toast.error(`Mount failed: ${e?.message ?? e}`);
|
||||||
} finally {
|
} finally {
|
||||||
setIsMounting(false);
|
setIsMounting(false);
|
||||||
|
setMountProgress(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -238,7 +257,10 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
|
||||||
<div className="flex-shrink-0 px-4 pt-3 pb-3 border-b border-neutral-200/70">
|
<div className="flex-shrink-0 px-4 pt-3 pb-3 border-b border-neutral-200/70">
|
||||||
<div className="flex items-center gap-1.5 mb-2">
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400 pointer-events-none" />
|
{isSearching
|
||||||
|
? <Loader2 className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-blue-500 animate-spin pointer-events-none" />
|
||||||
|
: <button onClick={() => doSearch(query)} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-blue-500 transition-colors" tabIndex={-1} title="Search"><Search className="w-4 h-4" /></button>
|
||||||
|
}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
|
|
@ -247,21 +269,20 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
|
||||||
placeholder="Search CSDb-ng…"
|
placeholder="Search CSDb-ng…"
|
||||||
inputMode="search"
|
inputMode="search"
|
||||||
enterKeyHint="search"
|
enterKeyHint="search"
|
||||||
className="w-full pl-9 pr-3 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-colors"
|
className="w-full pl-9 pr-9 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-colors"
|
||||||
disabled={isSearching}
|
disabled={isSearching}
|
||||||
/>
|
/>
|
||||||
</div>
|
{query && (
|
||||||
<button
|
<button
|
||||||
onClick={() => doSearch(query)}
|
onClick={() => { setQuery(''); setResults([]); setHasSearched(false); setSearchError(null); setTagFilter(null); }}
|
||||||
disabled={isSearching || !query.trim()}
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 transition-colors"
|
||||||
className="p-1.5 rounded-lg hover:bg-neutral-100 disabled:opacity-40 transition-colors flex-shrink-0 text-neutral-400 hover:text-blue-600"
|
title="Clear"
|
||||||
title="Search"
|
tabIndex={-1}
|
||||||
aria-label="Search"
|
|
||||||
>
|
>
|
||||||
{isSearching
|
<X className="w-4 h-4" />
|
||||||
? <Loader2 className="w-5 h-5 animate-spin text-blue-500" />
|
|
||||||
: <Search className="w-5 h-5" />}
|
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{hasSearched && (
|
{hasSearched && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowFilter(v => !v)}
|
onClick={() => setShowFilter(v => !v)}
|
||||||
|
|
@ -490,9 +511,27 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="overflow-y-auto flex-1 min-h-0">
|
<div className="overflow-y-auto flex-1 min-h-0">
|
||||||
{isMounting && (
|
{isMounting && (
|
||||||
<div className="flex flex-col items-center justify-center py-10 gap-3">
|
<div className="flex flex-col items-center justify-center py-10 gap-4">
|
||||||
|
{mountProgress?.phase === 'saving' || mountProgress?.phase === 'image' ? (
|
||||||
|
<>
|
||||||
<Loader2 className="w-7 h-7 text-blue-500 animate-spin" />
|
<Loader2 className="w-7 h-7 text-blue-500 animate-spin" />
|
||||||
<p className="text-sm text-neutral-500">Downloading…</p>
|
<p className="text-sm text-neutral-500">{mountProgress.phase === 'image' ? 'Saving cover image…' : 'Saving to device…'}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm font-medium text-neutral-600">
|
||||||
|
{mountProgress?.total
|
||||||
|
? `${humanFileSize(mountProgress.received)} / ${humanFileSize(mountProgress.total)}`
|
||||||
|
: mountProgress?.received ? humanFileSize(mountProgress.received) : 'Downloading…'}
|
||||||
|
</p>
|
||||||
|
<div className="w-56 h-1.5 bg-neutral-200 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 rounded-full transition-all duration-75"
|
||||||
|
style={{ width: mountProgress?.total ? `${Math.min(100, mountProgress.received / mountProgress.total * 100)}%` : '0%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isMounting && (() => {
|
{!isMounting && (() => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Search, Loader2, HardDrive, Download, ChevronRight, ChevronDown, Trophy, Calendar, Users, RefreshCw, HelpCircle, X, SlidersHorizontal, MoreVertical, FolderOpen } from 'lucide-react';
|
import { Search, Loader2, HardDrive, ChevronRight, ChevronDown, Trophy, Calendar, Users, RefreshCw, HelpCircle, X, SlidersHorizontal, MoreVertical, FolderOpen } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { humanFileSize, basename, joinPath, putFileContents } from '../webdav';
|
import { humanFileSize, basename, clearDirectory, joinPath, putFileContents, streamFetch, type FetchProgress } from '../webdav';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
||||||
import { MarqueeText } from './ui/marquee-text';
|
import { MarqueeText } from './ui/marquee-text';
|
||||||
import { EntryIcon } from './MediaEntry';
|
import { EntryIcon } from './MediaEntry';
|
||||||
|
|
@ -9,8 +9,8 @@ import { EntryIcon } from './MediaEntry';
|
||||||
// ─── API ──────────────────────────────────────────────────────────────────────
|
// ─── API ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const LEET_BASE = 'https://commoserve.files.commodore.net/leet';
|
const LEET_BASE = 'https://commoserve.files.commodore.net/leet';
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 1000;
|
||||||
const DOWNLOAD_DIR = '/sd/downloads';
|
const DOWNLOAD_DIR = '/sd/downloads/commoserve';
|
||||||
|
|
||||||
function leetFetch(path: string, query?: Record<string, string>) {
|
function leetFetch(path: string, query?: Record<string, string>) {
|
||||||
const url = new URL(LEET_BASE + path);
|
const url = new URL(LEET_BASE + path);
|
||||||
|
|
@ -123,10 +123,6 @@ function entryFilename(e: ContentEntry): string {
|
||||||
return basename(e.path) || e.path;
|
return basename(e.path) || e.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadUrl(item: ContentItem, entry: ContentEntry): string {
|
|
||||||
return `${LEET_BASE}/search/bin/${item.id}/${item.category}/${entry.id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RatingStars({ value, max = 10 }: { value?: number; max?: number }) {
|
function RatingStars({ value, max = 10 }: { value?: number; max?: number }) {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const pct = Math.round((value / max) * 5);
|
const pct = Math.round((value / max) * 5);
|
||||||
|
|
@ -178,7 +174,8 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
|
||||||
|
|
||||||
const [actionItem, setActionItem] = useState<ContentItem | null>(null);
|
const [actionItem, setActionItem] = useState<ContentItem | null>(null);
|
||||||
const [mountEntry, setMountEntry] = useState<{ item: ContentItem; entry: ContentEntry } | null>(null);
|
const [mountEntry, setMountEntry] = useState<{ item: ContentItem; entry: ContentEntry } | null>(null);
|
||||||
const [downloading, setDownloading] = useState<number | null>(null);
|
const [isMounting, setIsMounting] = useState(false);
|
||||||
|
const [mountProgress, setMountProgress] = useState<FetchProgress & { phase: 'fetching' | 'saving' | 'image' } | null>(null);
|
||||||
|
|
||||||
const [showFilter, setShowFilter] = useState(() => _store.showFilter);
|
const [showFilter, setShowFilter] = useState(() => _store.showFilter);
|
||||||
const [filterText, setFilterText] = useState(() => _store.filterText);
|
const [filterText, setFilterText] = useState(() => _store.filterText);
|
||||||
|
|
@ -301,40 +298,53 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Download to SD ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const downloadToSd = async (item: ContentItem, entry: ContentEntry) => {
|
|
||||||
setDownloading(entry.id);
|
|
||||||
try {
|
|
||||||
const res = await fetch(downloadUrl(item, entry));
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
const data = await res.arrayBuffer();
|
|
||||||
const dest = joinPath(DOWNLOAD_DIR, entryFilename(entry));
|
|
||||||
await putFileContents(dest, data);
|
|
||||||
toast.success(`Saved to ${dest}`);
|
|
||||||
} catch (e: any) {
|
|
||||||
toast.error(`Download failed: ${e?.message ?? e}`);
|
|
||||||
} finally {
|
|
||||||
setDownloading(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Mount ────────────────────────────────────────────────────────────────────
|
// ── Mount ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const mountOnDevice = (deviceType: 'drive' | 'meatloaf', key: string) => {
|
const mountOnDevice = async (deviceType: 'drive' | 'meatloaf', key: string) => {
|
||||||
if (!mountEntry) return;
|
if (!mountEntry) return;
|
||||||
const { item, entry } = mountEntry;
|
const { item, entry } = mountEntry;
|
||||||
|
setIsMounting(true);
|
||||||
|
setMountProgress({ received: 0, total: 0, phase: 'fetching' });
|
||||||
|
try {
|
||||||
|
await clearDirectory(DOWNLOAD_DIR);
|
||||||
|
const res = await leetFetch(`/search/bin/${item.id}/${item.category}/${entry.id}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = await streamFetch(res, p => setMountProgress({ ...p, phase: 'fetching' }));
|
||||||
|
const mainFname = entryFilename(entry);
|
||||||
|
const dest = joinPath(DOWNLOAD_DIR, mainFname);
|
||||||
|
setMountProgress(p => p && { ...p, phase: 'saving' });
|
||||||
|
await putFileContents(dest, data);
|
||||||
|
const imgEntry = (entries ?? []).find(e => e.id !== entry.id && /\.(png|jpg|jpeg|gif|bmp)$/i.test(e.path));
|
||||||
|
if (imgEntry) {
|
||||||
|
setMountProgress(p => p && { ...p, phase: 'image' });
|
||||||
|
try {
|
||||||
|
const imgRes = await leetFetch(`/search/bin/${item.id}/${item.category}/${imgEntry.id}`);
|
||||||
|
if (imgRes.ok) {
|
||||||
|
const imgData = await imgRes.arrayBuffer();
|
||||||
|
const mainBase = mainFname.replace(/\.[^.]+$/, '');
|
||||||
|
const imgExt = imgEntry.path.split('.').pop()?.toLowerCase() ?? 'png';
|
||||||
|
await putFileContents(joinPath(DOWNLOAD_DIR, `${mainBase}.${imgExt}`), imgData);
|
||||||
|
}
|
||||||
|
} catch { /* non-fatal */ }
|
||||||
|
}
|
||||||
const newConfig = JSON.parse(JSON.stringify(config));
|
const newConfig = JSON.parse(JSON.stringify(config));
|
||||||
if (!newConfig.devices) newConfig.devices = {};
|
if (!newConfig.devices) newConfig.devices = {};
|
||||||
if (!newConfig.devices.iec) newConfig.devices.iec = {};
|
if (!newConfig.devices.iec) newConfig.devices.iec = {};
|
||||||
if (!newConfig.devices.iec[key]) newConfig.devices.iec[key] = { type: deviceType };
|
if (!newConfig.devices.iec[key]) newConfig.devices.iec[key] = { type: deviceType };
|
||||||
const dev = newConfig.devices.iec[key];
|
const dev = newConfig.devices.iec[key];
|
||||||
dev.url = downloadUrl(item, entry);
|
dev.url = dest;
|
||||||
delete dev.media_set;
|
delete dev.media_set;
|
||||||
if (!dev.enabled) dev.enabled = 1;
|
if (!dev.enabled) dev.enabled = 1;
|
||||||
setConfig(newConfig);
|
setConfig(newConfig);
|
||||||
setMountEntry(null);
|
setMountEntry(null);
|
||||||
toast.success(`Mounted "${entryFilename(entry)}" on ${deviceType} #${key}`);
|
setSelectedItem(null);
|
||||||
|
toast.success(`Downloaded and mounted "${mainFname}" on device #${key}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(`Mount failed: ${e?.message ?? e}`);
|
||||||
|
} finally {
|
||||||
|
setIsMounting(false);
|
||||||
|
setMountProgress(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const mountedUrls = useMemo(() => {
|
const mountedUrls = useMemo(() => {
|
||||||
|
|
@ -367,7 +377,10 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
|
||||||
<div className="flex-shrink-0 px-4 pt-3 pb-3 border-b border-neutral-200/70">
|
<div className="flex-shrink-0 px-4 pt-3 pb-3 border-b border-neutral-200/70">
|
||||||
<div className="flex items-center gap-1.5 mb-2">
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400 pointer-events-none" />
|
{isSearching
|
||||||
|
? <Loader2 className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-blue-500 animate-spin pointer-events-none" />
|
||||||
|
: <button onClick={handleSearch} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-blue-500 transition-colors" tabIndex={-1} title="Search"><Search className="w-4 h-4" /></button>
|
||||||
|
}
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -380,25 +393,23 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
|
||||||
className="w-full pl-9 pr-9 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-colors"
|
className="w-full pl-9 pr-9 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-colors"
|
||||||
disabled={isSearching}
|
disabled={isSearching}
|
||||||
/>
|
/>
|
||||||
|
{query && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAqlHelp(true)}
|
onClick={() => { setQuery(''); setResults([]); setHasSearched(false); setSearchError(null); setOffset(0); setHasMore(false); }}
|
||||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 transition-colors"
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 transition-colors"
|
||||||
title="AQL search help"
|
title="Clear"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<HelpCircle className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleSearch}
|
onClick={() => setShowAqlHelp(true)}
|
||||||
disabled={isSearching}
|
className="p-1.5 rounded-lg hover:bg-neutral-100 transition-colors flex-shrink-0 text-neutral-400 hover:text-neutral-600"
|
||||||
className="p-1.5 rounded-lg hover:bg-neutral-100 disabled:opacity-40 transition-colors flex-shrink-0 text-neutral-400 hover:text-blue-600"
|
title="AQL search help"
|
||||||
title="Search"
|
|
||||||
aria-label="Search"
|
|
||||||
>
|
>
|
||||||
{isSearching
|
<HelpCircle className="w-4 h-4" />
|
||||||
? <Loader2 className="w-5 h-5 animate-spin text-blue-500" />
|
|
||||||
: <Search className="w-5 h-5" />}
|
|
||||||
</button>
|
</button>
|
||||||
{hasSearched && (
|
{hasSearched && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -650,35 +661,19 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
|
||||||
<div className="flex flex-col gap-2 py-1">
|
<div className="flex flex-col gap-2 py-1">
|
||||||
{entries.map(entry => {
|
{entries.map(entry => {
|
||||||
const fname = entryFilename(entry);
|
const fname = entryFilename(entry);
|
||||||
const isMounted = mountedUrls.has(downloadUrl(selectedItem!, entry));
|
const isMounted = mountedUrls.has(joinPath(DOWNLOAD_DIR, entryFilename(entry)));
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
className={`px-4 py-3 rounded-lg border flex items-center gap-3 ${isMounted ? 'border-blue-300 bg-blue-50' : 'border-neutral-200'}`}
|
onClick={() => setMountEntry({ item: selectedItem!, entry })}
|
||||||
|
className={`w-full text-left px-4 py-3 rounded-lg border flex items-center gap-3 transition-colors ${isMounted ? 'border-blue-300 bg-blue-50' : 'border-neutral-200 hover:bg-neutral-50 hover:border-neutral-300'}`}
|
||||||
>
|
>
|
||||||
<EntryIcon entry={{ name: fname, type: 'file', path: entry.path, size: entry.size, lastModified: new Date(entry.date * 1000), contentType: '' }} />
|
<EntryIcon entry={{ name: fname, type: 'file', path: entry.path, size: entry.size, lastModified: new Date(entry.date * 1000), contentType: '' }} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium text-neutral-800 truncate">{fname}</div>
|
<div className="text-sm font-medium text-neutral-800 truncate">{fname}</div>
|
||||||
<div className="text-xs text-neutral-400">{humanFileSize(entry.size)}</div>
|
<div className="text-xs text-neutral-400">{humanFileSize(entry.size)}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={() => downloadToSd(selectedItem!, entry)}
|
|
||||||
disabled={downloading === entry.id}
|
|
||||||
className="p-1.5 rounded-lg hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600 disabled:opacity-40"
|
|
||||||
title="Download to /sd/downloads"
|
|
||||||
>
|
|
||||||
{downloading === entry.id
|
|
||||||
? <Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
: <Download className="w-4 h-4" />}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => setMountEntry({ item: selectedItem!, entry })}
|
|
||||||
className={`p-1.5 rounded-lg ${isMounted ? 'text-blue-600' : 'hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600'}`}
|
|
||||||
title="Mount on virtual drive"
|
|
||||||
>
|
|
||||||
<HardDrive className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -688,7 +683,7 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Mount device picker */}
|
{/* Mount device picker */}
|
||||||
<Dialog open={mountEntry !== null} onOpenChange={open => !open && setMountEntry(null)}>
|
<Dialog open={mountEntry !== null} onOpenChange={open => { if (!open && !isMounting) setMountEntry(null); }}>
|
||||||
<DialogContent className="max-w-sm flex flex-col max-h-[90dvh]">
|
<DialogContent className="max-w-sm flex flex-col max-h-[90dvh]">
|
||||||
<DialogHeader className="flex-shrink-0">
|
<DialogHeader className="flex-shrink-0">
|
||||||
<DialogTitle>Mount on Virtual Drive</DialogTitle>
|
<DialogTitle>Mount on Virtual Drive</DialogTitle>
|
||||||
|
|
@ -696,7 +691,31 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
|
||||||
{mountEntry ? entryFilename(mountEntry.entry) : ''}
|
{mountEntry ? entryFilename(mountEntry.entry) : ''}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="overflow-y-auto flex-1 min-h-0">
|
{isMounting && mountProgress ? (
|
||||||
|
<div className="py-6 flex flex-col items-center gap-3">
|
||||||
|
{mountProgress.phase === 'fetching' ? (
|
||||||
|
<>
|
||||||
|
<div className="text-sm text-neutral-600">
|
||||||
|
{mountProgress.total
|
||||||
|
? `${humanFileSize(mountProgress.received)} / ${humanFileSize(mountProgress.total)}`
|
||||||
|
: humanFileSize(mountProgress.received) || 'Downloading…'}
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 bg-neutral-200 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 rounded-full transition-all duration-75"
|
||||||
|
style={{ width: mountProgress.total ? `${Math.min(100, mountProgress.received / mountProgress.total * 100)}%` : '0%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-neutral-600">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
{mountProgress.phase === 'image' ? 'Saving cover image…' : 'Saving to device…'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className={`overflow-y-auto flex-1 min-h-0 ${isMounting ? 'hidden' : ''}`}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const allDevices = Object.entries(config?.devices?.iec ?? {});
|
const allDevices = Object.entries(config?.devices?.iec ?? {});
|
||||||
const drives = allDevices
|
const drives = allDevices
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { flushSync } from 'react-dom';
|
import { flushSync } from 'react-dom';
|
||||||
import { Search, Loader2, RefreshCw, FolderSearch, SlidersHorizontal, HardDrive, FolderOpen, X } from 'lucide-react';
|
import { Search, Loader2, RefreshCw, FolderSearch, SlidersHorizontal, HardDrive, FolderOpen, X, DatabaseZap } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { humanFileSize, splitPath } from '../webdav';
|
import { humanFileSize, splitPath } from '../webdav';
|
||||||
import type { EntryInfo } from '../webdav';
|
import type { EntryInfo } from '../webdav';
|
||||||
|
|
@ -346,7 +346,7 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-2 flex-1 py-3 min-w-0">
|
<div className="flex items-center gap-2 flex-1 py-3 min-w-0">
|
||||||
<img src={`${import.meta.env.BASE_URL}favicon.ico`} className="w-5 h-5 flex-shrink-0 object-contain" alt="" aria-hidden="true" />
|
<img src={`${import.meta.env.BASE_URL}favicon.ico`} className="w-5 h-5 flex-shrink-0 object-contain" alt="" aria-hidden="true" />
|
||||||
<span className="text-sm font-semibold text-neutral-700">Local</span>
|
<span className="text-sm font-semibold text-neutral-700">Local Storage</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -354,36 +354,38 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
||||||
<div className="flex-shrink-0 px-4 pt-3 pb-3 border-b border-neutral-100">
|
<div className="flex-shrink-0 px-4 pt-3 pb-3 border-b border-neutral-100">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400 pointer-events-none" />
|
{busy
|
||||||
|
? <Loader2 className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-blue-500 animate-spin pointer-events-none" />
|
||||||
|
: <button onClick={handleSearch} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-blue-500 transition-colors" tabIndex={-1} title="Search"><Search className="w-4 h-4" /></button>
|
||||||
|
}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={e => setQuery(e.target.value)}
|
onChange={e => setQuery(e.target.value)}
|
||||||
onKeyDown={e => e.key === 'Enter' && !busy && handleSearch()}
|
onKeyDown={e => e.key === 'Enter' && !busy && handleSearch()}
|
||||||
placeholder="Search… (* any chars, ? one char)"
|
placeholder="Search… (* any chars, ? one char)"
|
||||||
className="w-full pl-9 pr-3 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-colors"
|
className="w-full pl-9 pr-9 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-colors"
|
||||||
autoFocus
|
autoFocus
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
</div>
|
{query && (
|
||||||
<button
|
<button
|
||||||
onClick={handleSearch}
|
onClick={() => { setQuery(''); setResults([]); setHasSearched(false); setSearchError(null); }}
|
||||||
disabled={busy || !query.trim()}
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 transition-colors"
|
||||||
className="p-1.5 rounded-lg hover:bg-neutral-100 disabled:opacity-40 transition-colors flex-shrink-0 text-neutral-400 hover:text-blue-600"
|
title="Clear"
|
||||||
title="Search"
|
tabIndex={-1}
|
||||||
aria-label="Search"
|
|
||||||
>
|
>
|
||||||
{busy
|
<X className="w-4 h-4" />
|
||||||
? <Loader2 className="w-5 h-5 animate-spin text-blue-500" />
|
|
||||||
: <Search className="w-5 h-5" />}
|
|
||||||
</button>
|
</button>
|
||||||
{dbPhase === 'ready' && !busy && (
|
)}
|
||||||
|
</div>
|
||||||
|
{/* {dbPhase === 'ready' && !busy && (
|
||||||
<button onClick={handleRefreshDb} className="p-1.5 rounded-lg hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600 transition-colors flex-shrink-0" title="Reload database">
|
<button onClick={handleRefreshDb} className="p-1.5 rounded-lg hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600 transition-colors flex-shrink-0" title="Reload database">
|
||||||
<RefreshCw className="w-4 h-4" />
|
<RefreshCw className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)} */}
|
||||||
<button onClick={() => setShowScanConfirm(true)} disabled={busy} className="p-1.5 rounded-lg hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600 disabled:opacity-40 transition-colors flex-shrink-0" title="Scan /sd and rebuild database">
|
<button onClick={() => setShowScanConfirm(true)} disabled={busy} className="p-1.5 rounded-lg hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600 disabled:opacity-40 transition-colors flex-shrink-0" title="Scan /sd and rebuild database">
|
||||||
<FolderSearch className="w-4 h-4" />
|
<DatabaseZap className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
{hasSearched && (
|
{hasSearched && (
|
||||||
<button onClick={() => setShowFilter(v => !v)} className={`relative p-1.5 rounded-lg transition-colors flex-shrink-0 ${showFilter ? 'bg-blue-100 text-blue-600' : 'hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600'}`} title="Filter & sort">
|
<button onClick={() => setShowFilter(v => !v)} className={`relative p-1.5 rounded-lg transition-colors flex-shrink-0 ${showFilter ? 'bg-blue-100 text-blue-600' : 'hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600'}`} title="Filter & sort">
|
||||||
|
|
|
||||||
|
|
@ -424,3 +424,36 @@ export function humanFileSize(bytes: number): string {
|
||||||
}
|
}
|
||||||
return `${v.toFixed(v >= 10 ? 0 : 1)} ${units[i]}`;
|
return `${v.toFixed(v >= 10 ? 0 : 1)} ${units[i]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FetchProgress = { received: number; total: number };
|
||||||
|
|
||||||
|
export async function clearDirectory(path: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const entries = await listDirectory(path);
|
||||||
|
await Promise.all(entries.filter(e => e.type === 'file').map(e => deletePath(e.path)));
|
||||||
|
} catch {
|
||||||
|
// Directory may not exist yet — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function streamFetch(
|
||||||
|
res: Response,
|
||||||
|
onProgress: (p: FetchProgress) => void,
|
||||||
|
): Promise<ArrayBuffer> {
|
||||||
|
if (!res.body) return res.arrayBuffer();
|
||||||
|
const total = +(res.headers.get('content-length') ?? 0);
|
||||||
|
const reader = res.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, total });
|
||||||
|
}
|
||||||
|
const out = new Uint8Array(received);
|
||||||
|
let off = 0;
|
||||||
|
for (const c of chunks) { out.set(c, off); off += c.byteLength; }
|
||||||
|
return out.buffer;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user