feat(SearchAssembly64): add AQL search help dialog and terms reference

This commit is contained in:
Jaime Idolpx 2026-06-14 03:21:41 -04:00
parent ed5a222fd6
commit ddbf0d2df2
2 changed files with 84 additions and 4 deletions

View File

@ -39,7 +39,20 @@ src/
NetworkPage.tsx # Network settings, WiFi scan/connect (via WS)
OtherPage.tsx # Misc settings
ToolsPage.tsx # Tools
SearchOverlay.tsx
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
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,
@ -72,6 +85,8 @@ 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/
@ -122,12 +137,14 @@ 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, Print Manager, **Serial Console** (implemented), Short Codes
- **Management**: Media Manager, **Assembly64** (implemented — opens SearchPane at Assembly64 tab), 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
@ -189,6 +206,8 @@ Grid of app cards grouped by category, each navigates to a stub `AppPage` unless
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
@ -273,6 +292,7 @@ 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 |

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Search, Loader2, HardDrive, Download, ChevronRight, Trophy, Calendar, Users, RefreshCw } from 'lucide-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';
@ -105,10 +105,28 @@ function RatingStars({ value, max = 10 }: { value?: number; max?: number }) {
);
}
// ─── 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);
@ -264,14 +282,23 @@ export default function SearchAssembly64({ config, setConfig, onClose: _onClose
<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-3 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-colors"
className="w-full pl-9 pr-9 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-colors"
disabled={isSearching}
/>
<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}
@ -550,6 +577,39 @@ export default function SearchAssembly64({ config, setConfig, onClose: _onClose
</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={() => {
setQuery(q => {
const trimmed = q.trimEnd();
return trimmed ? `${trimmed} ${term}` : term;
});
setShowAqlHelp(false);
setTimeout(() => inputRef.current?.focus(), 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>
</>
);
}