feat(SearchLocal, SearchPane): enhance loading state management and UI feedback for database scanning
This commit is contained in:
parent
65cc6bb602
commit
a1b2fa5ea7
|
|
@ -1,8 +1,8 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { Search, Loader2, RefreshCw, FolderSearch, SlidersHorizontal, HardDrive, FolderOpen, X, DatabaseZap } from 'lucide-react';
|
||||
import { Search, Loader2, RefreshCw, FolderSearch, SlidersHorizontal, HardDrive, FolderOpen, X, DatabaseZap, AlertCircle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { humanFileSize, splitPath } from '../webdav';
|
||||
import { humanFileSize, splitPath, stat, fileExists } from '../webdav';
|
||||
import type { EntryInfo } from '../webdav';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
||||
import { MediaEntry } from './MediaEntry';
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
isLocateDbLoaded,
|
||||
resetLocateDb,
|
||||
buildLocateDb,
|
||||
subscribeLoadProgress,
|
||||
type LocateEntry,
|
||||
type ScanPhase,
|
||||
} from '../locate-db';
|
||||
|
|
@ -180,10 +181,11 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
|||
const [actionEntry, setActionEntry] = useState<SearchResult | null>(null);
|
||||
const [showScanConfirm, setShowScanConfirm] = useState(false);
|
||||
const [searchError, setSearchError] = useState<string | null>(null);
|
||||
// The locate-database load progress is rendered by the SearchPane (above
|
||||
// this panel). The pane pre-fetches the database on mount, so by the time
|
||||
// the user clicks Search the database is almost always ready.
|
||||
const [dbPhase, setDbPhase] = useState<'idle' | 'ready'>('ready');
|
||||
const [dbPhase, setDbPhase] = useState<'idle' | 'ready'>('ready');
|
||||
const [checking, setChecking] = useState(() => !isLocateDbLoaded());
|
||||
const [sdMissing, setSdMissing] = useState(false);
|
||||
const [locateMissing, setLocateMissing] = useState(false);
|
||||
const [loadState, setLoadState] = useState<{ kind: 'idle' | 'engine' | 'database' | 'ready'; received: number; total: number | null }>({ kind: 'idle', received: 0, total: null });
|
||||
const [showFilter, setShowFilter] = useState(() => _store.showFilter);
|
||||
const [filterText, setFilterText] = useState(() => _store.filterText);
|
||||
const [filterSystem, setFilterSystem] = useState<string | null>(() => _store.filterSystem);
|
||||
|
|
@ -193,9 +195,34 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
|||
const [sortDir, setSortDir] = useState<SortDir>(() => _store.sortDir);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLocateDbLoaded()) setDbPhase('ready');
|
||||
return subscribeLoadProgress(s => {
|
||||
if (s.phase === 'ready' || s.phase === 'error') {
|
||||
setLoadState({ kind: 'ready', received: 0, total: null });
|
||||
setDbPhase('ready');
|
||||
} else if (s.phase === 'idle') {
|
||||
setLoadState({ kind: 'idle', received: 0, total: null });
|
||||
} else {
|
||||
setLoadState({ kind: s.phase, received: s.received, total: s.total });
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!checking) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const sdEntry = await stat('/sd').catch(() => null);
|
||||
if (cancelled) return;
|
||||
if (!sdEntry) { setSdMissing(true); setChecking(false); return; }
|
||||
const hasLocate = await fileExists('/sd/.locate').catch(() => false);
|
||||
if (cancelled) return;
|
||||
setLocateMissing(!hasLocate);
|
||||
setChecking(false);
|
||||
if (hasLocate) openLocateDb().catch(() => {});
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
_store.query = query;
|
||||
_store.results = results;
|
||||
|
|
@ -285,6 +312,7 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
|||
);
|
||||
toast.success(`Indexed ${count.toLocaleString()} items · ${humanFileSize(bytes)}`);
|
||||
setDbPhase('ready');
|
||||
setLocateMissing(false);
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') toast.info('Scan stopped.');
|
||||
else toast.error(`Scan failed: ${e?.message ?? e}`);
|
||||
|
|
@ -366,7 +394,47 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
{/* Search input slot — swaps content based on device/db state */}
|
||||
{sdMissing ? (
|
||||
<div className="flex-shrink-0 px-4 py-4 border-b border-neutral-100">
|
||||
<div className="flex items-center gap-3 px-3 py-3 rounded-xl bg-red-50 border border-red-200">
|
||||
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-700">No SD card detected</p>
|
||||
<p className="text-xs text-red-500 mt-0.5">Insert an SD card and reload.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : checking ? (
|
||||
<div className="flex-shrink-0 px-4 py-3 border-b border-neutral-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 text-neutral-400 animate-spin flex-shrink-0" />
|
||||
<span className="text-sm text-neutral-400">Checking device…</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (loadState.kind === 'engine' || loadState.kind === 'database') ? (
|
||||
<div className="flex-shrink-0 px-4 py-3 border-b border-neutral-100" aria-live="polite">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Loader2 className="w-4 h-4 text-blue-500 animate-spin flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-neutral-600">
|
||||
{loadState.kind === 'engine' ? 'Loading engine' : 'Loading database'}…
|
||||
</span>
|
||||
{loadState.received > 0 && (
|
||||
<span className="text-xs text-neutral-400 ml-auto tabular-nums">
|
||||
{loadState.total
|
||||
? `${humanFileSize(loadState.received)} / ${humanFileSize(loadState.total)}`
|
||||
: humanFileSize(loadState.received)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-1.5 bg-neutral-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full bg-blue-500 rounded-full transition-all duration-150 ease-out ${loadState.total ? '' : 'animate-pulse w-1/3'}`}
|
||||
style={loadState.total ? { width: `${Math.min(100, Math.round(loadState.received / loadState.total * 100))}%` } : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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="relative flex-1">
|
||||
|
|
@ -400,7 +468,12 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
|||
<RefreshCw className="w-4 h-4" />
|
||||
</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 transition-colors flex-shrink-0 disabled:opacity-40 ${locateMissing ? 'text-blue-600 bg-blue-50 ring-1 ring-blue-300 hover:bg-blue-100' : 'hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600'}`}
|
||||
title="Scan /sd and rebuild database"
|
||||
>
|
||||
<DatabaseZap className="w-4 h-4" />
|
||||
</button>
|
||||
{hasSearched && (
|
||||
|
|
@ -415,6 +488,7 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter + sort bar — same style as MediaManager */}
|
||||
<div className={`overflow-hidden flex-shrink-0 transition-all duration-200 ease-in-out ${showFilter ? 'max-h-40 opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||
|
|
@ -551,7 +625,9 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
|||
<Search className="w-10 h-10 mx-auto mb-3 text-neutral-300" />
|
||||
<p className="text-sm font-medium text-neutral-600 mb-1">Search your device</p>
|
||||
<p className="text-xs text-neutral-400">
|
||||
{dbPhase === 'idle'
|
||||
{locateMissing
|
||||
? 'No search index found. Tap the highlighted scan button to build one.'
|
||||
: dbPhase === 'idle'
|
||||
? 'The locate database will be downloaded on first search, or tap the scan icon to rebuild it.'
|
||||
: 'Type a filename or path to search the index.'}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,9 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Loader2, Database } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import SearchLocal from './SearchLocal';
|
||||
import SearchAssembly64 from './SearchAssembly64';
|
||||
import SearchCommoServe from './SearchCommoServe';
|
||||
import SearchCSDbNG from './SearchCSDbNG';
|
||||
import { humanFileSize } from '../webdav';
|
||||
import {
|
||||
openLocateDb,
|
||||
isLocateDbLoaded,
|
||||
subscribeLoadProgress,
|
||||
} from '../locate-db';
|
||||
|
||||
interface SearchPaneProps {
|
||||
config: any;
|
||||
|
|
@ -35,22 +28,6 @@ export default function SearchPane({ config, setConfig, initialTab, onClose, onO
|
|||
// ~17 MB /sd/.locate download happening on the panel itself, not buried
|
||||
// inside the search action. Multiple subscribers (this header bar + the
|
||||
// SearchLocal panel) can observe the same in-flight load.
|
||||
const [loadState, setLoadState] = useState<{ kind: 'idle' | 'engine' | 'database' | 'ready' | 'error'; received: number; total: number | null; message?: string }>({ kind: 'idle', received: 0, total: null });
|
||||
|
||||
useEffect(() => {
|
||||
// Kick off the load immediately on mount (if not already loaded).
|
||||
// openLocateDb is a no-op once the database is loaded, so this is safe
|
||||
// to call every time the pane opens.
|
||||
if (!isLocateDbLoaded()) {
|
||||
openLocateDb().catch(() => { /* errors are surfaced via loadState */ });
|
||||
}
|
||||
return subscribeLoadProgress(s => {
|
||||
if (s.phase === 'idle') setLoadState({ kind: 'idle', received: 0, total: null });
|
||||
else if (s.phase === 'ready') setLoadState({ kind: 'ready', received: 0, total: null });
|
||||
else if (s.phase === 'error') setLoadState({ kind: 'error', received: 0, total: null });
|
||||
else setLoadState({ kind: s.phase, received: s.received, total: s.total });
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Jump to starting tab without animation on first render
|
||||
useEffect(() => {
|
||||
|
|
@ -73,15 +50,6 @@ export default function SearchPane({ config, setConfig, initialTab, onClose, onO
|
|||
localStorage.setItem('search.tab', String(idx));
|
||||
};
|
||||
|
||||
// Active-transfer UI for the slim bar that lives just under the tab bar.
|
||||
// We only show the bar while a transfer is in flight; once it's ready
|
||||
// (or never started) the bar collapses to zero height.
|
||||
const showLoadBar = loadState.kind === 'engine' || loadState.kind === 'database';
|
||||
const activeLabel = loadState.kind === 'engine' ? 'Loading engine' : 'Loading database';
|
||||
const loadPct = loadState.total && loadState.total > 0
|
||||
? Math.min(100, Math.round((loadState.received / loadState.total) * 100))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<motion.div
|
||||
|
|
@ -98,44 +66,6 @@ export default function SearchPane({ config, setConfig, initialTab, onClose, onO
|
|||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
}}
|
||||
>
|
||||
{/* Locate-database load bar. Visible only while a transfer is in
|
||||
flight; collapses to zero height otherwise so it doesn't take
|
||||
up space when there's nothing to show. */}
|
||||
<div
|
||||
className="flex-shrink-0 overflow-hidden border-b border-neutral-200/70 transition-[max-height,opacity] duration-200 ease-in-out"
|
||||
style={{ maxHeight: showLoadBar ? '64px' : '0px', opacity: showLoadBar ? 1 : 0 }}
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="px-4 py-2 flex items-center gap-3">
|
||||
<Loader2 className="w-3.5 h-3.5 text-blue-500 animate-spin flex-shrink-0" />
|
||||
<Database className="w-3.5 h-3.5 text-neutral-400 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className="text-xs text-neutral-600 font-medium truncate">
|
||||
{activeLabel}…
|
||||
{loadState.received > 0 && (
|
||||
<span className="text-neutral-400 font-normal">
|
||||
{' '}
|
||||
{loadState.total === null
|
||||
? humanFileSize(loadState.received)
|
||||
: `${humanFileSize(loadState.received)} / ${humanFileSize(loadState.total)}`}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{loadPct !== null && (
|
||||
<span className="text-xs text-neutral-500 tabular-nums">{loadPct}%</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-1 bg-neutral-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full bg-blue-500 transition-all duration-150 ease-out ${loadPct === null ? 'animate-pulse w-1/3' : ''}`}
|
||||
style={loadPct !== null ? { width: `${loadPct}%` } : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Swipeable panels */}
|
||||
<div
|
||||
ref={panelRef}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user