Compare commits
No commits in common. "be9391caec703f9158bab2b86e5395a5c6a391a0" and "9a0268a6b4b8ee4328b9de030841960230bc4422" have entirely different histories.
be9391caec
...
9a0268a6b4
24
AGENTS.md
24
AGENTS.md
|
|
@ -39,20 +39,7 @@ src/
|
|||
NetworkPage.tsx # Network settings, WiFi scan/connect (via WS)
|
||||
OtherPage.tsx # Misc settings
|
||||
ToolsPage.tsx # Tools
|
||||
SearchPane.tsx # Swipeable search shell: spring slide-up, tab bar (Local | Assembly64),
|
||||
# X close button, horizontal snap-x scroll container; initialTab prop
|
||||
# (0=Local, 1=Assembly64); onScroll updates active tab indicator
|
||||
SearchLocal.tsx # Local file search panel (panel inside SearchPane, no fixed positioning);
|
||||
# TOSEC/No-Intro tag parsing (PAL/NTSC, system, ISO 639-1 language);
|
||||
# wildcard search (* → %, ? → _); MediaEntry rows with badges + path;
|
||||
# faceted filter chips; module-level _store persists state across unmounts;
|
||||
# actions dialog: "Mount on virtual drive" + "Open containing folder"
|
||||
SearchAssembly64.tsx # Assembly64 Leet API browser panel (panel inside SearchPane);
|
||||
# fetches /search/categories + /search/aql/presets on mount;
|
||||
# AQL search with pagination; category filter chips; ContentItem result rows;
|
||||
# item tap → /search/entries → file list with Download + Mount actions;
|
||||
# Download saves to /sd/downloads/ via putFileContents;
|
||||
# client-id: meatloaf-config header; module-level _store
|
||||
SearchOverlay.tsx
|
||||
WiFiScanOverlay.tsx # WiFi scan results; Connect sends "connect <ssid> [<pass>]" via WS
|
||||
MediaManager.tsx # WebDAV file browser: file icons by type, kebab actions,
|
||||
# Configure Folder (.config editor), base_url mount logic,
|
||||
|
|
@ -85,8 +72,6 @@ src/
|
|||
figma/ # Figma-generated components
|
||||
ui/
|
||||
lazy-loader.tsx # Animated progress bar for Suspense fallbacks (staged steps)
|
||||
marquee-text.tsx # <MarqueeText> — ping-pong scroll when text overflows; per-instance
|
||||
# @keyframes injected into <head> via ResizeObserver; module-level _seq
|
||||
confirm-dialog.tsx
|
||||
# … other shadcn/Radix UI wrappers
|
||||
vendor/
|
||||
|
|
@ -137,14 +122,12 @@ Header: fullscreen toggle, search, apps grid, profile button (→ ProfilePage).
|
|||
|
||||
Grid of app cards grouped by category, each navigates to a stub `AppPage` unless implemented:
|
||||
|
||||
- **Management**: Media Manager, **Assembly64** (implemented — opens SearchPane at Assembly64 tab), Print Manager, **Serial Console** (implemented), Short Codes
|
||||
- **Management**: Media Manager, Print Manager, **Serial Console** (implemented), Short Codes
|
||||
- **Disk**: RAM/ROM Explorer, BAM Editor, Directory Editor, Sector Editor, Disk Visualizer, Dump/Write Disk Image
|
||||
- **Cartridge**: PRG to CRT, Magic Desk Cart Builder, Easy Flash Cart Builder
|
||||
- **Development**: Basic Editor, Assembler, Sprite Editor, Character Set Editor, Petscii Editor
|
||||
- **Display**: Idle Animation, Loading Animation, **Reality Override** (implemented), **Override Admin** (implemented)
|
||||
|
||||
Header search icon opens SearchPane at tab 0 (Local). "Assembly64" AppCard opens SearchPane at tab 1.
|
||||
|
||||
## Work to Date (chronological)
|
||||
|
||||
1. **Initial commit** — project scaffolded with basic pages
|
||||
|
|
@ -206,8 +189,6 @@ Header search icon opens SearchPane at tab 0 (Local). "Assembly64" AppCard opens
|
|||
57. **Toast visibility improvements** — `<Toaster>` in `App.tsx` gains `richColors` (vivid green/red/amber/blue for success/error/warning/loading), `closeButton` (explicit ✕), `duration={5000}` (up from 4 s default), and `toastOptions` with `fontSize: 0.9rem`, `fontWeight: 500`, `padding: 14px 16px`
|
||||
58. **MediaManager: Duplicate action** — `duplicateEntry()` generates unique `"name copy.ext"` / `"name copy 2.ext"` etc. via sequential `fileExists` checks, then calls `copyPath`; button appears in the file Actions Dialog between Download and Rename; reloads current directory on success
|
||||
59. **`.vms` Virtual Media Stack format** — `.vms` files are like `.lst` but each line is `path,name` (comma-separated); name after comma becomes the `MediaSetEntry.name` display label; `PLAYLIST_EXTS = new Set(['lst', 'vms'])` exported from `MediaEntry.tsx` with `Layers` icon (indigo); `MediaManager` and `DeviceDetailOverlay` both parse `.vms` into `MediaSetEntry[]`; `mediaSetEntryUrl()` used for `fileExists` checks and `dev.url`
|
||||
60. **MarqueeText component** — `ui/marquee-text.tsx`: extracted from inline MediaManager code; module-level `_seq` counter generates unique animation IDs; per-instance `@keyframes` rule injected into `<head>` and cleaned up on unmount; `ResizeObserver` re-measures on resize; ping-pong (alternate) scroll with `ease-in-out` and 0.8 s delay; used by MediaManager, SearchLocal, SearchAssembly64
|
||||
61. **SearchLocal + SearchAssembly64 + SearchPane** — `SearchOverlay.tsx` split into three components: `SearchPane.tsx` (swipeable shell: `fixed inset-0 bg-white/80 backdrop-blur-md`, spring slide-up via `motion/react`, tab bar with underline indicator + X button, horizontal `snap-x snap-mandatory` scroll container, `onScroll` updates active tab); `SearchLocal.tsx` (local file search panel: TOSEC/No-Intro tag parsing for system/video/language facet chips, wildcard search via `toSqlLike()` in `locate-db.ts` converting `*`→`%` and `?`→`_`, `MediaEntry` rows with badges + path, module-level `_store` persists results/scroll/filters across unmounts, actions dialog with MarqueeText + "Mount on virtual drive" + "Open containing folder", mounted-path highlight via `base_url + url` resolution); `SearchAssembly64.tsx` (Assembly64 Leet API: AQL search via `GET /search/aql/{offset}/{limit}`, categories + presets on mount, category filter chips, paginated ContentItem results, item tap fetches file entries, Download saves to `/sd/downloads/` via `putFileContents`, Mount opens device picker dialog, `client-id: meatloaf-config` header, module-level `_store`); header search icon → tab 0, Apps "Assembly64" card → tab 1
|
||||
|
||||
## Known Issues / Open Work
|
||||
|
||||
|
|
@ -292,7 +273,6 @@ All WebDAV I/O goes through `src/app/webdav.ts`:
|
|||
| `createFolder(path)` | MKCOL |
|
||||
| `deletePath(path)` | DELETE |
|
||||
| `movePath(from, to)` | MOVE |
|
||||
| `copyPath(from, to)` | COPY |
|
||||
| `fileExists(path)` | HEAD → `boolean` |
|
||||
| `humanFileSize(bytes)` | Formatting helper |
|
||||
| `normalizePath` / `splitPath` / `joinPath` / `basename` | Path utilities |
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import GeneralPage from './components/GeneralPage';
|
|||
import NetworkPage from './components/NetworkPage';
|
||||
import IECPage from './components/IECPage';
|
||||
import ToolsPage from './components/ToolsPage';
|
||||
import SearchPane from './components/SearchPane';
|
||||
import SearchOverlay from './components/SearchOverlay';
|
||||
import RealityOverrideAdminPage from './components/RealityOverrideAdminPage';
|
||||
import MediaManager from './components/MediaManager';
|
||||
import ProfilePage from './components/ProfilePage';
|
||||
|
|
@ -55,7 +55,6 @@ export default function App() {
|
|||
const [currentPage, setCurrentPage] = useState<Page>('status');
|
||||
const { config, setConfig, saveStatus, pendingCount, flushNow, reload } = useSettings();
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [searchInitialTab, setSearchInitialTab] = useState<0 | 1>(0);
|
||||
const [devicesOpenId, setDevicesOpenId] = useState<string | null>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [fileManagerInitialPath, setFileManagerInitialPath] = useState<string | undefined>(undefined);
|
||||
|
|
@ -118,7 +117,6 @@ export default function App() {
|
|||
<h2 className="text-lg font-semibold mb-4 text-blue-700">Management</h2>
|
||||
<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={<Database className="w-7 h-7" />} label="Assembly64" onClick={() => { setSearchInitialTab(1); setShowSearch(true); }} />
|
||||
<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={<Link className="w-7 h-7" />} label="Short Codes" onClick={() => setCurrentPage('serial-console')} />
|
||||
|
|
@ -260,7 +258,7 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
|
|||
{isFullscreen ? <Minimize2 className="w-5 h-5 text-white" /> : <Maximize2 className="w-5 h-5 text-white" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setSearchInitialTab(0); setShowSearch(true); }}
|
||||
onClick={() => setShowSearch(true)}
|
||||
className="p-2 hover:bg-[#5e5e5e] rounded-lg"
|
||||
>
|
||||
<Search className="w-5 h-5 text-white" />
|
||||
|
|
@ -315,18 +313,19 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
|
|||
</nav>
|
||||
|
||||
{showSearch && (
|
||||
<SearchPane
|
||||
<Suspense fallback={null}>
|
||||
<SearchOverlay
|
||||
config={config}
|
||||
setConfig={setConfig}
|
||||
initialTab={searchInitialTab}
|
||||
onClose={() => setShowSearch(false)}
|
||||
onOpenFolder={(path: string) => {
|
||||
onOpenFolder={(path) => {
|
||||
setFileManagerInitialPath(path);
|
||||
setFileManagerReturnPage('status');
|
||||
setShowSearch(false);
|
||||
setCurrentPage('file-manager');
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
</WsProvider>
|
||||
|
|
|
|||
|
|
@ -1,619 +0,0 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Search, Loader2, HardDrive, Download, ChevronRight, 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://hackerswithstyle.se/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': 'meatloaf-config' } });
|
||||
}
|
||||
|
||||
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 Preset {
|
||||
name?: string;
|
||||
title?: string;
|
||||
query?: string;
|
||||
aql?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SearchAssembly64Props {
|
||||
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 SearchAssembly64({ config, setConfig, onClose: _onClose }: SearchAssembly64Props) {
|
||||
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<Preset[]>([]);
|
||||
|
||||
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<Preset[]>('/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 = (p: Preset) => {
|
||||
const q = (p.query ?? p.aql ?? '') as string;
|
||||
setQuery(q);
|
||||
doSearch(q, categoryFilter, 0);
|
||||
};
|
||||
|
||||
// ── Item entries ─────────────────────────────────────────────────────────────
|
||||
|
||||
const openItem = async (item: ContentItem) => {
|
||||
setSelectedItem(item);
|
||||
setEntries(null);
|
||||
setLoadingEntries(true);
|
||||
try {
|
||||
const data = await leetJson<ContentEntry[]>(`/search/entries/${item.id}/${item.category}`);
|
||||
setEntries(data);
|
||||
} 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">
|
||||
{/* Search input */}
|
||||
<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 */}
|
||||
{presets.length > 0 && (
|
||||
<div className="flex gap-1.5 mb-2 overflow-x-auto pb-0.5 scrollbar-none">
|
||||
{presets.map((p, i) => {
|
||||
const label = (p.name ?? p.title ?? `Preset ${i + 1}`) as string;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => applyPreset(p)}
|
||||
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"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category filter */}
|
||||
{categories.length > 0 && (
|
||||
<div className="flex gap-1.5 overflow-x-auto pb-0.5 scrollbar-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 Assembly64 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { Search, Loader2, RefreshCw, FolderSearch, SlidersHorizontal, ArrowUp, ArrowDown, ArrowUpDown, HardDrive, FolderOpen } from 'lucide-react';
|
||||
import { X, Search, Loader2, RefreshCw, FolderSearch, SlidersHorizontal, ArrowUp, ArrowDown, ArrowUpDown, HardDrive, FolderOpen } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { toast } from 'sonner';
|
||||
import { humanFileSize, splitPath } from '../webdav';
|
||||
import type { EntryInfo } from '../webdav';
|
||||
|
|
@ -17,7 +18,7 @@ import {
|
|||
type ScanPhase,
|
||||
} from '../locate-db';
|
||||
|
||||
interface SearchLocalProps {
|
||||
interface SearchOverlayProps {
|
||||
config: any;
|
||||
setConfig: (config: any) => void;
|
||||
onClose: () => void;
|
||||
|
|
@ -40,6 +41,7 @@ const DISK_EXTS = new Set(['d64','d71','d81','d82','dnp','t64','tap','g64','nib'
|
|||
const CART_EXTS = new Set(['crt','bin']);
|
||||
const PRG_EXTS = new Set(['prg','p00']);
|
||||
|
||||
// Common ISO 639-1 codes seen in TOSEC / No-Intro filenames
|
||||
const LANG_CODES = ['En','De','Fr','Es','It','Nl','Sv','Da','Fi','Pt','Pl','Ru','Ja','Ko','Zh','Cs','Hu','No'];
|
||||
const LANG_NAMES: Record<string, string> = {
|
||||
En:'English', De:'German', Fr:'French', Es:'Spanish', It:'Italian',
|
||||
|
|
@ -125,6 +127,7 @@ function scanPhaseLabel(phase: ScanPhase, value: number): string {
|
|||
type SortField = 'name' | 'size';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
// Persisted across overlay open/close cycles (survives unmount).
|
||||
const _store = {
|
||||
query: '',
|
||||
results: [] as SearchResult[],
|
||||
|
|
@ -163,7 +166,7 @@ function FilterChips({
|
|||
);
|
||||
}
|
||||
|
||||
export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }: SearchLocalProps) {
|
||||
export default function SearchOverlay({ config, setConfig, onClose, onOpenFolder }: SearchOverlayProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [query, setQuery] = useState(() => _store.query);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
|
@ -188,6 +191,7 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
|||
if (isLocateDbLoaded()) setDbPhase('ready');
|
||||
}, []);
|
||||
|
||||
// Persist search state so it survives overlay close/reopen.
|
||||
useEffect(() => {
|
||||
_store.query = query;
|
||||
_store.results = results;
|
||||
|
|
@ -200,12 +204,14 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
|||
_store.sortDir = sortDir;
|
||||
}, [query, results, hasSearched, showFilter, filterSystem, filterVideo, filterLanguage, sortField, sortDir]);
|
||||
|
||||
// Restore scroll position after results render.
|
||||
useEffect(() => {
|
||||
if (_store.scrollTop > 0 && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = _store.scrollTop;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Build a set of fully-resolved paths currently mounted on any IEC device.
|
||||
const mountedPaths = useMemo(() => {
|
||||
const paths = new Set<string>();
|
||||
for (const d of Object.values(config?.devices?.iec ?? {})) {
|
||||
|
|
@ -302,6 +308,7 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
|||
|
||||
const busy = isSearching || isScanning;
|
||||
|
||||
// Collect unique facet values from the full result set
|
||||
const facets = useMemo(() => {
|
||||
const systems = [...new Set(results.map(r => r.system).filter(Boolean) as string[])].sort();
|
||||
const videos = [...new Set(results.map(r => r.video).filter(Boolean) as string[])].sort();
|
||||
|
|
@ -337,11 +344,25 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
|||
const activeFilters = [filterSystem, filterVideo, filterLanguage].filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
|
||||
className="fixed inset-0 bg-white/80 backdrop-blur-md flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 px-4 pt-3 pb-3 border-b border-neutral-100">
|
||||
<div className="flex items-center justify-end mb-3 gap-1">
|
||||
<div className="flex-shrink-0 px-4 pt-4 pb-3 border-b border-neutral-100">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-base font-semibold text-neutral-800">Search</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
{dbPhase === 'ready' && !busy && (
|
||||
<button
|
||||
onClick={handleRefreshDb}
|
||||
|
|
@ -373,6 +394,10 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
|||
)}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-neutral-100 text-neutral-500 ml-1">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
|
|
@ -422,6 +447,7 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
|
|
@ -430,6 +456,7 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
|||
className="flex-1 overflow-y-auto"
|
||||
onScroll={e => { _store.scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; }}
|
||||
>
|
||||
{/* Scanning progress */}
|
||||
{isScanning && scanPhase && (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
|
||||
|
|
@ -438,6 +465,7 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Spinner — only when no prior results to show */}
|
||||
{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" />
|
||||
|
|
@ -445,12 +473,14 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{!busy && searchError && (
|
||||
<div className="p-6 text-center">
|
||||
<p className="text-sm text-red-600">{searchError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{!searchError && hasSearched && (
|
||||
<div>
|
||||
{visibleResults.length > 0 ? (
|
||||
|
|
@ -502,6 +532,7 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isSearching && !hasSearched && (
|
||||
<div className="py-16 text-center px-6">
|
||||
<Search className="w-10 h-10 mx-auto mb-3 text-neutral-300" />
|
||||
|
|
@ -514,7 +545,8 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Actions dialog */}
|
||||
<Dialog open={actionEntry !== null} onOpenChange={open => !open && setActionEntry(null)}>
|
||||
|
|
@ -550,7 +582,7 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Mount dialog */}
|
||||
{/* Mount dialog — same pattern as MediaManager */}
|
||||
<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">
|
||||
|
|
@ -604,6 +636,6 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
|||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import SearchLocal from './SearchLocal';
|
||||
import SearchAssembly64 from './SearchAssembly64';
|
||||
|
||||
interface SearchPaneProps {
|
||||
config: any;
|
||||
setConfig: (c: any) => void;
|
||||
initialTab?: 0 | 1;
|
||||
onClose: () => void;
|
||||
onOpenFolder: (path: string) => void;
|
||||
}
|
||||
|
||||
const TABS = ['Local', 'Assembly64'] as const;
|
||||
|
||||
export default function SearchPane({ config, setConfig, initialTab = 0, onClose, onOpenFolder }: SearchPaneProps) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [activeTab, setActiveTab] = useState<0 | 1>(initialTab);
|
||||
|
||||
// Jump to initialTab without animation on first render
|
||||
useEffect(() => {
|
||||
const el = panelRef.current;
|
||||
if (!el || initialTab === 0) return;
|
||||
el.scrollLeft = initialTab * el.clientWidth;
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const scrollToTab = (idx: 0 | 1) => {
|
||||
const el = panelRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTo({ left: idx * el.clientWidth, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
const el = panelRef.current;
|
||||
if (!el) return;
|
||||
const idx = Math.round(el.scrollLeft / el.clientWidth) as 0 | 1;
|
||||
setActiveTab(idx);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<motion.div
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
|
||||
className="fixed inset-0 bg-white/80 backdrop-blur-md flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Tab bar */}
|
||||
<div className="flex-shrink-0 flex items-center border-b border-neutral-200/70 px-1">
|
||||
{TABS.map((label, i) => (
|
||||
<button
|
||||
key={label}
|
||||
onClick={() => scrollToTab(i as 0 | 1)}
|
||||
className={`px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||
activeTab === i
|
||||
? 'text-blue-600 border-blue-600'
|
||||
: 'text-neutral-500 border-transparent hover:text-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 mr-2 rounded-lg hover:bg-neutral-100 text-neutral-500 transition-colors"
|
||||
aria-label="Close search"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Swipeable panels */}
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="flex flex-1 min-h-0 overflow-x-auto snap-x snap-mandatory"
|
||||
style={{ scrollbarWidth: 'none', WebkitOverflowScrolling: 'touch' } as React.CSSProperties}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{/* Local panel */}
|
||||
<div className="w-full h-full flex-shrink-0 snap-start flex flex-col min-w-0">
|
||||
<SearchLocal
|
||||
config={config}
|
||||
setConfig={setConfig}
|
||||
onClose={onClose}
|
||||
onOpenFolder={onOpenFolder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Assembly64 panel */}
|
||||
<div className="w-full h-full flex-shrink-0 snap-start flex flex-col min-w-0">
|
||||
<SearchAssembly64
|
||||
config={config}
|
||||
setConfig={setConfig}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user