Compare commits
9 Commits
65bf69fb2a
...
48f75a1acc
| Author | SHA1 | Date | |
|---|---|---|---|
| 48f75a1acc | |||
| 5c525c3287 | |||
| 8a2100c587 | |||
| 38cb32be4d | |||
| ca8f20c598 | |||
| 251e9a9495 | |||
| b0c37694ba | |||
| eb0357253c | |||
| 7f218f4225 |
|
|
@ -189,6 +189,7 @@ Header search icon opens SearchPane at tab 0 (Local). "Assembly64" AppCard opens
|
||||||
38. **Split config / devices storage** — `settings.ts` loads `/.sys/config.json` and `/.sys/devices.json` in parallel; merges with one-level deep merge (so `devices.iec` from devices.json is merged into `iec` from config.json); saves with split: `devices.iec` → `devices.json`, everything else (including remaining `iec` bus settings) → `config.json`; `beforeunload` flush also split across both files
|
38. **Split config / devices storage** — `settings.ts` loads `/.sys/config.json` and `/.sys/devices.json` in parallel; merges with one-level deep merge (so `devices.iec` from devices.json is merged into `iec` from config.json); saves with split: `devices.iec` → `devices.json`, everything else (including remaining `iec` bus settings) → `config.json`; `beforeunload` flush also split across both files
|
||||||
39. **SerialConsolePage** — xterm.js terminal (`@xterm/xterm` + `@xterm/addon-fit`) over shared `useWs()`; line-buffered input: printable chars echoed locally, `\r` sends buffer, `\x7f` backspaces, `\x03` clears; echo suppression via `echoQueue` ref; tiled icon background; lazy-loaded
|
39. **SerialConsolePage** — xterm.js terminal (`@xterm/xterm` + `@xterm/addon-fit`) over shared `useWs()`; line-buffered input: printable chars echoed locally, `\r` sends buffer, `\x7f` backspaces, `\x03` clears; echo suppression via `echoQueue` ref; tiled icon background; lazy-loaded
|
||||||
40. **LazyLoader component** — `ui/lazy-loader.tsx`: animated progress bar with staged percentage steps (30 → 60 → 80 → 92%) for Suspense fallbacks; replaces inline `PageLoader` in `App.tsx`
|
40. **LazyLoader component** — `ui/lazy-loader.tsx`: animated progress bar with staged percentage steps (30 → 60 → 80 → 92%) for Suspense fallbacks; replaces inline `PageLoader` in `App.tsx`
|
||||||
|
40a. **Pre-React splash loader** — `index.html` now ships an inline `#splash` div inside `#root` that shows a dark backdrop, animated blue progress bar, Meatloaf "M" icon, spinner, and "Loading…" label from the moment the HTML parses. Pure CSS + inline SVG, no extra requests, no JS dependency. A tiny inline `<script>` attaches a `MutationObserver` to `#root` and fades the splash out (250 ms) once React replaces it. If JS or the bundle fails, the splash stays visible — never a blank page.
|
||||||
41. **MediaViewerEditor tiled background** — icon tile overlay (`z-index: -1`) inside a `z-index: 0` stacking context; sub-components (HexEditor, ConfigEditor, CodeEditor) use transparent backgrounds so the tile shows through
|
41. **MediaViewerEditor tiled background** — icon tile overlay (`z-index: -1`) inside a `z-index: 0` stacking context; sub-components (HexEditor, ConfigEditor, CodeEditor) use transparent backgrounds so the tile shows through
|
||||||
42. **HexEditor responsive columns** — `ResizeObserver` on scroll container switches between 8 columns (< 600px) and 16 columns (≥ 600px); address column brightened to `text-neutral-400`
|
42. **HexEditor responsive columns** — `ResizeObserver` on scroll container switches between 8 columns (< 600px) and 16 columns (≥ 600px); address column brightened to `text-neutral-400`
|
||||||
43. **ProfilePage** — `ProfilePage.tsx` replaces the header dropdown; iOS-style grouped list with Preferences → GeneralPage, Notifications (stub), Documentation (stub), About Meatloaf → AboutMeatloafPage, Log Out; profile button in header navigates to `'profile'` page key
|
43. **ProfilePage** — `ProfilePage.tsx` replaces the header dropdown; iOS-style grouped list with Preferences → GeneralPage, Notifications (stub), Documentation (stub), About Meatloaf → AboutMeatloafPage, Log Out; profile button in header navigates to `'profile'` page key
|
||||||
|
|
|
||||||
67
index.html
67
index.html
|
|
@ -18,12 +18,77 @@
|
||||||
<style>
|
<style>
|
||||||
html, body { height: 100%; margin: 0; overscroll-behavior: none; }
|
html, body { height: 100%; margin: 0; overscroll-behavior: none; }
|
||||||
#root { height: 100%; }
|
#root { height: 100%; }
|
||||||
|
/* Pre-React splash: shown from first paint until React mounts and
|
||||||
|
replaces #root. Visually matches the in-app LazyLoader (dark
|
||||||
|
backdrop, blue progress bar, "Loading…" label) so the handoff is
|
||||||
|
seamless. Pure CSS + inline SVG — no JS, no extra requests. */
|
||||||
|
#splash {
|
||||||
|
position: fixed; inset: 0; z-index: 40;
|
||||||
|
background: #0a0a0a;
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
color: #525252; font: 500 12px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
transition: opacity 250ms ease-out;
|
||||||
|
}
|
||||||
|
#splash.gone { opacity: 0; pointer-events: none; }
|
||||||
|
#splash .bar {
|
||||||
|
position: absolute; top: 0; left: 0; right: 0; height: 2px;
|
||||||
|
background: #262626; overflow: hidden;
|
||||||
|
}
|
||||||
|
#splash .bar::after {
|
||||||
|
content: ""; display: block; height: 100%; width: 30%;
|
||||||
|
background: #3b82f6;
|
||||||
|
animation: splash-bar 1500ms ease-in-out infinite;
|
||||||
|
}
|
||||||
|
#splash .ring {
|
||||||
|
width: 24px; height: 24px; margin-bottom: 12px;
|
||||||
|
border: 2px solid #262626; border-top-color: #525252;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: splash-spin 800ms linear infinite;
|
||||||
|
}
|
||||||
|
#splash .icon { width: 48px; height: 48px; margin-bottom: 16px; opacity: 0.7; }
|
||||||
|
@keyframes splash-spin { to { transform: rotate(360deg); } }
|
||||||
|
@keyframes splash-bar { 0% { transform: translateX(-100%); } 100% { transform: translateX(400%); } }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root">
|
||||||
|
<div id="splash" role="status" aria-live="polite" aria-label="Loading Meatloaf Manipulator">
|
||||||
|
<div class="bar"></div>
|
||||||
|
<!-- Meatloaf logo from public/assets/icon.svg, inlined as a data URI
|
||||||
|
so it works under any Vite base path (/, /config/, etc.) and
|
||||||
|
adds no extra HTTP request during the initial bundle load. -->
|
||||||
|
<img class="icon" alt="" aria-hidden="true"
|
||||||
|
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iM2luIiBoZWlnaHQ9IjNpbiIgdmlld0JveD0iMCAwIDYyOS4wMDcgNjI5LjAwNyIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBmaWxsPSIjNGQ0ZDRkIiBzdHJva2U9IiM0ZDRkNGQiIHN0cm9rZS13aWR0aD0iMjcuOTc0IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGQ9Ik0xNC4zMTggMTMuNzQ5aDYwMC42NjZ2NjAxLjA5NUgxNC4zMTh6Ii8+PGcgc3Ryb2tlLXdpZHRoPSIuMTE0Ij48cGF0aCBkPSJNNTE4LjM3IDQ2MC4yOTVsLjAxMiA3NC41MjhoNzQuNTE1di03NC4zeiIgZmlsbD0iI2ZhNTc0YSIgc3Ryb2tlPSIjZmE1NzRhIiBzdHJva2Utd2lkdGg9Ii40Njk3NjY2NCIvPjxwYXRoIGQ9Ik01MTguMzY5IDM2Ni45MDNsLjAxMiA3NC41MjdoNzQuNTE2di03NC4zeiIgZmlsbD0iI2ZjOTE0OSIgc3Ryb2tlPSIjZmM5MTQ5IiBzdHJva2Utd2lkdGg9Ii40Njk3NjY2NCIvPjxwYXRoIGQ9Ik01MTguMzY5IDI3NS45MDNsLjAxMiA3NC41MjdoNzQuNTE2di03NC4zeiIgZmlsbD0iI2VkZTI0YyIgc3Ryb2tlPSIjZWRlMjRjIiBzdHJva2Utd2lkdGg9Ii40Njk3NjY2NCIvPjxwYXRoIGQ9Ik01MTguMzY5IDE4NC45MDNsLjAxMiA3NC41MjhoNzQuNTE2di03NC4zeiIgZmlsbD0iIzkxY2I0MSIgc3Ryb2tlPSIjOTFjYjQxIiBzdHJva2Utd2lkdGg9Ii40Njk3NjY2NCIvPjxwYXRoIGQ9Ik01MTguMzY5IDkzLjg2NGwuMDEyIDc0LjUyOGg3NC41MTZ2LTc0LjN6IiBmaWxsPSIjNzZjNGYyIiBzdHJva2U9IiM3NmM0ZjIiIHN0cm9rZS13aWR0aD0iLjQ2OTc2NjY0Ii8+PC9nPjxnIGZpbGw9IiNmZmZmZmIiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIuMjY1Ij48cGF0aCBkPSJNMzcuMDkyIDk1LjA2NmwyMDYuNjEyIDIwNi42MTIuMzM2IDIzMy4xNTItMjA3LjYyLS4zMzZ6TTQ5Ni40MTggOTUuMDY4TDI4OS44MDUgMzAxLjY4bC0uMzM2IDIzMy4xNTMgMjA3LjYyMS0uMzM2eiIgc3Ryb2tlLXdpZHRoPSIxLjA5MjAwMTQiLz48L2c+PC9zdmc+" />
|
||||||
|
<div class="ring"></div>
|
||||||
|
<span>Loading…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
<script>
|
||||||
|
// Fade the splash as React takes over. React renders into #root and
|
||||||
|
// replaces its children, so we listen for that swap and animate the
|
||||||
|
// old splash out instead of just letting it disappear.
|
||||||
|
(function () {
|
||||||
|
var root = document.getElementById('root');
|
||||||
|
if (!root) return;
|
||||||
|
var splash = document.getElementById('splash');
|
||||||
|
if (!splash) return;
|
||||||
|
// Use a MutationObserver to detect when React replaces the splash.
|
||||||
|
// The splash node is removed entirely when React renders <App />.
|
||||||
|
new MutationObserver(function () {
|
||||||
|
if (!document.getElementById('splash')) return;
|
||||||
|
if (root.firstChild && root.firstChild.id !== 'splash') {
|
||||||
|
splash.classList.add('gone');
|
||||||
|
setTimeout(function () { splash.remove(); }, 300);
|
||||||
|
}
|
||||||
|
}).observe(root, { childList: true });
|
||||||
|
// Hard fallback: if JS-driven React never mounts (network failure,
|
||||||
|
// bundle error, etc.), keep the splash visible rather than showing
|
||||||
|
// a blank page. We do nothing here; the splash is the fallback.
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|
@ -55,7 +55,7 @@ export default function App() {
|
||||||
const [currentPage, setCurrentPage] = useState<Page>('status');
|
const [currentPage, setCurrentPage] = useState<Page>('status');
|
||||||
const { config, setConfig, saveStatus, pendingCount, flushNow, reload } = useSettings();
|
const { config, setConfig, saveStatus, pendingCount, flushNow, reload } = useSettings();
|
||||||
const [showSearch, setShowSearch] = useState(false);
|
const [showSearch, setShowSearch] = useState(false);
|
||||||
const [searchInitialTab, setSearchInitialTab] = useState<0 | 1 | undefined>(undefined);
|
const [searchInitialTab, setSearchInitialTab] = useState<0 | 1 | 2 | 3 | undefined>(undefined);
|
||||||
const [devicesOpenId, setDevicesOpenId] = useState<string | null>(null);
|
const [devicesOpenId, setDevicesOpenId] = useState<string | null>(null);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [fileManagerInitialPath, setFileManagerInitialPath] = useState<string | undefined>(undefined);
|
const [fileManagerInitialPath, setFileManagerInitialPath] = useState<string | undefined>(undefined);
|
||||||
|
|
@ -119,6 +119,8 @@ export default function App() {
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
<AppCard icon={<Folder className="w-7 h-7" />} label="Media Manager" onClick={() => { setFileManagerInitialPath(undefined); setCurrentPage('file-manager'); }} />
|
<AppCard icon={<Folder className="w-7 h-7" />} label="Media Manager" onClick={() => { setFileManagerInitialPath(undefined); setCurrentPage('file-manager'); }} />
|
||||||
<AppCard icon={<Database className="w-7 h-7" />} label="Assembly64" onClick={() => { setSearchInitialTab(1); setShowSearch(true); }} />
|
<AppCard icon={<Database className="w-7 h-7" />} label="Assembly64" onClick={() => { setSearchInitialTab(1); setShowSearch(true); }} />
|
||||||
|
<AppCard icon={<Database className="w-7 h-7" />} label="CommoServe" onClick={() => { setSearchInitialTab(2); setShowSearch(true); }} />
|
||||||
|
<AppCard icon={<Database className="w-7 h-7" />} label="CSDb" onClick={() => { setSearchInitialTab(3); setShowSearch(true); }} />
|
||||||
<AppCard icon={<Printer className="w-7 h-7" />} label="Print Manager" onClick={() => setCurrentPage('print-manager')} />
|
<AppCard icon={<Printer className="w-7 h-7" />} label="Print Manager" onClick={() => setCurrentPage('print-manager')} />
|
||||||
<AppCard icon={<Terminal className="w-7 h-7" />} label="Serial Console" onClick={() => setCurrentPage('serial-console')} />
|
<AppCard icon={<Terminal className="w-7 h-7" />} label="Serial Console" onClick={() => setCurrentPage('serial-console')} />
|
||||||
<AppCard icon={<Link className="w-7 h-7" />} label="Short Codes" onClick={() => setCurrentPage('serial-console')} />
|
<AppCard icon={<Link className="w-7 h-7" />} label="Short Codes" onClick={() => setCurrentPage('serial-console')} />
|
||||||
|
|
|
||||||
|
|
@ -358,7 +358,7 @@ export default function SearchAssembly64({ config, setConfig, onClose: _onClose
|
||||||
|
|
||||||
{/* Presets */}
|
{/* Presets */}
|
||||||
{presets.length > 0 && (
|
{presets.length > 0 && (
|
||||||
<div className="flex gap-1.5 mb-2 overflow-x-auto pb-0.5 scrollbar-none">
|
<div className="flex gap-1.5 mb-2 overflow-x-auto pb-0.5" style={{ scrollbarWidth: 'none' }}>
|
||||||
{presets
|
{presets
|
||||||
.filter(group => PRESET_PREFIX[group.type] !== null) // hide sort/order for now
|
.filter(group => PRESET_PREFIX[group.type] !== null) // hide sort/order for now
|
||||||
.map((group, i) => (
|
.map((group, i) => (
|
||||||
|
|
@ -376,7 +376,7 @@ export default function SearchAssembly64({ config, setConfig, onClose: _onClose
|
||||||
|
|
||||||
{/* Category filter */}
|
{/* Category filter */}
|
||||||
{categories.length > 0 && (
|
{categories.length > 0 && (
|
||||||
<div className="flex gap-1.5 overflow-x-auto pb-0.5 scrollbar-none">
|
<div className="flex gap-1.5 overflow-x-auto pb-0.5" style={{ scrollbarWidth: 'none' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setCategoryFilter(null); if (hasSearched) doSearch(query, null, 0); }}
|
onClick={() => { setCategoryFilter(null); if (hasSearched) doSearch(query, null, 0); }}
|
||||||
className={`flex-shrink-0 px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${categoryFilter === null ? 'bg-blue-600 text-white' : 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'}`}
|
className={`flex-shrink-0 px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${categoryFilter === null ? 'bg-blue-600 text-white' : 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'}`}
|
||||||
|
|
|
||||||
432
src/app/components/SearchCSDbNG.tsx
Normal file
432
src/app/components/SearchCSDbNG.tsx
Normal file
|
|
@ -0,0 +1,432 @@
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Search, Loader2, HardDrive, Download, ChevronRight } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { basename, joinPath, putFileContents } from '../webdav';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
||||||
|
import { MarqueeText } from './ui/marquee-text';
|
||||||
|
|
||||||
|
// ─── API ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CSDB_API = 'https://api.idolpx.com/csdb';
|
||||||
|
const CSDB_HOST = 'https://csdb.dk';
|
||||||
|
const DOWNLOAD_DIR = '/sd/downloads';
|
||||||
|
|
||||||
|
function resolveLink(link: string): string {
|
||||||
|
if (link.startsWith('http://') || link.startsWith('https://')) return link;
|
||||||
|
return CSDB_HOST + link;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface CsdbRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tags: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CsdbDownloadLink {
|
||||||
|
Link: string;
|
||||||
|
CounterLink: string;
|
||||||
|
Downloads: number;
|
||||||
|
Status: string;
|
||||||
|
Filename: string;
|
||||||
|
hash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CsdbRelease {
|
||||||
|
ID: number;
|
||||||
|
Name: string;
|
||||||
|
Type: string;
|
||||||
|
ScreenShot: string[];
|
||||||
|
DownloadLinks: CsdbDownloadLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface SearchCSDbNGProps {
|
||||||
|
config: any;
|
||||||
|
setConfig: (c: any) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Module-level persistence ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const _store = {
|
||||||
|
query: '',
|
||||||
|
results: [] as CsdbRow[],
|
||||||
|
hasSearched: false,
|
||||||
|
tagFilter: null as string | null,
|
||||||
|
scrollTop: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// "c64 crack" → "crack", "c64 music collection" → "music collection"
|
||||||
|
function typeLabel(tags: string): string {
|
||||||
|
return tags.split(' ').slice(1).join(' ') || tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function SearchCSDbNG({ config, setConfig, onClose: _onClose }: SearchCSDbNGProps) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [query, setQuery] = useState(() => _store.query);
|
||||||
|
const [results, setResults] = useState<CsdbRow[]>(() => _store.results);
|
||||||
|
const [hasSearched, setHasSearched] = useState(() => _store.hasSearched);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [searchError, setSearchError] = useState<string | null>(null);
|
||||||
|
const [tagFilter, setTagFilter] = useState<string | null>(() => _store.tagFilter);
|
||||||
|
|
||||||
|
const [selectedRow, setSelectedRow] = useState<CsdbRow | null>(null);
|
||||||
|
const [release, setRelease] = useState<CsdbRelease | null>(null);
|
||||||
|
const [loadingRelease, setLoadingRelease] = useState(false);
|
||||||
|
|
||||||
|
const [mountEntry, setMountEntry] = useState<{ row: CsdbRow; link: CsdbDownloadLink } | null>(null);
|
||||||
|
const [downloading, setDownloading] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
_store.query = query;
|
||||||
|
_store.results = results;
|
||||||
|
_store.hasSearched = hasSearched;
|
||||||
|
_store.tagFilter = tagFilter;
|
||||||
|
}, [query, results, hasSearched, tagFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (_store.scrollTop > 0 && scrollRef.current)
|
||||||
|
scrollRef.current.scrollTop = _store.scrollTop;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Search ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const doSearch = async (q: string) => {
|
||||||
|
if (!q.trim()) return;
|
||||||
|
setIsSearching(true);
|
||||||
|
setSearchError(null);
|
||||||
|
setTagFilter(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${CSDB_API}/search/${encodeURIComponent(q.trim())}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data: { count: number; rows: CsdbRow[] } = await res.json();
|
||||||
|
setResults(data.rows ?? []);
|
||||||
|
setHasSearched(true);
|
||||||
|
} catch (e: any) {
|
||||||
|
setSearchError(e?.message ?? 'Search failed');
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Release detail ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const openRelease = async (row: CsdbRow) => {
|
||||||
|
setSelectedRow(row);
|
||||||
|
setRelease(null);
|
||||||
|
setLoadingRelease(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${CSDB_API}/release/${row.id}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data: CsdbRelease = await res.json();
|
||||||
|
setRelease(data);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(`Failed to load release: ${e?.message ?? e}`);
|
||||||
|
setRelease({ ID: +row.id, Name: row.name, Type: '', ScreenShot: [], DownloadLinks: [] });
|
||||||
|
} finally {
|
||||||
|
setLoadingRelease(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Download to SD ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const downloadToSd = async (row: CsdbRow, link: CsdbDownloadLink) => {
|
||||||
|
const url = resolveLink(link.Link);
|
||||||
|
setDownloading(url);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = await res.arrayBuffer();
|
||||||
|
const fname = link.Filename || basename(link.Link) || row.name;
|
||||||
|
const dest = joinPath(DOWNLOAD_DIR, fname);
|
||||||
|
await putFileContents(dest, data);
|
||||||
|
toast.success(`Saved to ${dest}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(`Download failed: ${e?.message ?? e}`);
|
||||||
|
} finally {
|
||||||
|
setDownloading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Mount ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const mountOnDevice = (deviceType: 'drive' | 'meatloaf', key: string) => {
|
||||||
|
if (!mountEntry) return;
|
||||||
|
const { row, link } = mountEntry;
|
||||||
|
const newConfig = JSON.parse(JSON.stringify(config));
|
||||||
|
if (!newConfig.devices) newConfig.devices = {};
|
||||||
|
if (!newConfig.devices.iec) newConfig.devices.iec = {};
|
||||||
|
if (!newConfig.devices.iec[key]) newConfig.devices.iec[key] = { type: deviceType };
|
||||||
|
const dev = newConfig.devices.iec[key];
|
||||||
|
dev.url = resolveLink(link.Link);
|
||||||
|
delete dev.media_set;
|
||||||
|
if (!dev.enabled) dev.enabled = 1;
|
||||||
|
setConfig(newConfig);
|
||||||
|
setMountEntry(null);
|
||||||
|
toast.success(`Mounted "${link.Filename || row.name}" on ${deviceType} #${key}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mountedUrls = useMemo(() => {
|
||||||
|
const s = new Set<string>();
|
||||||
|
for (const d of Object.values(config?.devices?.iec ?? {})) {
|
||||||
|
const dev = d as any;
|
||||||
|
if (dev?.url) s.add(dev.url);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
// ── Derived ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const tagTypes = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const r of results) set.add(typeLabel(r.tags));
|
||||||
|
return [...set].sort();
|
||||||
|
}, [results]);
|
||||||
|
|
||||||
|
const visibleResults = useMemo(() =>
|
||||||
|
tagFilter ? results.filter(r => typeLabel(r.tags) === tagFilter) : results,
|
||||||
|
[results, tagFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex-shrink-0 px-4 pt-3 pb-3 border-b border-neutral-200/70">
|
||||||
|
<div className="flex gap-2 mb-2">
|
||||||
|
<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" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={e => setQuery(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && !isSearching && doSearch(query)}
|
||||||
|
placeholder="Search CSDb…"
|
||||||
|
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"
|
||||||
|
disabled={isSearching}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => doSearch(query)}
|
||||||
|
disabled={isSearching || !query.trim()}
|
||||||
|
className="px-4 py-2.5 bg-blue-600 text-white rounded-xl text-sm font-medium hover:bg-blue-700 disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
{isSearching ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Search'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type filter chips */}
|
||||||
|
{tagTypes.length > 1 && (
|
||||||
|
<div className="flex gap-1.5 overflow-x-auto pb-0.5" style={{ scrollbarWidth: 'none' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setTagFilter(null)}
|
||||||
|
className={`flex-shrink-0 px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${tagFilter === null ? 'bg-blue-600 text-white' : 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{tagTypes.map(t => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setTagFilter(tagFilter === t ? null : t)}
|
||||||
|
className={`flex-shrink-0 px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${tagFilter === t ? 'bg-blue-600 text-white' : 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'}`}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="flex-1 overflow-y-auto"
|
||||||
|
onScroll={e => { _store.scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; }}
|
||||||
|
>
|
||||||
|
{isSearching && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||||
|
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
|
||||||
|
<p className="text-sm text-neutral-500">Searching…</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isSearching && searchError && (
|
||||||
|
<div className="p-6 text-center">
|
||||||
|
<p className="text-sm text-red-500">{searchError}</p>
|
||||||
|
<button onClick={() => doSearch(query)} className="mt-2 text-xs text-blue-600 hover:underline">
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isSearching && !searchError && hasSearched && (
|
||||||
|
visibleResults.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-neutral-400 px-4 py-2 border-b border-neutral-100">
|
||||||
|
{visibleResults.length}{tagFilter ? ` of ${results.length}` : ''} result{visibleResults.length !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
{visibleResults.map(row => (
|
||||||
|
<button
|
||||||
|
key={row.id}
|
||||||
|
onClick={() => openRelease(row)}
|
||||||
|
className="w-full pl-4 pr-4 py-3 flex items-center gap-3 border-b border-neutral-100 border-l-2 border-l-transparent transition-colors hover:bg-blue-50 hover:border-l-blue-400 text-left"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-sm font-medium text-neutral-900 truncate">{row.name}</span>
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 font-mono flex-shrink-0">{typeLabel(row.tags)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-4 h-4 text-neutral-300 flex-shrink-0" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="py-16 text-center">
|
||||||
|
<Search className="w-10 h-10 mx-auto mb-3 text-neutral-300" />
|
||||||
|
<p className="text-sm text-neutral-500">No results</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isSearching && !hasSearched && (
|
||||||
|
<div className="py-16 text-center px-6">
|
||||||
|
<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 the CSDb database</p>
|
||||||
|
<p className="text-xs text-neutral-400">Commodore Scene Database — games, demos, music and more</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Release detail dialog */}
|
||||||
|
<Dialog open={selectedRow !== null} onOpenChange={open => !open && setSelectedRow(null)}>
|
||||||
|
<DialogContent className="max-w-sm flex flex-col max-h-[90dvh]">
|
||||||
|
<DialogHeader className="flex-shrink-0 overflow-hidden min-w-0">
|
||||||
|
<DialogTitle className="sr-only">{selectedRow?.name}</DialogTitle>
|
||||||
|
<p className="text-lg font-semibold leading-none pr-6 overflow-hidden min-w-0">
|
||||||
|
<MarqueeText>{selectedRow?.name}</MarqueeText>
|
||||||
|
</p>
|
||||||
|
<DialogDescription>
|
||||||
|
{[release?.Type, selectedRow?.tags.split(' ')[0]?.toUpperCase()].filter(Boolean).join(' · ')}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="overflow-y-auto flex-1 min-h-0">
|
||||||
|
{loadingRelease && (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-blue-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loadingRelease && release?.DownloadLinks.length === 0 && (
|
||||||
|
<p className="text-sm text-neutral-400 text-center py-8">No download links found</p>
|
||||||
|
)}
|
||||||
|
{!loadingRelease && release && release.DownloadLinks.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2 py-1">
|
||||||
|
{release.DownloadLinks.map((link, i) => {
|
||||||
|
const url = resolveLink(link.Link);
|
||||||
|
const isMounted = mountedUrls.has(url);
|
||||||
|
const fname = link.Filename || basename(link.Link) || selectedRow!.name;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`px-4 py-3 rounded-lg border flex items-center gap-3 ${isMounted ? 'border-blue-300 bg-blue-50' : 'border-neutral-200'}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-neutral-800 truncate">{fname}</div>
|
||||||
|
<div className="text-xs text-neutral-400">
|
||||||
|
{link.Downloads > 0 ? `${link.Downloads.toLocaleString()} downloads` : link.Status}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => downloadToSd(selectedRow!, link)}
|
||||||
|
disabled={downloading === url}
|
||||||
|
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 === url
|
||||||
|
? <Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
: <Download className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMountEntry({ row: selectedRow!, link })}
|
||||||
|
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>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Mount device picker */}
|
||||||
|
<Dialog open={mountEntry !== null} onOpenChange={open => !open && setMountEntry(null)}>
|
||||||
|
<DialogContent className="max-w-sm flex flex-col max-h-[90dvh]">
|
||||||
|
<DialogHeader className="flex-shrink-0">
|
||||||
|
<DialogTitle>Mount on Virtual Drive</DialogTitle>
|
||||||
|
<DialogDescription className="truncate">
|
||||||
|
{mountEntry ? (mountEntry.link.Filename || mountEntry.row.name) : ''}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="overflow-y-auto flex-1 min-h-0">
|
||||||
|
{(() => {
|
||||||
|
const allDevices = Object.entries(config?.devices?.iec ?? {});
|
||||||
|
const drives = allDevices
|
||||||
|
.filter(([, v]: [string, any]) => (v as any)?.type === 'drive')
|
||||||
|
.map(([k, v]: [string, any]) => ({ type: 'drive' as const, key: k, base_url: v?.base_url as string | undefined, url: v?.url as string | undefined, enabled: !!v?.enabled }));
|
||||||
|
const meatloafs = allDevices
|
||||||
|
.filter(([, v]: [string, any]) => (v as any)?.type === 'meatloaf')
|
||||||
|
.map(([k, v]: [string, any]) => ({ type: 'meatloaf' as const, key: k, base_url: v?.base_url as string | undefined, url: v?.url as string | undefined, enabled: !!v?.enabled }));
|
||||||
|
const devices = [...drives, ...meatloafs];
|
||||||
|
if (!devices.length)
|
||||||
|
return <p className="text-sm text-neutral-500 text-center py-4">No drive devices found in config.</p>;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{devices.map(dev => (
|
||||||
|
<button
|
||||||
|
key={`${dev.type}-${dev.key}`}
|
||||||
|
onClick={() => mountOnDevice(dev.type, dev.key)}
|
||||||
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<HardDrive className={`w-5 h-5 flex-shrink-0 ${dev.enabled ? 'text-blue-500' : 'text-neutral-400'}`} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-medium text-sm">Device #{dev.key}</div>
|
||||||
|
{(dev.base_url || dev.url) && (
|
||||||
|
<div
|
||||||
|
className="text-xs text-neutral-500 overflow-hidden whitespace-nowrap"
|
||||||
|
style={{ direction: 'rtl', textOverflow: 'ellipsis' }}
|
||||||
|
title={[dev.base_url, dev.url].filter(Boolean).join('')}
|
||||||
|
>
|
||||||
|
<span style={{ direction: 'ltr', unicodeBidi: 'embed' }}>
|
||||||
|
{[dev.base_url, dev.url].filter(Boolean).join('')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!dev.enabled && <span className="text-xs text-neutral-400 flex-shrink-0">disabled</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
675
src/app/components/SearchCommoServe.tsx
Normal file
675
src/app/components/SearchCommoServe.tsx
Normal file
|
|
@ -0,0 +1,675 @@
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Search, Loader2, HardDrive, Download, ChevronRight, ChevronDown, Trophy, Calendar, Users, RefreshCw, HelpCircle } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { humanFileSize, basename, joinPath, putFileContents } from '../webdav';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
||||||
|
import { MarqueeText } from './ui/marquee-text';
|
||||||
|
|
||||||
|
// ─── API ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const LEET_BASE = 'https://commoserve.files.commodore.net/leet';
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
const DOWNLOAD_DIR = '/sd/downloads';
|
||||||
|
|
||||||
|
function leetFetch(path: string, query?: Record<string, string>) {
|
||||||
|
const url = new URL(LEET_BASE + path);
|
||||||
|
if (query) Object.entries(query).forEach(([k, v]) => url.searchParams.set(k, v));
|
||||||
|
return fetch(url.toString(), {
|
||||||
|
headers: {
|
||||||
|
'Client-Id': 'Ultimate',
|
||||||
|
'User-Agent': 'Assembly Query',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function leetJson<T>(path: string, query?: Record<string, string>): Promise<T> {
|
||||||
|
const res = await leetFetch(path, query);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || (data as any).errorCode) throw new Error(`Leet API error ${(data as any).errorCode ?? res.status}`);
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ContentItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: number;
|
||||||
|
group?: string;
|
||||||
|
handle?: string;
|
||||||
|
year?: number;
|
||||||
|
country?: string;
|
||||||
|
event?: string;
|
||||||
|
rating?: number;
|
||||||
|
siteRating?: number;
|
||||||
|
place?: number;
|
||||||
|
compo?: number;
|
||||||
|
updated?: string;
|
||||||
|
released?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContentEntry {
|
||||||
|
id: number;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
date: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryMapping {
|
||||||
|
id: number;
|
||||||
|
name?: string;
|
||||||
|
title?: string;
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PresetValue {
|
||||||
|
aqlKey: string;
|
||||||
|
name?: string;
|
||||||
|
id?: number;
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PresetGroup {
|
||||||
|
type: string;
|
||||||
|
description: string;
|
||||||
|
values: PresetValue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRESET_PREFIX: Record<string, string | null> = {
|
||||||
|
repo: 'repo',
|
||||||
|
category: 'category',
|
||||||
|
subcat: 'subcat',
|
||||||
|
rating: 'rating',
|
||||||
|
type: 'type',
|
||||||
|
date: 'year',
|
||||||
|
latest: 'added',
|
||||||
|
sort: null,
|
||||||
|
order: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface SearchCommoServeProps {
|
||||||
|
config: any;
|
||||||
|
setConfig: (c: any) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Module-level persistence ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const _store = {
|
||||||
|
query: '',
|
||||||
|
results: [] as ContentItem[],
|
||||||
|
offset: 0,
|
||||||
|
hasMore: false,
|
||||||
|
hasSearched: false,
|
||||||
|
categoryFilter: null as number | null,
|
||||||
|
scrollTop: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function entryFilename(e: ContentEntry): string {
|
||||||
|
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 }) {
|
||||||
|
if (!value) return null;
|
||||||
|
const pct = Math.round((value / max) * 5);
|
||||||
|
return (
|
||||||
|
<span className="text-amber-400 text-xs">
|
||||||
|
{'★'.repeat(pct)}{'☆'.repeat(5 - pct)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AQL reference ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const AQL_TERMS = [
|
||||||
|
{ term: 'name:', label: 'Name', example: 'name:manic*', description: 'Title of the release. Supports wildcards (*).' },
|
||||||
|
{ term: 'group:', label: 'Group', example: 'group:triad', description: 'Group or organization name.' },
|
||||||
|
{ term: 'handle:', label: 'Handle', example: 'handle:jco', description: 'Author or creator handle.' },
|
||||||
|
{ term: 'year:', label: 'Year', example: 'year:1983', description: 'Release year.' },
|
||||||
|
{ term: 'event:', label: 'Event', example: 'event:assembly', description: 'Party or event name.' },
|
||||||
|
{ term: 'country:', label: 'Country', example: 'country:SE', description: 'Country code (ISO 3166-1 alpha-2).' },
|
||||||
|
{ term: 'category:', label: 'Category', example: 'category:1', description: 'Category ID number.' },
|
||||||
|
{ term: 'compo:', label: 'Compo', example: 'compo:demo', description: 'Competition or compo type.' },
|
||||||
|
{ term: 'place:', label: 'Place', example: 'place:1', description: 'Placement in competition (1 = winner).' },
|
||||||
|
{ term: 'rating:', label: 'Rating', example: 'rating:9', description: 'Internal rating value.' },
|
||||||
|
{ term: 'siteRating:', label: 'Site Rating', example: 'siteRating:8', description: 'Site (user) rating value.' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function SearchCommoServe({ config, setConfig, onClose: _onClose }: SearchCommoServeProps) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [showAqlHelp, setShowAqlHelp] = useState(false);
|
||||||
|
|
||||||
|
const [query, setQuery] = useState(() => _store.query);
|
||||||
|
const [results, setResults] = useState<ContentItem[]>(() => _store.results);
|
||||||
|
const [offset, setOffset] = useState(() => _store.offset);
|
||||||
|
const [hasMore, setHasMore] = useState(() => _store.hasMore);
|
||||||
|
const [hasSearched, setHasSearched] = useState(() => _store.hasSearched);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
|
const [searchError, setSearchError] = useState<string | null>(null);
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState<number | null>(() => _store.categoryFilter);
|
||||||
|
|
||||||
|
const [categories, setCategories] = useState<CategoryMapping[]>([]);
|
||||||
|
const [presets, setPresets] = useState<PresetGroup[]>([]);
|
||||||
|
const [activePreset, setActivePreset] = useState<PresetGroup | null>(null);
|
||||||
|
|
||||||
|
const [selectedItem, setSelectedItem] = useState<ContentItem | null>(null);
|
||||||
|
const [entries, setEntries] = useState<ContentEntry[] | null>(null);
|
||||||
|
const [loadingEntries, setLoadingEntries] = useState(false);
|
||||||
|
|
||||||
|
const [mountEntry, setMountEntry] = useState<{ item: ContentItem; entry: ContentEntry } | null>(null);
|
||||||
|
const [downloading, setDownloading] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
_store.query = query;
|
||||||
|
_store.results = results;
|
||||||
|
_store.offset = offset;
|
||||||
|
_store.hasMore = hasMore;
|
||||||
|
_store.hasSearched = hasSearched;
|
||||||
|
_store.categoryFilter = categoryFilter;
|
||||||
|
}, [query, results, offset, hasMore, hasSearched, categoryFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (_store.scrollTop > 0 && scrollRef.current)
|
||||||
|
scrollRef.current.scrollTop = _store.scrollTop;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
leetJson<CategoryMapping[]>('/search/categories').then(setCategories).catch(() => {});
|
||||||
|
leetJson<PresetGroup[]>('/search/aql/presets').then(setPresets).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const categoryName = useMemo(() => {
|
||||||
|
const map: Record<number, string> = {};
|
||||||
|
for (const c of categories) map[c.id] = (c.name ?? c.title ?? String(c.id)) as string;
|
||||||
|
return map;
|
||||||
|
}, [categories]);
|
||||||
|
|
||||||
|
// ── Search ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const doSearch = async (q: string, cat: number | null, fromOffset: number, append = false) => {
|
||||||
|
if (!append) setIsSearching(true);
|
||||||
|
else setIsLoadingMore(true);
|
||||||
|
setSearchError(null);
|
||||||
|
try {
|
||||||
|
let aql = q.trim();
|
||||||
|
if (cat !== null) aql = aql ? `${aql} category:${cat}` : `category:${cat}`;
|
||||||
|
const data = await leetJson<ContentItem[]>(
|
||||||
|
`/search/aql/${fromOffset}/${PAGE_SIZE}`,
|
||||||
|
aql ? { query: aql } : undefined,
|
||||||
|
);
|
||||||
|
if (append) setResults(prev => [...prev, ...data]);
|
||||||
|
else setResults(data);
|
||||||
|
setOffset(fromOffset + data.length);
|
||||||
|
setHasMore(data.length === PAGE_SIZE);
|
||||||
|
setHasSearched(true);
|
||||||
|
} catch (e: any) {
|
||||||
|
setSearchError(e?.message ?? 'Search failed');
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
setIsLoadingMore(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = () => doSearch(query, categoryFilter, 0);
|
||||||
|
const handleLoadMore = () => doSearch(query, categoryFilter, offset, true);
|
||||||
|
|
||||||
|
const applyPreset = (group: PresetGroup, value: PresetValue) => {
|
||||||
|
const prefix = PRESET_PREFIX[group.type];
|
||||||
|
setActivePreset(null);
|
||||||
|
if (prefix === null || prefix === undefined) return;
|
||||||
|
const token = value.aqlKey.includes(':') ? value.aqlKey : `${prefix}:${value.aqlKey}`;
|
||||||
|
const trimmed = query.trim();
|
||||||
|
const next = trimmed ? `${trimmed} ${token}` : token;
|
||||||
|
setQuery(next);
|
||||||
|
doSearch(next, categoryFilter, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Item entries ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const openItem = async (item: ContentItem) => {
|
||||||
|
setSelectedItem(item);
|
||||||
|
setEntries(null);
|
||||||
|
setLoadingEntries(true);
|
||||||
|
try {
|
||||||
|
const data = await leetJson<ContentEntry[] | { contentEntry: ContentEntry[] }>(
|
||||||
|
`/search/entries/${item.id}/${item.category}`,
|
||||||
|
);
|
||||||
|
const list = Array.isArray(data) ? data : (data.contentEntry ?? []);
|
||||||
|
setEntries(list);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(`Failed to load entries: ${e?.message ?? e}`);
|
||||||
|
setEntries([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingEntries(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Download to SD ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const downloadToSd = async (item: ContentItem, entry: ContentEntry) => {
|
||||||
|
setDownloading(entry.id);
|
||||||
|
try {
|
||||||
|
const url = downloadUrl(item, entry);
|
||||||
|
const res = await fetch(url);
|
||||||
|
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 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const mountOnDevice = (deviceType: 'drive' | 'meatloaf', key: string) => {
|
||||||
|
if (!mountEntry) return;
|
||||||
|
const { item, entry } = mountEntry;
|
||||||
|
const newConfig = JSON.parse(JSON.stringify(config));
|
||||||
|
if (!newConfig.devices) newConfig.devices = {};
|
||||||
|
if (!newConfig.devices.iec) newConfig.devices.iec = {};
|
||||||
|
if (!newConfig.devices.iec[key]) newConfig.devices.iec[key] = { type: deviceType };
|
||||||
|
const dev = newConfig.devices.iec[key];
|
||||||
|
dev.url = downloadUrl(item, entry);
|
||||||
|
delete dev.media_set;
|
||||||
|
if (!dev.enabled) dev.enabled = 1;
|
||||||
|
setConfig(newConfig);
|
||||||
|
setMountEntry(null);
|
||||||
|
toast.success(`Mounted "${entryFilename(entry)}" on ${deviceType} #${key}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mountedUrls = useMemo(() => {
|
||||||
|
const s = new Set<string>();
|
||||||
|
for (const d of Object.values(config?.devices?.iec ?? {})) {
|
||||||
|
const dev = d as any;
|
||||||
|
if (dev?.url) s.add(dev.url);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex-shrink-0 px-4 pt-3 pb-3 border-b border-neutral-200/70">
|
||||||
|
<div className="flex gap-2 mb-2">
|
||||||
|
<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" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={e => setQuery(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && !isSearching && handleSearch()}
|
||||||
|
placeholder="name:manic* group:ultimate year:1983…"
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAqlHelp(true)}
|
||||||
|
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"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<HelpCircle className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSearch}
|
||||||
|
disabled={isSearching}
|
||||||
|
className="px-4 py-2.5 bg-blue-600 text-white rounded-xl text-sm font-medium hover:bg-blue-700 disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
{isSearching ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Search'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{presets.length > 0 && (
|
||||||
|
<div className="flex gap-1.5 mb-2 overflow-x-auto pb-0.5" style={{ scrollbarWidth: 'none' }}>
|
||||||
|
{presets
|
||||||
|
.filter(group => PRESET_PREFIX[group.type] !== null)
|
||||||
|
.map((group, i) => (
|
||||||
|
<button
|
||||||
|
key={`${group.type}-${i}`}
|
||||||
|
onClick={() => setActivePreset(group)}
|
||||||
|
className="flex-shrink-0 px-2.5 py-0.5 rounded-full text-xs font-medium bg-neutral-100 text-neutral-600 hover:bg-neutral-200 transition-colors inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{group.description}
|
||||||
|
<ChevronDown className="w-3 h-3 opacity-60" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{categories.length > 0 && (
|
||||||
|
<div className="flex gap-1.5 overflow-x-auto pb-0.5" style={{ scrollbarWidth: 'none' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => { setCategoryFilter(null); if (hasSearched) doSearch(query, null, 0); }}
|
||||||
|
className={`flex-shrink-0 px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${categoryFilter === null ? 'bg-blue-600 text-white' : 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{categories.map(c => {
|
||||||
|
const name = (c.name ?? c.title ?? String(c.id)) as string;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => { setCategoryFilter(c.id); if (hasSearched) doSearch(query, c.id, 0); }}
|
||||||
|
className={`flex-shrink-0 px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${categoryFilter === c.id ? 'bg-blue-600 text-white' : 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'}`}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="flex-1 overflow-y-auto"
|
||||||
|
onScroll={e => { _store.scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; }}
|
||||||
|
>
|
||||||
|
{isSearching && !hasSearched && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||||
|
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
|
||||||
|
<p className="text-sm text-neutral-500">Searching…</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searchError && (
|
||||||
|
<div className="p-6 text-center">
|
||||||
|
<p className="text-sm text-red-500">{searchError}</p>
|
||||||
|
<button onClick={handleSearch} className="mt-2 inline-flex items-center gap-1 text-xs text-blue-600">
|
||||||
|
<RefreshCw className="w-3 h-3" /> Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!searchError && hasSearched && (
|
||||||
|
<>
|
||||||
|
{results.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-neutral-400 px-4 py-2 border-b border-neutral-100 flex items-center gap-1.5">
|
||||||
|
{isSearching && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||||
|
{results.length} result{results.length !== 1 ? 's' : ''}{hasMore ? '+' : ''}
|
||||||
|
</p>
|
||||||
|
{results.map(item => {
|
||||||
|
const catLabel = categoryName[item.category] ?? `Cat ${item.category}`;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => openItem(item)}
|
||||||
|
className="w-full pl-4 pr-4 py-3 flex items-center gap-3 border-b border-neutral-100 border-l-2 border-l-transparent transition-colors hover:bg-blue-50 hover:border-l-blue-400 text-left"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-sm font-medium text-neutral-900 truncate">{item.name}</span>
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 font-mono flex-shrink-0">{catLabel}</span>
|
||||||
|
{item.place === 1 && <Trophy className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||||
|
{item.group && (
|
||||||
|
<span className="text-xs text-neutral-400 flex items-center gap-0.5">
|
||||||
|
<Users className="w-3 h-3" />{item.group}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.handle && !item.group && (
|
||||||
|
<span className="text-xs text-neutral-400">{item.handle}</span>
|
||||||
|
)}
|
||||||
|
{item.year && (
|
||||||
|
<span className="text-xs text-neutral-400 flex items-center gap-0.5">
|
||||||
|
<Calendar className="w-3 h-3" />{item.year}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.event && (
|
||||||
|
<span className="text-xs text-neutral-400 truncate">{item.event}</span>
|
||||||
|
)}
|
||||||
|
{item.place && (
|
||||||
|
<span className="text-xs text-neutral-400">#{item.place}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0 flex flex-col items-end gap-0.5">
|
||||||
|
{item.siteRating != null && <RatingStars value={item.siteRating} />}
|
||||||
|
<ChevronRight className="w-4 h-4 text-neutral-300" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<div className="p-4 flex justify-center">
|
||||||
|
<button
|
||||||
|
onClick={handleLoadMore}
|
||||||
|
disabled={isLoadingMore}
|
||||||
|
className="px-6 py-2 bg-neutral-100 rounded-xl text-sm text-neutral-600 hover:bg-neutral-200 disabled:opacity-40 inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{isLoadingMore ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||||
|
Load more
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="py-16 text-center">
|
||||||
|
<Search className="w-10 h-10 mx-auto mb-3 text-neutral-300" />
|
||||||
|
<p className="text-sm text-neutral-500">No results</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasSearched && !isSearching && (
|
||||||
|
<div className="py-16 text-center px-6">
|
||||||
|
<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 the CommoServe database</p>
|
||||||
|
<p className="text-xs text-neutral-400">
|
||||||
|
Use AQL syntax: <code className="bg-neutral-100 px-1 rounded">name:manic*</code>,{' '}
|
||||||
|
<code className="bg-neutral-100 px-1 rounded">group:triad</code>,{' '}
|
||||||
|
<code className="bg-neutral-100 px-1 rounded">year:1983</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Item entries dialog */}
|
||||||
|
<Dialog open={selectedItem !== null} onOpenChange={open => !open && setSelectedItem(null)}>
|
||||||
|
<DialogContent className="max-w-sm flex flex-col max-h-[90dvh]">
|
||||||
|
<DialogHeader className="flex-shrink-0 overflow-hidden min-w-0">
|
||||||
|
<DialogTitle className="sr-only">{selectedItem?.name}</DialogTitle>
|
||||||
|
<p className="text-lg font-semibold leading-none pr-6 overflow-hidden min-w-0">
|
||||||
|
<MarqueeText>{selectedItem?.name}</MarqueeText>
|
||||||
|
</p>
|
||||||
|
<DialogDescription>
|
||||||
|
{[selectedItem?.group, selectedItem?.year, selectedItem?.event].filter(Boolean).join(' · ')}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="overflow-y-auto flex-1 min-h-0">
|
||||||
|
{loadingEntries && (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-blue-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loadingEntries && entries?.length === 0 && (
|
||||||
|
<p className="text-sm text-neutral-400 text-center py-8">No files found</p>
|
||||||
|
)}
|
||||||
|
{!loadingEntries && entries && entries.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2 py-1">
|
||||||
|
{entries.map(entry => {
|
||||||
|
const fname = entryFilename(entry);
|
||||||
|
const isMounted = mountedUrls.has(downloadUrl(selectedItem!, entry));
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
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'}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-neutral-800 truncate">{fname}</div>
|
||||||
|
<div className="text-xs text-neutral-400">{humanFileSize(entry.size)}</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
|
||||||
|
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>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Mount device picker */}
|
||||||
|
<Dialog open={mountEntry !== null} onOpenChange={open => !open && setMountEntry(null)}>
|
||||||
|
<DialogContent className="max-w-sm flex flex-col max-h-[90dvh]">
|
||||||
|
<DialogHeader className="flex-shrink-0">
|
||||||
|
<DialogTitle>Mount on Virtual Drive</DialogTitle>
|
||||||
|
<DialogDescription className="truncate">
|
||||||
|
{mountEntry ? entryFilename(mountEntry.entry) : ''}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="overflow-y-auto flex-1 min-h-0">
|
||||||
|
{(() => {
|
||||||
|
const allDevices = Object.entries(config?.devices?.iec ?? {});
|
||||||
|
const drives = allDevices
|
||||||
|
.filter(([, v]: [string, any]) => (v as any)?.type === 'drive')
|
||||||
|
.map(([k, v]: [string, any]) => ({ type: 'drive' as const, key: k, base_url: v?.base_url as string | undefined, url: v?.url as string | undefined, enabled: !!v?.enabled }));
|
||||||
|
const meatloafs = allDevices
|
||||||
|
.filter(([, v]: [string, any]) => (v as any)?.type === 'meatloaf')
|
||||||
|
.map(([k, v]: [string, any]) => ({ type: 'meatloaf' as const, key: k, base_url: v?.base_url as string | undefined, url: v?.url as string | undefined, enabled: !!v?.enabled }));
|
||||||
|
const devices = [...drives, ...meatloafs];
|
||||||
|
if (!devices.length)
|
||||||
|
return <p className="text-sm text-neutral-500 text-center py-4">No drive devices found in config.</p>;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{devices.map(dev => (
|
||||||
|
<button
|
||||||
|
key={`${dev.type}-${dev.key}`}
|
||||||
|
onClick={() => mountOnDevice(dev.type, dev.key)}
|
||||||
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<HardDrive className={`w-5 h-5 flex-shrink-0 ${dev.enabled ? 'text-blue-500' : 'text-neutral-400'}`} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-medium text-sm">Device #{dev.key}</div>
|
||||||
|
{(dev.base_url || dev.url) && (
|
||||||
|
<div
|
||||||
|
className="text-xs text-neutral-500 overflow-hidden whitespace-nowrap"
|
||||||
|
style={{ direction: 'rtl', textOverflow: 'ellipsis' }}
|
||||||
|
title={[dev.base_url, dev.url].filter(Boolean).join('')}
|
||||||
|
>
|
||||||
|
<span style={{ direction: 'ltr', unicodeBidi: 'embed' }}>
|
||||||
|
{[dev.base_url, dev.url].filter(Boolean).join('')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!dev.enabled && <span className="text-xs text-neutral-400 flex-shrink-0">disabled</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* AQL help dialog */}
|
||||||
|
<Dialog open={showAqlHelp} onOpenChange={setShowAqlHelp}>
|
||||||
|
<DialogContent className="max-w-sm flex flex-col max-h-[90dvh]">
|
||||||
|
<DialogHeader className="flex-shrink-0">
|
||||||
|
<DialogTitle>AQL Search Terms</DialogTitle>
|
||||||
|
<DialogDescription>Tap a term to insert it into the search field.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="overflow-y-auto flex-1 min-h-0 flex flex-col gap-1 py-1">
|
||||||
|
{AQL_TERMS.map(({ term, label, example, description }) => (
|
||||||
|
<button
|
||||||
|
key={term}
|
||||||
|
onClick={() => {
|
||||||
|
const trimmed = query.trimEnd();
|
||||||
|
const next = trimmed ? `${trimmed} ${term}` : term;
|
||||||
|
setQuery(next);
|
||||||
|
setShowAqlHelp(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
const el = inputRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.focus();
|
||||||
|
el.setSelectionRange(next.length, next.length);
|
||||||
|
}, 50);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-3 rounded-lg border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="font-mono text-sm font-semibold text-blue-700">{term}</span>
|
||||||
|
<span className="text-xs text-neutral-400">{label}</span>
|
||||||
|
<span className="ml-auto font-mono text-xs text-neutral-400">{example}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5">{description}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Preset values dialog */}
|
||||||
|
<Dialog open={activePreset !== null} onOpenChange={open => !open && setActivePreset(null)}>
|
||||||
|
<DialogContent className="max-w-sm flex flex-col max-h-[90dvh]">
|
||||||
|
<DialogHeader className="flex-shrink-0">
|
||||||
|
<DialogTitle>{activePreset?.description ?? 'Preset'}</DialogTitle>
|
||||||
|
<DialogDescription>Tap a value to add it to your search.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="overflow-y-auto flex-1 min-h-0 flex flex-col gap-1 py-1">
|
||||||
|
{activePreset?.values.map((v, i) => {
|
||||||
|
const label = v.name ?? v.aqlKey;
|
||||||
|
const prefix = PRESET_PREFIX[activePreset.type];
|
||||||
|
const token = v.aqlKey.includes(':') ? v.aqlKey : `${prefix}:${v.aqlKey}`;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${v.aqlKey}-${i}`}
|
||||||
|
onClick={() => applyPreset(activePreset, v)}
|
||||||
|
className="w-full text-left px-4 py-2.5 rounded-lg border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-neutral-800 flex-1 truncate">{label}</span>
|
||||||
|
<span className="font-mono text-xs text-neutral-400 flex-shrink-0">{token}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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, ArrowUp, ArrowDown, ArrowUpDown, HardDrive, FolderOpen } from 'lucide-react';
|
import { Search, Loader2, RefreshCw, FolderSearch, SlidersHorizontal, HardDrive, FolderOpen, X } 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';
|
||||||
|
|
@ -130,6 +130,7 @@ const _store = {
|
||||||
results: [] as SearchResult[],
|
results: [] as SearchResult[],
|
||||||
hasSearched: false,
|
hasSearched: false,
|
||||||
showFilter: false,
|
showFilter: false,
|
||||||
|
filterText: '',
|
||||||
filterSystem: null as string | null,
|
filterSystem: null as string | null,
|
||||||
filterVideo: null as string | null,
|
filterVideo: null as string | null,
|
||||||
filterLanguage: null as string | null,
|
filterLanguage: null as string | null,
|
||||||
|
|
@ -174,10 +175,14 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
||||||
const [hasSearched, setHasSearched] = useState(() => _store.hasSearched);
|
const [hasSearched, setHasSearched] = useState(() => _store.hasSearched);
|
||||||
const [mountEntry, setMountEntry] = useState<SearchResult | null>(null);
|
const [mountEntry, setMountEntry] = useState<SearchResult | null>(null);
|
||||||
const [actionEntry, setActionEntry] = useState<SearchResult | null>(null);
|
const [actionEntry, setActionEntry] = useState<SearchResult | null>(null);
|
||||||
|
const [showScanConfirm, setShowScanConfirm] = useState(false);
|
||||||
const [searchError, setSearchError] = useState<string | null>(null);
|
const [searchError, setSearchError] = useState<string | null>(null);
|
||||||
const [dbProgress, setDbProgress] = useState<{ received: number; total: number | null }>({ received: 0, total: null });
|
// The locate-database load progress is rendered by the SearchPane (above
|
||||||
const [dbPhase, setDbPhase] = useState<'idle' | 'downloading' | 'ready'>('idle');
|
// 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 [showFilter, setShowFilter] = useState(() => _store.showFilter);
|
const [showFilter, setShowFilter] = useState(() => _store.showFilter);
|
||||||
|
const [filterText, setFilterText] = useState(() => _store.filterText);
|
||||||
const [filterSystem, setFilterSystem] = useState<string | null>(() => _store.filterSystem);
|
const [filterSystem, setFilterSystem] = useState<string | null>(() => _store.filterSystem);
|
||||||
const [filterVideo, setFilterVideo] = useState<string | null>(() => _store.filterVideo);
|
const [filterVideo, setFilterVideo] = useState<string | null>(() => _store.filterVideo);
|
||||||
const [filterLanguage, setFilterLanguage] = useState<string | null>(() => _store.filterLanguage);
|
const [filterLanguage, setFilterLanguage] = useState<string | null>(() => _store.filterLanguage);
|
||||||
|
|
@ -193,12 +198,13 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
||||||
_store.results = results;
|
_store.results = results;
|
||||||
_store.hasSearched = hasSearched;
|
_store.hasSearched = hasSearched;
|
||||||
_store.showFilter = showFilter;
|
_store.showFilter = showFilter;
|
||||||
|
_store.filterText = filterText;
|
||||||
_store.filterSystem = filterSystem;
|
_store.filterSystem = filterSystem;
|
||||||
_store.filterVideo = filterVideo;
|
_store.filterVideo = filterVideo;
|
||||||
_store.filterLanguage = filterLanguage;
|
_store.filterLanguage = filterLanguage;
|
||||||
_store.sortField = sortField;
|
_store.sortField = sortField;
|
||||||
_store.sortDir = sortDir;
|
_store.sortDir = sortDir;
|
||||||
}, [query, results, hasSearched, showFilter, filterSystem, filterVideo, filterLanguage, sortField, sortDir]);
|
}, [query, results, hasSearched, showFilter, filterText, filterSystem, filterVideo, filterLanguage, sortField, sortDir]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (_store.scrollTop > 0 && scrollRef.current) {
|
if (_store.scrollTop > 0 && scrollRef.current) {
|
||||||
|
|
@ -223,15 +229,18 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
setHasSearched(true);
|
setHasSearched(true);
|
||||||
setSearchError(null);
|
setSearchError(null);
|
||||||
|
setFilterText('');
|
||||||
setFilterSystem(null);
|
setFilterSystem(null);
|
||||||
setFilterVideo(null);
|
setFilterVideo(null);
|
||||||
setFilterLanguage(null);
|
setFilterLanguage(null);
|
||||||
try {
|
try {
|
||||||
|
// The SearchPane pre-fetches the locate database when it opens, so by
|
||||||
|
// the time the user clicks Search the database is almost always ready.
|
||||||
|
// This branch only fires if the user types immediately on mount, before
|
||||||
|
// the pane's pre-fetch has resolved. The pane-level progress bar above
|
||||||
|
// the panel already shows the transfer; we don't need a second bar.
|
||||||
if (!isLocateDbLoaded()) {
|
if (!isLocateDbLoaded()) {
|
||||||
setDbPhase('downloading');
|
await openLocateDb();
|
||||||
setDbProgress({ received: 0, total: null });
|
|
||||||
await openLocateDb(p => flushSync(() => setDbProgress(p)));
|
|
||||||
setDbPhase('ready');
|
|
||||||
}
|
}
|
||||||
const needle = query.trim();
|
const needle = query.trim();
|
||||||
const entries = searchLocate(needle);
|
const entries = searchLocate(needle);
|
||||||
|
|
@ -296,18 +305,6 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
||||||
toast.success(`Mounted "${mountEntry.name}" on ${deviceType} #${key}`);
|
toast.success(`Mounted "${mountEntry.name}" on ${deviceType} #${key}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadingLabel = dbPhase === 'downloading'
|
|
||||||
? (dbProgress.received === 0
|
|
||||||
? 'Loading database…'
|
|
||||||
: dbProgress.total === null
|
|
||||||
? `Loading database… ${humanFileSize(dbProgress.received)}`
|
|
||||||
: `Loading database… ${humanFileSize(dbProgress.received)} / ${humanFileSize(dbProgress.total)}`)
|
|
||||||
: 'Searching…';
|
|
||||||
|
|
||||||
const downloadPct = dbProgress.total && dbProgress.total > 0
|
|
||||||
? Math.min(100, Math.round((dbProgress.received / dbProgress.total) * 100))
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const busy = isSearching || isScanning;
|
const busy = isSearching || isScanning;
|
||||||
|
|
||||||
const facets = useMemo(() => {
|
const facets = useMemo(() => {
|
||||||
|
|
@ -317,10 +314,10 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
||||||
return { systems, videos, langs };
|
return { systems, videos, langs };
|
||||||
}, [results]);
|
}, [results]);
|
||||||
|
|
||||||
const hasAnyFacets = facets.systems.length > 0 || facets.videos.length > 0 || facets.langs.length > 0;
|
|
||||||
|
|
||||||
const visibleResults = useMemo(() => {
|
const visibleResults = useMemo(() => {
|
||||||
|
const needle = filterText.trim().toLowerCase();
|
||||||
let list = results.filter(r =>
|
let list = results.filter(r =>
|
||||||
|
(!needle || r.name.toLowerCase().includes(needle) || r.path.toLowerCase().includes(needle)) &&
|
||||||
(filterSystem === null || r.system === filterSystem) &&
|
(filterSystem === null || r.system === filterSystem) &&
|
||||||
(filterVideo === null || r.video === filterVideo) &&
|
(filterVideo === null || r.video === filterVideo) &&
|
||||||
(filterLanguage === null || r.language === filterLanguage)
|
(filterLanguage === null || r.language === filterLanguage)
|
||||||
|
|
@ -330,19 +327,14 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
||||||
return sortDir === 'asc' ? cmp : -cmp;
|
return sortDir === 'asc' ? cmp : -cmp;
|
||||||
});
|
});
|
||||||
return list;
|
return list;
|
||||||
}, [results, filterSystem, filterVideo, filterLanguage, sortField, sortDir]);
|
}, [results, filterText, filterSystem, filterVideo, filterLanguage, sortField, sortDir]);
|
||||||
|
|
||||||
const toggleSort = (field: SortField) => {
|
const toggleSort = (field: SortField) => {
|
||||||
if (sortField === field) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
if (sortField === field) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||||
else { setSortField(field); setSortDir('asc'); }
|
else { setSortField(field); setSortDir('asc'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const SortIcon = ({ field }: { field: SortField }) => {
|
const activeFilters = [filterText || null, filterSystem, filterVideo, filterLanguage].filter(Boolean).length;
|
||||||
if (sortField !== field) return <ArrowUpDown className="w-3 h-3 opacity-50" />;
|
|
||||||
return sortDir === 'asc' ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const activeFilters = [filterSystem, filterVideo, filterLanguage].filter(Boolean).length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -360,14 +352,14 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleScan}
|
onClick={() => setShowScanConfirm(true)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="p-1.5 rounded-lg hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600 disabled:opacity-40 transition-colors"
|
className="p-1.5 rounded-lg hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600 disabled:opacity-40 transition-colors"
|
||||||
title="Scan /sd and rebuild database"
|
title="Scan /sd and rebuild database"
|
||||||
>
|
>
|
||||||
<FolderSearch className="w-4 h-4" />
|
<FolderSearch className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
{hasAnyFacets && (
|
{hasSearched && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowFilter(v => !v)}
|
onClick={() => setShowFilter(v => !v)}
|
||||||
className={`relative p-1.5 rounded-lg transition-colors ${showFilter ? 'bg-blue-100 text-blue-600' : 'hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600'}`}
|
className={`relative p-1.5 rounded-lg transition-colors ${showFilter ? 'bg-blue-100 text-blue-600' : 'hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600'}`}
|
||||||
|
|
@ -407,28 +399,40 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter panel */}
|
</div>
|
||||||
<div className={`overflow-hidden transition-all duration-200 ease-in-out ${showFilter && hasAnyFacets ? 'max-h-48 opacity-100 mt-3' : 'max-h-0 opacity-0'}`}>
|
|
||||||
<div className="space-y-2 pb-1">
|
{/* 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'}`}>
|
||||||
|
<div className="bg-neutral-50 border-b border-neutral-200 px-4 py-2 flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1 min-w-0">
|
||||||
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-neutral-400 pointer-events-none" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={filterText}
|
||||||
|
onChange={e => setFilterText(e.target.value)}
|
||||||
|
placeholder="Filter…"
|
||||||
|
className="w-full pl-7 pr-6 py-1 text-sm border border-neutral-300 rounded bg-white"
|
||||||
|
/>
|
||||||
|
{filterText && (
|
||||||
|
<button onClick={() => setFilterText('')} className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||||
|
<X className="w-3.5 h-3.5 text-neutral-400" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(['name', 'size'] as SortField[]).map(f => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => toggleSort(f)}
|
||||||
|
className={`text-xs px-2 py-1 rounded border flex-shrink-0 ${sortField === f ? 'border-blue-400 bg-blue-50 text-blue-700' : 'border-neutral-300 bg-white text-neutral-600'}`}
|
||||||
|
>
|
||||||
|
{f === 'name' ? 'Name' : 'Size'}{sortField === f ? (sortDir === 'asc' ? ' ↑' : ' ↓') : ''}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<FilterChips label="System" values={facets.systems} selected={filterSystem} onSelect={setFilterSystem} />
|
<FilterChips label="System" values={facets.systems} selected={filterSystem} onSelect={setFilterSystem} />
|
||||||
<FilterChips label="Video" values={facets.videos} selected={filterVideo} onSelect={setFilterVideo} />
|
<FilterChips label="Video" values={facets.videos} selected={filterVideo} onSelect={setFilterVideo} />
|
||||||
<FilterChips label="Language" values={facets.langs} selected={filterLanguage} onSelect={setFilterLanguage} />
|
<FilterChips label="Language" values={facets.langs} selected={filterLanguage} onSelect={setFilterLanguage} />
|
||||||
<div className="flex items-center gap-1.5 pt-0.5 border-t border-neutral-100">
|
|
||||||
<span className="text-xs text-neutral-400 font-medium w-14 flex-shrink-0">Sort</span>
|
|
||||||
<button
|
|
||||||
onClick={() => toggleSort('name')}
|
|
||||||
className={`flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${sortField === 'name' ? 'bg-blue-600 text-white' : 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'}`}
|
|
||||||
>
|
|
||||||
Name <SortIcon field="name" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => toggleSort('size')}
|
|
||||||
className={`flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${sortField === 'size' ? 'bg-blue-600 text-white' : 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'}`}
|
|
||||||
>
|
|
||||||
Size <SortIcon field="size" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -447,22 +451,10 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isSearching && !hasSearched && (
|
{isSearching && !hasSearched && (
|
||||||
<div className="flex flex-col items-center justify-center py-16 gap-3 w-full px-8">
|
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||||
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
|
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
|
||||||
<p className="text-sm text-neutral-500 text-center">{loadingLabel}</p>
|
<p className="text-sm text-neutral-500">Searching…</p>
|
||||||
{dbPhase === 'downloading' && (
|
<p className="text-xs text-neutral-400">The locate database is loading above.</p>
|
||||||
<div className="w-full max-w-xs">
|
|
||||||
<div className="h-1.5 bg-neutral-200 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full bg-blue-500 transition-all duration-150 ease-out ${downloadPct === null ? 'animate-pulse w-1/3' : ''}`}
|
|
||||||
style={downloadPct !== null ? { width: `${downloadPct}%` } : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{downloadPct !== null && (
|
|
||||||
<p className="text-xs text-neutral-400 text-center mt-1.5">{downloadPct}%</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -512,7 +504,7 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
||||||
</p>
|
</p>
|
||||||
{results.length > 0 && (
|
{results.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setFilterSystem(null); setFilterVideo(null); setFilterLanguage(null); }}
|
onClick={() => { setFilterText(''); setFilterSystem(null); setFilterVideo(null); setFilterLanguage(null); }}
|
||||||
className="mt-2 text-xs text-blue-600 hover:underline"
|
className="mt-2 text-xs text-blue-600 hover:underline"
|
||||||
>
|
>
|
||||||
Clear filters
|
Clear filters
|
||||||
|
|
@ -537,6 +529,32 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scan confirm dialog */}
|
||||||
|
<Dialog open={showScanConfirm} onOpenChange={setShowScanConfirm}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Rebuild Search Index</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This will scan all files on <code>/sd</code> and rebuild the local search database. It may take a minute on large collections.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex gap-2 justify-end pt-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowScanConfirm(false)}
|
||||||
|
className="px-4 py-2 rounded-xl text-sm text-neutral-600 hover:bg-neutral-100 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowScanConfirm(false); handleScan(); }}
|
||||||
|
className="px-4 py-2 rounded-xl text-sm font-medium bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Scan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Actions dialog */}
|
{/* Actions dialog */}
|
||||||
<Dialog open={actionEntry !== null} onOpenChange={open => !open && setActionEntry(null)}>
|
<Dialog open={actionEntry !== null} onOpenChange={open => !open && setActionEntry(null)}>
|
||||||
<DialogContent className="max-w-sm">
|
<DialogContent className="max-w-sm">
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,56 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { X } from 'lucide-react';
|
import { X, Loader2, Database } from 'lucide-react';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import SearchLocal from './SearchLocal';
|
import SearchLocal from './SearchLocal';
|
||||||
import SearchAssembly64 from './SearchAssembly64';
|
import SearchAssembly64 from './SearchAssembly64';
|
||||||
|
import SearchCommoServe from './SearchCommoServe';
|
||||||
|
import SearchCSDbNG from './SearchCSDbNG';
|
||||||
|
import { humanFileSize } from '../webdav';
|
||||||
|
import {
|
||||||
|
openLocateDb,
|
||||||
|
isLocateDbLoaded,
|
||||||
|
subscribeLoadProgress,
|
||||||
|
type DbProgress,
|
||||||
|
} from '../locate-db';
|
||||||
|
|
||||||
interface SearchPaneProps {
|
interface SearchPaneProps {
|
||||||
config: any;
|
config: any;
|
||||||
setConfig: (c: any) => void;
|
setConfig: (c: any) => void;
|
||||||
initialTab?: 0 | 1;
|
initialTab?: 0 | 1 | 2 | 3;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onOpenFolder: (path: string) => void;
|
onOpenFolder: (path: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TABS = ['Local', 'Assembly64'] as const;
|
const TABS = ['Local', 'Assembly64', 'CommoServe', 'CSDb'] as const;
|
||||||
|
|
||||||
let _lastTab: 0 | 1 = (localStorage.getItem('search.tab') === '1' ? 1 : 0);
|
let _lastTab: 0 | 1 | 2 | 3 = (() => {
|
||||||
|
const v = parseInt(localStorage.getItem('search.tab') ?? '0', 10);
|
||||||
|
return (v >= 0 && v <= 3 ? v : 0) as 0 | 1 | 2 | 3;
|
||||||
|
})();
|
||||||
|
|
||||||
export default function SearchPane({ config, setConfig, initialTab, onClose, onOpenFolder }: SearchPaneProps) {
|
export default function SearchPane({ config, setConfig, initialTab, onClose, onOpenFolder }: SearchPaneProps) {
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
const [activeTab, setActiveTab] = useState<0 | 1>(initialTab ?? _lastTab);
|
const [activeTab, setActiveTab] = useState<0 | 1 | 2 | 3>(initialTab ?? _lastTab);
|
||||||
|
|
||||||
|
// Subscribe to the locate-database load pipeline so the user can see the
|
||||||
|
// ~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 setLoadState({ kind: s.phase, received: s.received, total: s.total });
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Jump to starting tab without animation on first render
|
// Jump to starting tab without animation on first render
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -28,7 +60,7 @@ export default function SearchPane({ config, setConfig, initialTab, onClose, onO
|
||||||
el.scrollLeft = tab * el.clientWidth;
|
el.scrollLeft = tab * el.clientWidth;
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const scrollToTab = (idx: 0 | 1) => {
|
const scrollToTab = (idx: 0 | 1 | 2 | 3) => {
|
||||||
const el = panelRef.current;
|
const el = panelRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.scrollTo({ left: idx * el.clientWidth, behavior: 'smooth' });
|
el.scrollTo({ left: idx * el.clientWidth, behavior: 'smooth' });
|
||||||
|
|
@ -37,12 +69,21 @@ export default function SearchPane({ config, setConfig, initialTab, onClose, onO
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
const el = panelRef.current;
|
const el = panelRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const idx = Math.round(el.scrollLeft / el.clientWidth) as 0 | 1;
|
const idx = Math.round(el.scrollLeft / el.clientWidth) as 0 | 1 | 2 | 3;
|
||||||
setActiveTab(idx);
|
setActiveTab(idx);
|
||||||
_lastTab = idx;
|
_lastTab = idx;
|
||||||
localStorage.setItem('search.tab', String(idx));
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 z-50">
|
<div className="fixed inset-0 z-50">
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
@ -57,7 +98,7 @@ export default function SearchPane({ config, setConfig, initialTab, onClose, onO
|
||||||
{TABS.map((label, i) => (
|
{TABS.map((label, i) => (
|
||||||
<button
|
<button
|
||||||
key={label}
|
key={label}
|
||||||
onClick={() => scrollToTab(i as 0 | 1)}
|
onClick={() => scrollToTab(i as 0 | 1 | 2 | 3)}
|
||||||
className={`px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
className={`px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||||
activeTab === i
|
activeTab === i
|
||||||
? 'text-blue-600 border-blue-600'
|
? 'text-blue-600 border-blue-600'
|
||||||
|
|
@ -77,6 +118,44 @@ export default function SearchPane({ config, setConfig, initialTab, onClose, onO
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 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 */}
|
{/* Swipeable panels */}
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
|
|
@ -102,6 +181,24 @@ export default function SearchPane({ config, setConfig, initialTab, onClose, onO
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* CommoServe panel */}
|
||||||
|
<div className="w-full h-full flex-shrink-0 snap-start flex flex-col min-w-0">
|
||||||
|
<SearchCommoServe
|
||||||
|
config={config}
|
||||||
|
setConfig={setConfig}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CSDb panel */}
|
||||||
|
<div className="w-full h-full flex-shrink-0 snap-start flex flex-col min-w-0">
|
||||||
|
<SearchCSDbNG
|
||||||
|
config={config}
|
||||||
|
setConfig={setConfig}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
|
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
|
||||||
|
import wasmUrl from '@sqlite.org/sqlite-wasm/sqlite3.wasm?url';
|
||||||
import { getWebDAVBaseUrl, basename, listDirectory, putFileContents, deletePath } from './webdav';
|
import { getWebDAVBaseUrl, basename, listDirectory, putFileContents, deletePath } from './webdav';
|
||||||
|
|
||||||
const LOCATE_PATH = '/sd/.locate';
|
const LOCATE_PATH = '/sd/.locate';
|
||||||
|
// Vite rewrites this `?url` import to the hashed asset path
|
||||||
|
// (e.g. /assets/sqlite3-BVKGSWc-.wasm) and respects the configured base path.
|
||||||
|
const WASM_URL: string = wasmUrl;
|
||||||
|
|
||||||
function parseContentLength(header: string | null): number | null {
|
function parseContentLength(header: string | null): number | null {
|
||||||
if (!header) return null;
|
if (!header) return null;
|
||||||
|
|
@ -9,49 +13,21 @@ function parseContentLength(header: string | null): number | null {
|
||||||
return Number.isFinite(n) && n > 0 ? n : null;
|
return Number.isFinite(n) && n > 0 ? n : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memoize the module init — loading the WASM binary is expensive.
|
// Fetch a URL and report progress as { received, total } chunks arrive.
|
||||||
let _sqlite3Promise: Promise<any> | null = null;
|
// Returns the full byte array. Streams the body so progress is real-time
|
||||||
function getSqlite3(): Promise<any> {
|
// (not just a single notification when the whole thing is buffered).
|
||||||
if (!_sqlite3Promise) {
|
async function fetchWithProgress(
|
||||||
_sqlite3Promise = sqlite3InitModule({ print: () => {}, printErr: () => {} });
|
url: string,
|
||||||
}
|
onProgress?: (p: { received: number; total: number | null }) => void,
|
||||||
return _sqlite3Promise;
|
): Promise<Uint8Array> {
|
||||||
}
|
|
||||||
|
|
||||||
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 { received, total } for each chunk; total is the value
|
|
||||||
* of the Content-Length response header, or null if the server didn't send
|
|
||||||
* one (chunked transfer, etc.).
|
|
||||||
*/
|
|
||||||
export async function openLocateDb(
|
|
||||||
onProgress?: (progress: { received: number; total: number | null }) => void,
|
|
||||||
): Promise<void> {
|
|
||||||
if (_db) return;
|
|
||||||
|
|
||||||
const sqlite3 = await getSqlite3();
|
|
||||||
|
|
||||||
const url = getWebDAVBaseUrl() + LOCATE_PATH;
|
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok) throw new Error(`Cannot fetch locate database: ${response.status} ${response.statusText}`);
|
if (!response.ok) throw new Error(`Cannot fetch ${url}: ${response.status} ${response.statusText}`);
|
||||||
|
|
||||||
const total = parseContentLength(response.headers.get('content-length'));
|
const total = parseContentLength(response.headers.get('content-length'));
|
||||||
|
if (!response.body) {
|
||||||
// Stream the response body so onProgress gets called per chunk.
|
const buf = new Uint8Array(await response.arrayBuffer());
|
||||||
let bytes: Uint8Array;
|
onProgress?.({ received: buf.byteLength, total: total ?? buf.byteLength });
|
||||||
if (response.body) {
|
return buf;
|
||||||
|
}
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
const chunks: Uint8Array[] = [];
|
const chunks: Uint8Array[] = [];
|
||||||
let received = 0;
|
let received = 0;
|
||||||
|
|
@ -65,11 +41,104 @@ export async function openLocateDb(
|
||||||
const combined = new Uint8Array(received);
|
const combined = new Uint8Array(received);
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.byteLength; }
|
for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.byteLength; }
|
||||||
bytes = combined;
|
return combined;
|
||||||
} else {
|
}
|
||||||
bytes = new Uint8Array(await response.arrayBuffer());
|
|
||||||
onProgress?.({ received: bytes.byteLength, total: total ?? bytes.byteLength });
|
// Memoize the module init — loading the WASM binary is expensive.
|
||||||
}
|
// We pre-fetch the WASM in the main thread (with progress reporting)
|
||||||
|
// and hand the bytes to Emscripten as Module.wasmBinary so it skips
|
||||||
|
// its own internal fetch. The sqlite-wasm worker still loads its own
|
||||||
|
// copy on first use, but the main-thread load (the one users see in
|
||||||
|
// the network panel as a 400 KB download) is gone.
|
||||||
|
let _sqlite3Promise: Promise<any> | null = null;
|
||||||
|
function getSqlite3(
|
||||||
|
onProgress?: (p: { received: number; total: number | null }) => void,
|
||||||
|
): Promise<any> {
|
||||||
|
if (_sqlite3Promise) return _sqlite3Promise;
|
||||||
|
_sqlite3Promise = (async () => {
|
||||||
|
const wasmBytes = await fetchWithProgress(WASM_URL, onProgress);
|
||||||
|
return sqlite3InitModule({
|
||||||
|
print: () => {},
|
||||||
|
printErr: () => {},
|
||||||
|
wasmBinary: wasmBytes,
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
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;
|
||||||
|
_loadState = { phase: 'idle' };
|
||||||
|
for (const cb of _loadSubscribers) cb(_loadState);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Multi-subscriber progress for the load pipeline ────────────────────────
|
||||||
|
// The SearchPane kicks off openLocateDb() on mount (so the user can see the
|
||||||
|
// transfer happen on the panel itself, not buried inside the search action).
|
||||||
|
// SearchLocal may also call openLocateDb() when the user types a query, and
|
||||||
|
// it needs to receive the same progress events. This module-level pub/sub
|
||||||
|
// fan-outs events to every active subscriber; late subscribers get an
|
||||||
|
// immediate "done" notification so they can skip their own progress UI.
|
||||||
|
|
||||||
|
type LoadState =
|
||||||
|
| { phase: 'idle' }
|
||||||
|
| { phase: 'engine' | 'database'; received: number; total: number | null }
|
||||||
|
| { phase: 'ready' }
|
||||||
|
| { phase: 'error'; message: string };
|
||||||
|
|
||||||
|
let _loadState: LoadState = { phase: 'idle' };
|
||||||
|
const _loadSubscribers = new Set<(s: LoadState) => void>();
|
||||||
|
|
||||||
|
function _setLoadState(s: LoadState) {
|
||||||
|
_loadState = s;
|
||||||
|
for (const cb of _loadSubscribers) cb(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to the in-flight (or already-completed) locate-database load.
|
||||||
|
* The callback fires immediately with the current state, then on every
|
||||||
|
* change. Returns an unsubscribe function. Multiple subscribers (e.g. the
|
||||||
|
* SearchPane header and the SearchLocal panel) can observe the same load.
|
||||||
|
*/
|
||||||
|
export function subscribeLoadProgress(cb: (s: LoadState) => void): () => void {
|
||||||
|
_loadSubscribers.add(cb);
|
||||||
|
cb(_loadState);
|
||||||
|
return () => { _loadSubscribers.delete(cb); };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DbProgress =
|
||||||
|
| { kind: 'engine'; received: number; total: number | null }
|
||||||
|
| { kind: 'database'; received: number; total: number | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch /sd/.locate and open it as an in-memory SQLite database. On the first
|
||||||
|
* call this also loads the SQLite WASM engine (~400 KB), so onProgress fires
|
||||||
|
* twice: first for the engine download, then for the locate database itself.
|
||||||
|
* Calling again when already loaded is a no-op unless you call
|
||||||
|
* resetLocateDb() first.
|
||||||
|
*/
|
||||||
|
export async function openLocateDb(
|
||||||
|
onProgress?: (p: DbProgress) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
if (_db) return;
|
||||||
|
|
||||||
|
const sqlite3 = await getSqlite3(p => {
|
||||||
|
onProgress?.({ kind: 'engine', received: p.received, total: p.total });
|
||||||
|
_setLoadState({ phase: 'engine', received: p.received, total: p.total });
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = getWebDAVBaseUrl() + LOCATE_PATH;
|
||||||
|
const bytes = await fetchWithProgress(url, p => {
|
||||||
|
onProgress?.({ kind: 'database', received: p.received, total: p.total });
|
||||||
|
_setLoadState({ phase: 'database', received: p.received, total: p.total });
|
||||||
|
});
|
||||||
|
|
||||||
// Allocate the bytes in WASM heap, then deserialize into a fresh in-memory DB.
|
// Allocate the bytes in WASM heap, then deserialize into a fresh in-memory DB.
|
||||||
const p = sqlite3.wasm.allocFromTypedArray(bytes);
|
const p = sqlite3.wasm.allocFromTypedArray(bytes);
|
||||||
|
|
@ -87,6 +156,7 @@ export async function openLocateDb(
|
||||||
throw new Error(`sqlite3_deserialize failed (code ${rc})`);
|
throw new Error(`sqlite3_deserialize failed (code ${rc})`);
|
||||||
}
|
}
|
||||||
_db = db;
|
_db = db;
|
||||||
|
_setLoadState({ phase: 'ready' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScanPhase = 'scanning' | 'building' | 'saving';
|
export type ScanPhase = 'scanning' | 'building' | 'saving';
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user