Compare commits

..

No commits in common. "3b94d3d9561174580ae21bf8dc9bd192be50cfc2" and "be9391caec703f9158bab2b86e5395a5c6a391a0" have entirely different histories.

5 changed files with 112 additions and 166 deletions

View File

@ -52,9 +52,7 @@ src/
# AQL search with pagination; category filter chips; ContentItem result rows; # AQL search with pagination; category filter chips; ContentItem result rows;
# item tap → /search/entries → file list with Download + Mount actions; # item tap → /search/entries → file list with Download + Mount actions;
# Download saves to /sd/downloads/ via putFileContents; # Download saves to /sd/downloads/ via putFileContents;
# Client-Id: Ultimate + User-Agent: Assembly Query headers (identifies # client-id: meatloaf-config header; module-level _store
# the 1541 Ultimate cartridge; server returns 464 for unknown Client-Id
# and 463 for missing/foreign User-Agent); module-level _store
WiFiScanOverlay.tsx # WiFi scan results; Connect sends "connect <ssid> [<pass>]" via WS WiFiScanOverlay.tsx # WiFi scan results; Connect sends "connect <ssid> [<pass>]" via WS
MediaManager.tsx # WebDAV file browser: file icons by type, kebab actions, MediaManager.tsx # WebDAV file browser: file icons by type, kebab actions,
# Configure Folder (.config editor), base_url mount logic, # Configure Folder (.config editor), base_url mount logic,
@ -209,7 +207,7 @@ Header search icon opens SearchPane at tab 0 (Local). "Assembly64" AppCard opens
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 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` 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 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: Ultimate` + `User-Agent: Assembly Query` headers (identifies the 1541 Ultimate cartridge; server returns 464 for wrong Client-Id and 463 for missing/foreign User-Agent), module-level `_store`); header search icon → tab 0, Apps "Assembly64" card → tab 1 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 ## Known Issues / Open Work

View File

@ -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>(0);
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);
@ -260,7 +260,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" />} {isFullscreen ? <Minimize2 className="w-5 h-5 text-white" /> : <Maximize2 className="w-5 h-5 text-white" />}
</button> </button>
<button <button
onClick={() => { setSearchInitialTab(undefined); setShowSearch(true); }} onClick={() => { setSearchInitialTab(0); setShowSearch(true); }}
className="p-2 hover:bg-[#5e5e5e] rounded-lg" className="p-2 hover:bg-[#5e5e5e] rounded-lg"
> >
<Search className="w-5 h-5 text-white" /> <Search className="w-5 h-5 text-white" />

View File

@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { SettingsInput } from './ui/settings-input'; import { SettingsInput } from './ui/settings-input';
import { X, Printer, HardDrive, Network, Box, FolderOpen } from 'lucide-react'; import { X, Printer, HardDrive, Network, Box, FolderOpen, MoreVertical, RotateCcw } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import { Swiper, SwiperSlide } from 'swiper/react'; import { Swiper, SwiperSlide } from 'swiper/react';
import type { Swiper as SwiperType } from 'swiper'; import type { Swiper as SwiperType } from 'swiper';
@ -176,7 +176,7 @@ function DeviceCard({ device, config, setConfig, isActive, onBrowsingChange }: D
return ( return (
<> <>
<div className="p-4 space-y-5"> <div className="p-4 space-y-6">
<div className="space-y-4"> <div className="space-y-4">
{!device.physical && ( {!device.physical && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -191,20 +191,27 @@ function DeviceCard({ device, config, setConfig, isActive, onBrowsingChange }: D
)} )}
<div> <div>
<label className="text-xs font-medium text-neutral-400 uppercase tracking-wide block mb-1.5">Device Name</label> <label className="text-sm text-neutral-500 block mb-2">Type</label>
<div className="px-3 py-2 bg-neutral-50 border border-neutral-200 rounded-lg text-neutral-700">
{device.type.charAt(0).toUpperCase() + device.type.slice(1)}
</div>
</div>
<div>
<label className="text-sm text-neutral-500 block mb-2">Device Name</label>
<SettingsInput <SettingsInput
type="text" type="text"
value={deviceData.name || device.name || `Device ${device.number}`} value={deviceData.name || device.name || `Device ${device.number}`}
onCommit={(v) => updateDeviceSetting([...getDevicePath(), 'name'], v)} onCommit={(v) => updateDeviceSetting([...getDevicePath(), 'name'], v)}
onClear={() => updateDeviceSetting([...getDevicePath(), 'name'], '')} onClear={() => updateDeviceSetting([...getDevicePath(), 'name'], '')}
className="px-3 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm" className="px-3 py-2 border border-neutral-300 rounded-lg"
containerClassName="w-full" containerClassName="w-full"
/> />
</div> </div>
{!device.physical && <> {!device.physical && <>
<div> <div>
<label className="text-xs font-medium text-neutral-400 uppercase tracking-wide block mb-1.5">Base URL</label> <label className="text-sm text-neutral-500 block mb-2">Base URL</label>
<div className="flex gap-2"> <div className="flex gap-2">
<SettingsInput <SettingsInput
type="text" type="text"
@ -212,19 +219,19 @@ function DeviceCard({ device, config, setConfig, isActive, onBrowsingChange }: D
onCommit={(v) => updateDeviceSetting([...getDevicePath(), 'base_url'], v)} onCommit={(v) => updateDeviceSetting([...getDevicePath(), 'base_url'], v)}
onClear={() => updateDeviceSetting([...getDevicePath(), 'base_url'], '')} onClear={() => updateDeviceSetting([...getDevicePath(), 'base_url'], '')}
containerClassName="flex-1" containerClassName="flex-1"
className="px-3 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm" className="px-3 py-2 border border-neutral-300 rounded-lg"
/> />
<button <button
onClick={() => setBrowsingField('base_url')} onClick={() => setBrowsingField('base_url')}
className="px-3 py-2.5 bg-neutral-100 hover:bg-neutral-200 rounded-xl transition-colors" className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
> >
<FolderOpen className="w-5 h-5 text-neutral-500" /> <FolderOpen className="w-5 h-5" />
</button> </button>
</div> </div>
</div> </div>
<div> <div>
<label className="text-xs font-medium text-neutral-400 uppercase tracking-wide block mb-1.5">URL</label> <label className="text-sm text-neutral-500 block mb-2">URL</label>
<div className="flex gap-2"> <div className="flex gap-2">
<SettingsInput <SettingsInput
type="text" type="text"
@ -252,13 +259,13 @@ function DeviceCard({ device, config, setConfig, isActive, onBrowsingChange }: D
setMediaSetFiles(null); setMediaSetFiles(null);
}} }}
containerClassName="flex-1" containerClassName="flex-1"
className="px-3 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm" className="px-3 py-2 border border-neutral-300 rounded-lg"
/> />
<button <button
onClick={() => setBrowsingField('url')} onClick={() => setBrowsingField('url')}
className="px-3 py-2.5 bg-neutral-100 hover:bg-neutral-200 rounded-xl transition-colors" className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
> >
<FolderOpen className="w-5 h-5 text-neutral-500" /> <FolderOpen className="w-5 h-5" />
</button> </button>
</div> </div>
{mediaSetFiles && ( {mediaSetFiles && (
@ -272,7 +279,7 @@ function DeviceCard({ device, config, setConfig, isActive, onBrowsingChange }: D
(deviceData.base_url ?? '').includes('://') || (deviceData.base_url ?? '').includes('://') ||
(deviceData.url ?? '').includes('://')) && ( (deviceData.url ?? '').includes('://')) && (
<div> <div>
<label className="text-xs font-medium text-neutral-400 uppercase tracking-wide block mb-1.5">Cache</label> <label className="text-sm text-neutral-500 block mb-2">Cache</label>
<div className="flex gap-2"> <div className="flex gap-2">
<SettingsInput <SettingsInput
type="text" type="text"
@ -280,13 +287,13 @@ function DeviceCard({ device, config, setConfig, isActive, onBrowsingChange }: D
onCommit={(v) => updateDeviceSetting([...getDevicePath(), 'cache'], v)} onCommit={(v) => updateDeviceSetting([...getDevicePath(), 'cache'], v)}
onClear={() => updateDeviceSetting([...getDevicePath(), 'cache'], '')} onClear={() => updateDeviceSetting([...getDevicePath(), 'cache'], '')}
containerClassName="flex-1" containerClassName="flex-1"
className="px-3 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm" className="px-3 py-2 border border-neutral-300 rounded-lg"
/> />
<button <button
onClick={() => setBrowsingField('cache')} onClick={() => setBrowsingField('cache')}
className="px-3 py-2.5 bg-neutral-100 hover:bg-neutral-200 rounded-xl transition-colors" className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
> >
<FolderOpen className="w-5 h-5 text-neutral-500" /> <FolderOpen className="w-5 h-5" />
</button> </button>
</div> </div>
</div> </div>
@ -295,37 +302,40 @@ function DeviceCard({ device, config, setConfig, isActive, onBrowsingChange }: D
{deviceData.mode !== undefined && ( {deviceData.mode !== undefined && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-sm text-neutral-500">Write Enabled</label> <label className="text-sm text-neutral-500">Mode</label>
{device.physical {device.physical
? <span className={`text-sm ${(deviceData.mode ?? 0) === 1 ? 'text-blue-600' : 'text-neutral-400'}`}> ? <span className="text-sm text-neutral-700 px-3 py-2">{(deviceData.mode ?? 0) === 0 ? 'Read Only' : 'Write Enabled'}</span>
{(deviceData.mode ?? 0) === 1 ? 'Yes' : 'No'} : <div className="flex rounded-lg border border-neutral-300 overflow-hidden text-sm">
</span> {([0, 1] as const).map((val, i) => (
: <button <button
onClick={() => updateDeviceSetting([...getDevicePath(), 'mode'], (deviceData.mode ?? 0) === 0 ? 1 : 0)} key={val}
className={`relative w-12 h-6 rounded-full transition-colors ${(deviceData.mode ?? 0) === 1 ? 'bg-blue-600' : 'bg-neutral-300'}`} onClick={() => updateDeviceSetting([...getDevicePath(), 'mode'], val)}
> className={`px-4 py-2 ${i > 0 ? 'border-l border-neutral-300' : ''} ${(deviceData.mode ?? 0) === val ? 'bg-blue-600 text-white' : 'bg-white text-neutral-700 hover:bg-neutral-50'}`}
<div className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${(deviceData.mode ?? 0) === 1 ? 'translate-x-6' : 'translate-x-0.5'}`} /> >
</button> {val === 0 ? 'Read Only' : 'Write Enabled'}
</button>
))}
</div>
} }
</div> </div>
)} )}
{deviceData.baud !== undefined && ( {deviceData.baud !== undefined && (
<div> <div>
<label className="text-xs font-medium text-neutral-400 uppercase tracking-wide block mb-1.5">Baud Rate</label> <label className="text-sm text-neutral-500 block mb-2">Baud Rate</label>
<SettingsInput <SettingsInput
type="number" type="number"
value={String(deviceData.baud ?? '')} value={String(deviceData.baud ?? '')}
onCommit={(v) => updateDeviceSetting([...getDevicePath(), 'baud'], parseInt(v))} onCommit={(v) => updateDeviceSetting([...getDevicePath(), 'baud'], parseInt(v))}
className="w-full px-3 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm" className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
/> />
</div> </div>
)} )}
</div> </div>
<div className="pt-4 border-t border-neutral-100"> <div className="pt-4 border-t border-neutral-200">
<h3 className="text-xs font-medium text-neutral-400 uppercase tracking-wide mb-1.5">Device ID</h3> <h3 className="text-sm text-neutral-500 mb-2">Device ID</h3>
<code className="text-xs text-neutral-500 bg-neutral-100 px-2.5 py-1.5 rounded-lg">{device.id}</code> <code className="text-xs text-neutral-600 bg-neutral-50 px-2 py-1 rounded">{device.id}</code>
</div> </div>
</div> </div>
@ -363,11 +373,13 @@ export default function DeviceDetailOverlay({
onIndexChange, onIndexChange,
}: DeviceDetailOverlayProps) { }: DeviceDetailOverlayProps) {
const [activeIndex, setActiveIndex] = useState(initialIndex); const [activeIndex, setActiveIndex] = useState(initialIndex);
const [showCommandMenu, setShowCommandMenu] = useState(false);
const [isBrowsing, setIsBrowsing] = useState(false); const [isBrowsing, setIsBrowsing] = useState(false);
const swiperRef = useRef<SwiperType | null>(null); const swiperRef = useRef<SwiperType | null>(null);
const activeDevice = devices[activeIndex] ?? devices[0]; const activeDevice = devices[activeIndex] ?? devices[0];
useEffect(() => { setShowCommandMenu(false); }, [activeIndex]);
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
@ -383,51 +395,66 @@ export default function DeviceDetailOverlay({
return ( return (
<AnimatePresence> <AnimatePresence>
<div className="fixed inset-0 z-50"> <motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 z-50"
onClick={onClose}
>
<motion.div <motion.div
initial={{ y: '100%' }} initial={{ y: '100%' }}
animate={{ y: 0 }} animate={{ y: 0 }}
exit={{ y: '100%' }} exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 28, stiffness: 280 }} transition={{ type: 'spring', damping: 30, stiffness: 300 }}
className="fixed inset-0 bg-white/80 backdrop-blur-md flex flex-col overflow-hidden" className="fixed inset-0 bg-white flex flex-col z-50"
onClick={(e) => e.stopPropagation()}
> >
{/* ── Header ── */} {/* ── Header ── */}
<div className="flex-shrink-0 border-b border-neutral-200/70 relative"> <div className="flex-shrink-0 border-b border-neutral-200 relative">
<div className="flex items-center gap-3 px-4 py-3"> <div className="flex items-center justify-between p-4">
<div className={`flex flex-col items-center gap-0.5 flex-shrink-0 ${ <button onClick={onClose} className="p-2 -m-2">
<X className="w-6 h-6" />
</button>
<div className={`flex flex-col items-center gap-0.5 ${
activeDevice.physical ? 'text-green-600' : activeDevice.enabled ? 'text-blue-600' : 'text-neutral-400' activeDevice.physical ? 'text-green-600' : activeDevice.enabled ? 'text-blue-600' : 'text-neutral-400'
}`}> }`}>
<span className="text-xs font-semibold leading-none tabular-nums">{activeDevice.number}</span> <span className="text-sm font-semibold leading-none">{activeDevice.number}</span>
<DeviceIcon device={activeDevice} /> <DeviceIcon device={activeDevice} />
{activeDevice.physical && ( {activeDevice.physical && (
<span className="text-[10px] text-green-700 px-1 py-0.5 bg-green-50 border border-green-200 rounded leading-none"> <span className="text-xs text-green-700 px-1 py-0.5 bg-green-50 border border-green-200 rounded leading-none">
Physical Physical
</span> </span>
)} )}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex items-center gap-2">
<p className="text-base font-semibold text-neutral-800 truncate leading-tight">
{activeDevice.name ?? `Device ${activeDevice.number}`}
</p>
<p className="text-xs text-neutral-400 capitalize">{activeDevice.type}</p>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{devices.length > 1 && ( {devices.length > 1 && (
<span className="text-xs text-neutral-400 tabular-nums px-1"> <span className="text-xs text-neutral-400 tabular-nums">
{activeIndex + 1} / {devices.length} {activeIndex + 1} / {devices.length}
</span> </span>
)} )}
<button <button onClick={() => setShowCommandMenu(v => !v)} className="p-2 -m-2">
onClick={onClose} <MoreVertical className="w-6 h-6" />
className="p-1.5 rounded-lg hover:bg-neutral-100 text-neutral-500 transition-colors"
>
<X className="w-5 h-5" />
</button> </button>
</div> </div>
</div> </div>
{showCommandMenu && (
<div className="absolute right-4 top-14 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[200px] z-20">
<button
onClick={() => {
console.log(`Reset device ${activeDevice.number}`);
setShowCommandMenu(false);
}}
className="w-full px-4 py-2 text-left hover:bg-neutral-50 flex items-center gap-2"
>
<RotateCcw className="w-4 h-4" />
Reset Device
</button>
</div>
)}
</div> </div>
{/* ── Swiper ── */} {/* ── Swiper ── */}
@ -456,7 +483,7 @@ export default function DeviceDetailOverlay({
</Swiper> </Swiper>
</div> </div>
</motion.div> </motion.div>
</div> </motion.div>
</AnimatePresence> </AnimatePresence>
); );
} }

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Search, Loader2, HardDrive, Download, ChevronRight, ChevronDown, Trophy, Calendar, Users, RefreshCw, HelpCircle } from 'lucide-react'; import { Search, Loader2, HardDrive, Download, ChevronRight, Trophy, Calendar, Users, RefreshCw, HelpCircle } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { humanFileSize, basename, joinPath, putFileContents } from '../webdav'; import { humanFileSize, basename, joinPath, putFileContents } from '../webdav';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
@ -14,17 +14,7 @@ const DOWNLOAD_DIR = '/sd/downloads';
function leetFetch(path: string, query?: Record<string, string>) { function leetFetch(path: string, query?: Record<string, string>) {
const url = new URL(LEET_BASE + path); const url = new URL(LEET_BASE + path);
if (query) Object.entries(query).forEach(([k, v]) => url.searchParams.set(k, v)); if (query) Object.entries(query).forEach(([k, v]) => url.searchParams.set(k, v));
// The server whitelists "Client-Id: Ultimate" and the "Assembly Query" return fetch(url.toString(), { headers: { 'client-id': 'meatloaf-config' } });
// User-Agent (matching the native Assembly64 client). Requests with the
// wrong identifiers get rejected: HTTP 464 for unknown Client-Id, HTTP
// 463 for a missing/foreign User-Agent. The server's CORS preflight
// allows both headers, so the browser accepts this request.
return fetch(url.toString(), {
headers: {
'Client-Id': 'Ultimate',
'User-Agent': 'Assembly Query',
},
});
} }
async function leetJson<T>(path: string, query?: Record<string, string>): Promise<T> { async function leetJson<T>(path: string, query?: Record<string, string>): Promise<T> {
@ -67,34 +57,14 @@ interface CategoryMapping {
[k: string]: unknown; [k: string]: unknown;
} }
interface PresetValue { interface Preset {
aqlKey: string;
name?: string; name?: string;
id?: number; title?: string;
query?: string;
aql?: string;
[k: string]: unknown; [k: string]: unknown;
} }
interface PresetGroup {
type: string; // 'repo' | 'category' | 'subcat' | 'rating' | 'type' | 'date' | 'latest' | 'sort' | 'order'
description: string; // Human-readable group label shown on the chip
values: PresetValue[];
}
// Each preset group maps to a search-token prefix. 'sort' and 'order' are not
// filters (they're result-ordering directives) so they get an empty prefix
// marker and the renderer skips them.
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 ──────────────────────────────────────────────────────────────────── // ─── Props ────────────────────────────────────────────────────────────────────
interface SearchAssembly64Props { interface SearchAssembly64Props {
@ -169,8 +139,7 @@ export default function SearchAssembly64({ config, setConfig, onClose: _onClose
const [categoryFilter, setCategoryFilter] = useState<number | null>(() => _store.categoryFilter); const [categoryFilter, setCategoryFilter] = useState<number | null>(() => _store.categoryFilter);
const [categories, setCategories] = useState<CategoryMapping[]>([]); const [categories, setCategories] = useState<CategoryMapping[]>([]);
const [presets, setPresets] = useState<PresetGroup[]>([]); const [presets, setPresets] = useState<Preset[]>([]);
const [activePreset, setActivePreset] = useState<PresetGroup | null>(null);
const [selectedItem, setSelectedItem] = useState<ContentItem | null>(null); const [selectedItem, setSelectedItem] = useState<ContentItem | null>(null);
const [entries, setEntries] = useState<ContentEntry[] | null>(null); const [entries, setEntries] = useState<ContentEntry[] | null>(null);
@ -231,21 +200,10 @@ export default function SearchAssembly64({ config, setConfig, onClose: _onClose
}; };
const handleSearch = () => doSearch(query, categoryFilter, 0); const handleSearch = () => doSearch(query, categoryFilter, 0);
const handleLoadMore = () => doSearch(query, categoryFilter, offset, true);
// Build an AQL token for a preset value and append/replace it in the query. const applyPreset = (p: Preset) => {
// Tokens that already contain a colon (e.g. 'subcat:c64comdemos') are const q = (p.query ?? p.aql ?? '') as string;
// inserted verbatim; raw values get the group's prefix. setQuery(q);
const applyPreset = (group: PresetGroup, value: PresetValue) => {
const prefix = PRESET_PREFIX[group.type];
setActivePreset(null);
if (prefix === null || prefix === undefined) return; // sort/order: not a filter
// Some aqlKeys are self-describing ("subcat:c64comdemos"); use them
// verbatim. Otherwise prepend the group's prefix.
const token = value.aqlKey.includes(':') ? value.aqlKey : `${prefix}:${value.aqlKey}`;
const trimmed = query.trim();
const next = trimmed ? `${trimmed} ${token}` : token;
setQuery(next);
doSearch(next);
doSearch(q, categoryFilter, 0); doSearch(q, categoryFilter, 0);
}; };
@ -256,13 +214,8 @@ export default function SearchAssembly64({ config, setConfig, onClose: _onClose
setEntries(null); setEntries(null);
setLoadingEntries(true); setLoadingEntries(true);
try { try {
// The /search/entries endpoint now returns { contentEntry: [...] } const data = await leetJson<ContentEntry[]>(`/search/entries/${item.id}/${item.category}`);
// (matching the API's ContentEntryContainerV2 schema), not a bare array. setEntries(data);
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) { } catch (e: any) {
toast.error(`Failed to load entries: ${e?.message ?? e}`); toast.error(`Failed to load entries: ${e?.message ?? e}`);
setEntries([]); setEntries([]);
@ -359,18 +312,18 @@ 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 scrollbar-none">
{presets {presets.map((p, i) => {
.filter(group => PRESET_PREFIX[group.type] !== null) // hide sort/order for now const label = (p.name ?? p.title ?? `Preset ${i + 1}`) as string;
.map((group, i) => ( return (
<button <button
key={`${group.type}-${i}`} key={i}
onClick={() => setActivePreset(group)} 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 inline-flex items-center gap-1" 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"
> >
{group.description} {label}
<ChevronDown className="w-3 h-3 opacity-60" />
</button> </button>
))} );
})}
</div> </div>
)} )}
@ -661,33 +614,6 @@ export default function SearchAssembly64({ config, setConfig, onClose: _onClose
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </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>
</> </>
); );
} }

View File

@ -14,18 +14,15 @@ interface SearchPaneProps {
const TABS = ['Local', 'Assembly64'] as const; const TABS = ['Local', 'Assembly64'] as const;
let _lastTab: 0 | 1 = (localStorage.getItem('search.tab') === '1' ? 1 : 0); export default function SearchPane({ config, setConfig, initialTab = 0, 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>(initialTab);
// Jump to starting tab without animation on first render // Jump to initialTab without animation on first render
useEffect(() => { useEffect(() => {
const el = panelRef.current; const el = panelRef.current;
const tab = initialTab ?? _lastTab; if (!el || initialTab === 0) return;
if (!el || tab === 0) return; el.scrollLeft = initialTab * 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) => {
@ -39,8 +36,6 @@ export default function SearchPane({ config, setConfig, initialTab, onClose, onO
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;
setActiveTab(idx); setActiveTab(idx);
_lastTab = idx;
localStorage.setItem('search.tab', String(idx));
}; };
return ( return (