Compare commits

...

7 Commits

Author SHA1 Message Date
936dc5db12 feat(SearchCommoServe): refactor search logic and enhance array extraction functionality 2026-06-15 15:56:03 -04:00
e3f7ba9c19 feat(webdav3): enhance CORS support for /commoserve/ proxy and update routing logic 2026-06-15 15:55:22 -04:00
c289ffd477 feat: enhance search components with filter and sort functionality
- Added filter and sort options to SearchCSDbNG, SearchCommoServe, and SearchLocal components.
- Introduced new state variables for managing filter text, sort direction, and visibility of filter options.
- Updated the visible results logic to incorporate filtering based on user input and sorting by name or other criteria.
- Enhanced UI with buttons for toggling filters and sorting, including visual indicators for active filters.
- Implemented action dialogs for item-specific actions in SearchCSDbNG and SearchCommoServe.
- Added CORS preflight handling and proxy functionality for /leet/ requests in the webdav3.py server.
2026-06-15 14:43:06 -04:00
e00081a9f6 feat(SearchAssembly64, SearchCommoServe, SearchPane): update fetch headers and improve UI responsiveness 2026-06-15 13:42:47 -04:00
80bffaf9ad feat(webdav3): implement proxy download functionality with CORS support 2026-06-15 01:54:22 -04:00
e6f4ecdc29 feat(SearchAssembly64): refactor download and mount functionality for improved clarity and state management 2026-06-15 01:41:31 -04:00
8957254471 feat(SearchCommoServe): add CORS handling and loading state for search errors 2026-06-15 01:16:18 -04:00
9 changed files with 748 additions and 301 deletions

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ files/*
node_modules/*
package-lock.json
__pycache__/*
api/*

View File

@ -7,6 +7,11 @@ self.addEventListener('activate', event => {
});
self.addEventListener('fetch', event => {
// Only intercept same-origin requests. Cross-origin fetches (external APIs,
// CDNs) must go straight to the network — routing them through the SW causes
// the browser to see duplicate CORS headers and reject the response.
if (new URL(event.request.url).origin !== self.location.origin) return;
event.respondWith(
caches.match(event.request).then(response => {
return response || fetch(event.request);

View File

@ -1,5 +1,5 @@
import { lazy, Suspense, useEffect, useState } from 'react';
import { Cpu, Wifi, Network, HardDrive, Activity, Search, Wrench, User, FileText, AppWindow, Folder, Edit, Eye, Database, Upload, Download, Code2, LayoutList, Image, ChevronLeft, Loader2, Terminal, Link, Printer, Maximize2, Minimize2 } from 'lucide-react';
import { Cpu, Wifi, Network, HardDrive, Activity, Search, Wrench, User, FileText, AppWindow, Folder, Edit, Eye, Database, Upload, Download, Code2, LayoutList, Image, ChevronLeft, Loader2, Terminal, Link, Printer } from 'lucide-react';
import { AnimatePresence } from 'motion/react';
import { Toaster, toast } from 'sonner';
import StatusPage from './components/StatusPage';
@ -57,7 +57,6 @@ export default function App() {
const { config, setConfig, saveStatus, pendingCount, flushNow, reload } = useSettings();
const [searchPanel, setSearchPanel] = useState<'local' | 'assembly64' | 'commoserve' | 'csdb' | 'last' | null>(null);
const [devicesOpenId, setDevicesOpenId] = useState<string | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [fileManagerInitialPath, setFileManagerInitialPath] = useState<string | undefined>(undefined);
const [fileManagerReturnPage, setFileManagerReturnPage] = useState<Page>('apps');
@ -83,24 +82,21 @@ export default function App() {
}
}, [saveStatus, pendingCount, flushNow, reload]);
// On touch devices, enter fullscreen on the first user interaction.
// The Fullscreen API requires a user gesture — this is the earliest
// opportunity that satisfies that requirement without a dedicated button.
useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onChange);
document.addEventListener('webkitfullscreenchange', onChange);
return () => {
document.removeEventListener('fullscreenchange', onChange);
document.removeEventListener('webkitfullscreenchange', onChange);
if (!navigator.maxTouchPoints) return;
const request = () => {
if (document.fullscreenElement) return;
(document.documentElement.requestFullscreen?.() ??
(document.documentElement as any).webkitRequestFullscreen?.())
?.catch?.(() => {});
};
document.addEventListener('pointerdown', request, { once: true });
return () => document.removeEventListener('pointerdown', request);
}, []);
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
(document.documentElement.requestFullscreen?.() ?? (document.documentElement as any).webkitRequestFullscreen?.());
} else {
(document.exitFullscreen?.() ?? (document as any).webkitExitFullscreen?.());
}
};
const pages = {
status: <StatusPage config={config} setConfig={setConfig} onOpenFileManager={(path) => { setFileManagerInitialPath(path); setFileManagerReturnPage('status'); setCurrentPage('file-manager'); }} />,
devices: <DevicesPage config={config} setConfig={setConfig} openDeviceId={devicesOpenId} onClearOpenDevice={() => setDevicesOpenId(null)} />,
@ -254,13 +250,6 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
<img src={logoSvg} alt="Meatloaf" className="h-full max-h-[56px] w-auto object-contain" />
</button>
<div className="flex items-center gap-3">
<button
onClick={toggleFullscreen}
className="p-2 hover:bg-[#5e5e5e] rounded-lg"
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
>
{isFullscreen ? <Minimize2 className="w-5 h-5 text-white" /> : <Maximize2 className="w-5 h-5 text-white" />}
</button>
<button
onClick={() => setSearchPanel('last')}
className="p-2 hover:bg-[#5e5e5e] rounded-lg"

View File

@ -1,9 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Search, Loader2, HardDrive, Download, ChevronRight, ChevronDown, Trophy, Calendar, Users, RefreshCw, HelpCircle, X } from 'lucide-react';
import { Search, Loader2, HardDrive, ChevronRight, ChevronDown, Trophy, Calendar, Users, RefreshCw, HelpCircle, X, SlidersHorizontal, MoreVertical, FolderOpen } 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';
import { EntryIcon } from './MediaEntry';
// ─── API ──────────────────────────────────────────────────────────────────────
@ -14,15 +15,10 @@ 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));
// The server whitelists "Client-Id: Ultimate" and the "Assembly Query"
// 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',
'Client-Id': 'meatloaf',
},
});
}
@ -75,14 +71,11 @@ interface PresetValue {
}
interface PresetGroup {
type: string; // 'repo' | 'category' | 'subcat' | 'rating' | 'type' | 'date' | 'latest' | 'sort' | 'order'
description: string; // Human-readable group label shown on the chip
type: string;
description: string;
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',
@ -95,6 +88,9 @@ const PRESET_PREFIX: Record<string, string | null> = {
order: null,
};
type SortField = 'name' | 'year' | 'rating';
type SortDir = 'asc' | 'desc';
// ─── Props ────────────────────────────────────────────────────────────────────
interface SearchAssembly64Props {
@ -113,6 +109,10 @@ const _store = {
hasSearched: false,
categoryFilter: null as number | null,
scrollTop: 0,
showFilter: false,
filterText: '',
sortField: 'name' as SortField,
sortDir: 'asc' as SortDir,
};
// ─── Helpers ──────────────────────────────────────────────────────────────────
@ -121,10 +121,6 @@ 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);
@ -176,8 +172,14 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
const [entries, setEntries] = useState<ContentEntry[] | null>(null);
const [loadingEntries, setLoadingEntries] = useState(false);
const [actionItem, setActionItem] = useState<ContentItem | null>(null);
const [mountEntry, setMountEntry] = useState<{ item: ContentItem; entry: ContentEntry } | null>(null);
const [downloading, setDownloading] = useState<number | null>(null);
const [isMounting, setIsMounting] = useState(false);
const [showFilter, setShowFilter] = useState(() => _store.showFilter);
const [filterText, setFilterText] = useState(() => _store.filterText);
const [sortField, setSortField] = useState<SortField>(() => _store.sortField);
const [sortDir, setSortDir] = useState<SortDir>(() => _store.sortDir);
useEffect(() => {
_store.query = query;
@ -186,7 +188,11 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
_store.hasMore = hasMore;
_store.hasSearched = hasSearched;
_store.categoryFilter = categoryFilter;
}, [query, results, offset, hasMore, hasSearched, categoryFilter]);
_store.showFilter = showFilter;
_store.filterText = filterText;
_store.sortField = sortField;
_store.sortDir = sortDir;
}, [query, results, offset, hasMore, hasSearched, categoryFilter, showFilter, filterText, sortField, sortDir]);
useEffect(() => {
if (_store.scrollTop > 0 && scrollRef.current)
@ -195,7 +201,7 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
useEffect(() => {
leetJson<CategoryMapping[]>('/search/categories').then(setCategories).catch(() => {});
leetJson<Preset[]>('/search/aql/presets').then(setPresets).catch(() => {});
leetJson<PresetGroup[]>('/search/aql/presets').then(setPresets).catch(() => {});
}, []);
const categoryName = useMemo(() => {
@ -230,25 +236,48 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
}
};
const handleSearch = () => doSearch(query, categoryFilter, 0);
const handleSearch = () => { setFilterText(''); 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.
// Tokens that already contain a colon (e.g. 'subcat:c64comdemos') are
// inserted verbatim; raw values get the group's prefix.
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.
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);
doSearch(q, categoryFilter, 0);
doSearch(next, categoryFilter, 0);
};
// ── Filter / sort ────────────────────────────────────────────────────────────
const toggleSort = (field: SortField) => {
if (sortField === field) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setSortField(field); setSortDir('asc'); }
};
const visibleResults = useMemo(() => {
const needle = filterText.trim().toLowerCase();
let list = needle
? results.filter(r =>
r.name.toLowerCase().includes(needle) ||
(r.group?.toLowerCase().includes(needle)) ||
(r.handle?.toLowerCase().includes(needle))
)
: results;
list = [...list].sort((a, b) => {
let cmp: number;
if (sortField === 'year') cmp = (a.year ?? 0) - (b.year ?? 0);
else if (sortField === 'rating') cmp = (a.siteRating ?? 0) - (b.siteRating ?? 0);
else cmp = a.name.localeCompare(b.name);
return sortDir === 'asc' ? cmp : -cmp;
});
return list;
}, [results, filterText, sortField, sortDir]);
const activeFilters = filterText ? 1 : 0;
// ── Item entries ─────────────────────────────────────────────────────────────
const openItem = async (item: ContentItem) => {
@ -256,8 +285,6 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
setEntries(null);
setLoadingEntries(true);
try {
// The /search/entries endpoint now returns { contentEntry: [...] }
// (matching the API's ContentEntryContainerV2 schema), not a bare array.
const data = await leetJson<ContentEntry[] | { contentEntry: ContentEntry[] }>(
`/search/entries/${item.id}/${item.category}`,
);
@ -271,41 +298,35 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
}
};
// ── Download to SD ───────────────────────────────────────────────────────────
// ── Mount ────────────────────────────────────────────────────────────────────
const downloadToSd = async (item: ContentItem, entry: ContentEntry) => {
setDownloading(entry.id);
const mountOnDevice = async (deviceType: 'drive' | 'meatloaf', key: string) => {
if (!mountEntry) return;
const { item, entry } = mountEntry;
setIsMounting(true);
try {
const url = downloadUrl(item, entry);
const res = await fetch(url);
const res = await leetFetch(`/search/bin/${item.id}/${item.category}/${entry.id}`);
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);
dev.url = dest;
delete dev.media_set;
if (!dev.enabled) dev.enabled = 1;
setConfig(newConfig);
setMountEntry(null);
toast.success(`Mounted "${entryFilename(entry)}" on ${deviceType} #${key}`);
setSelectedItem(null);
toast.success(`Downloaded and mounted "${entryFilename(entry)}" on device #${key}`);
} catch (e: any) {
toast.error(`Mount failed: ${e?.message ?? e}`);
} finally {
setIsMounting(false);
}
};
const mountedUrls = useMemo(() => {
@ -322,19 +343,33 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
return (
<>
<div className="flex flex-col h-full overflow-hidden">
{/* Panel header */}
<div className="flex-shrink-0 flex items-center border-b border-neutral-200/70 px-4">
{/* Panel header — X on the left so it's never behind a top-right camera */}
<div className="flex-shrink-0 flex items-center gap-2 border-b border-neutral-200/70 px-4">
<button onClick={onClose} className="p-1.5 my-1.5 rounded-lg hover:bg-neutral-100 text-neutral-500 transition-colors flex-shrink-0" aria-label="Close search">
<X className="w-5 h-5" />
</button>
<div className="flex items-center gap-2 flex-1 py-3 min-w-0">
<img src={`${import.meta.env.BASE_URL}assets/favicon.a64.png`} className="w-5 h-5 flex-shrink-0 object-contain" alt="" aria-hidden="true" />
<span className="text-sm font-semibold text-neutral-700">Assembly64</span>
</div>
<button onClick={onClose} className="p-1.5 my-1.5 rounded-lg hover:bg-neutral-100 text-neutral-500 transition-colors" aria-label="Close search">
<X className="w-5 h-5" />
{hasSearched && (
<button
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'}`}
title="Filter & sort results"
>
<SlidersHorizontal className="w-4 h-4" />
{activeFilters > 0 && (
<span className="absolute -top-0.5 -right-0.5 w-3.5 h-3.5 rounded-full bg-blue-600 text-white text-[9px] flex items-center justify-center font-bold">
{activeFilters}
</span>
)}
</button>
)}
</div>
{/* Header */}
{/* Search input + presets + categories */}
<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" />
@ -345,6 +380,8 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
onChange={e => setQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !isSearching && handleSearch()}
placeholder="name:manic* group:ultimate year:1983…"
inputMode="search"
enterKeyHint="search"
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}
/>
@ -366,11 +403,10 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
</button>
</div>
{/* Presets */}
{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) // hide sort/order for now
.filter(group => PRESET_PREFIX[group.type] !== null)
.map((group, i) => (
<button
key={`${group.type}-${i}`}
@ -384,7 +420,6 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
</div>
)}
{/* Category filter */}
{categories.length > 0 && (
<div className="flex gap-1.5 overflow-x-auto pb-0.5" style={{ scrollbarWidth: 'none' }}>
<button
@ -409,6 +444,37 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
)}
</div>
{/* Filter + sort bar */}
<div className={`overflow-hidden flex-shrink-0 transition-all duration-200 ease-in-out ${showFilter ? 'max-h-24 opacity-100' : 'max-h-0 opacity-0'}`}>
<div className="bg-neutral-50 border-b border-neutral-200 px-4 py-2 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 results…"
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', 'year', 'rating'] 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' : f === 'year' ? 'Year' : 'Rating'}
{sortField === f ? (sortDir === 'asc' ? ' ↑' : ' ↓') : ''}
</button>
))}
</div>
</div>
{/* Body */}
<div
ref={scrollRef}
@ -433,19 +499,22 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
{!searchError && hasSearched && (
<>
{results.length > 0 ? (
{visibleResults.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 ? '+' : ''}
{visibleResults.length}{results.length !== visibleResults.length ? ` of ${results.length}` : ''} result{visibleResults.length !== 1 ? 's' : ''}{hasMore ? '+' : ''}
</p>
{results.map(item => {
{visibleResults.map(item => {
const catLabel = categoryName[item.category] ?? `Cat ${item.category}`;
return (
<button
<div
key={item.id}
className="flex items-stretch border-b border-neutral-100 border-l-2 border-l-transparent hover:bg-blue-50 hover:border-l-blue-400 transition-colors"
>
<button
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"
className="flex-1 pl-4 pr-2 py-3 flex items-center gap-3 text-left min-w-0"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
@ -480,6 +549,14 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
<ChevronRight className="w-4 h-4 text-neutral-300" />
</div>
</button>
<button
onClick={() => setActionItem(item)}
className="px-3 text-neutral-300 hover:text-neutral-600 flex-shrink-0 transition-colors"
aria-label="Actions"
>
<MoreVertical className="w-4 h-4" />
</button>
</div>
);
})}
@ -499,7 +576,14 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
) : (
<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>
<p className="text-sm text-neutral-500">
{results.length > 0 ? 'No results match the current filter' : 'No results'}
</p>
{results.length > 0 && filterText && (
<button onClick={() => setFilterText('')} className="mt-2 text-xs text-blue-600 hover:underline">
Clear filter
</button>
)}
</div>
)}
</>
@ -519,6 +603,29 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
</div>
</div>
{/* Actions dialog */}
<Dialog open={actionItem !== null} onOpenChange={open => !open && setActionItem(null)}>
<DialogContent className="max-w-sm">
<DialogHeader className="overflow-hidden min-w-0">
<DialogTitle className="sr-only">{actionItem?.name}</DialogTitle>
<p className="text-lg font-semibold leading-none pr-6 overflow-hidden min-w-0">
<MarqueeText>{actionItem?.name}</MarqueeText>
</p>
<DialogDescription>
{[actionItem?.group, actionItem?.year, actionItem?.event].filter(Boolean).join(' · ')}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<button
onClick={() => { openItem(actionItem!); setActionItem(null); }}
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3"
>
<FolderOpen className="w-4 h-4 text-blue-600" /> <span>Browse Files</span>
</button>
</div>
</DialogContent>
</Dialog>
{/* Item entries dialog */}
<Dialog open={selectedItem !== null} onOpenChange={open => !open && setSelectedItem(null)}>
<DialogContent className="max-w-sm flex flex-col max-h-[90dvh]">
@ -545,34 +652,21 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
<div className="flex flex-col gap-2 py-1">
{entries.map(entry => {
const fname = entryFilename(entry);
const isMounted = mountedUrls.has(downloadUrl(selectedItem!, entry));
const localPath = joinPath(DOWNLOAD_DIR, fname);
const isMounted = mountedUrls.has(localPath);
return (
<div
<button
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'}`}
onClick={() => setMountEntry({ item: selectedItem!, entry })}
className={`px-4 py-3 rounded-lg border flex items-center gap-3 text-left w-full transition-colors ${isMounted ? 'border-blue-300 bg-blue-50' : 'border-neutral-200 hover:bg-blue-50 hover:border-blue-300'}`}
>
<EntryIcon entry={{ name: fname, type: 'file', path: entry.path, size: entry.size, lastModified: new Date(entry.date * 1000), contentType: '' }} />
<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" />}
{isMounted && <span className="text-xs text-blue-600 flex-shrink-0">Mounted</span>}
</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>
@ -591,7 +685,13 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
</DialogDescription>
</DialogHeader>
<div className="overflow-y-auto flex-1 min-h-0">
{(() => {
{isMounting && (
<div className="flex flex-col items-center justify-center py-10 gap-3">
<Loader2 className="w-7 h-7 text-blue-500 animate-spin" />
<p className="text-sm text-neutral-500">Downloading</p>
</div>
)}
{!isMounting && (() => {
const allDevices = Object.entries(config?.devices?.iec ?? {});
const drives = allDevices
.filter(([, v]: [string, any]) => (v as any)?.type === 'drive')

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Search, Loader2, HardDrive, Download, ChevronRight, X, Archive } from 'lucide-react';
import { Search, Loader2, HardDrive, Download, ChevronRight, X, Archive, SlidersHorizontal, MoreVertical, FolderOpen } from 'lucide-react';
import { toast } from 'sonner';
import { basename, joinPath, putFileContents } from '../webdav';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
@ -41,6 +41,8 @@ interface CsdbRelease {
DownloadLinks: CsdbDownloadLink[];
}
type SortDir = 'asc' | 'desc';
// ─── Props ────────────────────────────────────────────────────────────────────
interface SearchCSDbNGProps {
@ -57,6 +59,9 @@ const _store = {
hasSearched: false,
tagFilter: null as string | null,
scrollTop: 0,
showFilter: false,
filterText: '',
sortDir: 'asc' as SortDir,
};
// ─── Helpers ──────────────────────────────────────────────────────────────────
@ -82,15 +87,23 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
const [release, setRelease] = useState<CsdbRelease | null>(null);
const [loadingRelease, setLoadingRelease] = useState(false);
const [actionRow, setActionRow] = useState<CsdbRow | null>(null);
const [mountEntry, setMountEntry] = useState<{ row: CsdbRow; link: CsdbDownloadLink } | null>(null);
const [downloading, setDownloading] = useState<string | null>(null);
const [showFilter, setShowFilter] = useState(() => _store.showFilter);
const [filterText, setFilterText] = useState(() => _store.filterText);
const [sortDir, setSortDir] = useState<SortDir>(() => _store.sortDir);
useEffect(() => {
_store.query = query;
_store.results = results;
_store.hasSearched = hasSearched;
_store.tagFilter = tagFilter;
}, [query, results, hasSearched, tagFilter]);
_store.showFilter = showFilter;
_store.filterText = filterText;
_store.sortDir = sortDir;
}, [query, results, hasSearched, tagFilter, showFilter, filterText, sortDir]);
useEffect(() => {
if (_store.scrollTop > 0 && scrollRef.current)
@ -104,6 +117,7 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
setIsSearching(true);
setSearchError(null);
setTagFilter(null);
setFilterText('');
try {
const res = await fetch(`${CSDB_API}/search/${encodeURIComponent(q.trim())}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@ -191,27 +205,52 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
return [...set].sort();
}, [results]);
const visibleResults = useMemo(() =>
tagFilter ? results.filter(r => typeLabel(r.tags) === tagFilter) : results,
[results, tagFilter]
const visibleResults = useMemo(() => {
const needle = filterText.trim().toLowerCase();
let list = results.filter(r =>
(!tagFilter || typeLabel(r.tags) === tagFilter) &&
(!needle || r.name.toLowerCase().includes(needle))
);
list = [...list].sort((a, b) => {
const cmp = a.name.localeCompare(b.name);
return sortDir === 'asc' ? cmp : -cmp;
});
return list;
}, [results, tagFilter, filterText, sortDir]);
const activeFilters = (filterText ? 1 : 0) + (tagFilter ? 1 : 0);
// ── Render ────────────────────────────────────────────────────────────────────
return (
<>
<div className="flex flex-col h-full overflow-hidden">
{/* Panel header */}
<div className="flex-shrink-0 flex items-center border-b border-neutral-200/70 px-4">
{/* Panel header — X on the left so it's never behind a top-right camera */}
<div className="flex-shrink-0 flex items-center gap-2 border-b border-neutral-200/70 px-4">
<button onClick={onClose} className="p-1.5 my-1.5 rounded-lg hover:bg-neutral-100 text-neutral-500 transition-colors flex-shrink-0" aria-label="Close search">
<X className="w-5 h-5" />
</button>
<div className="flex items-center gap-2 flex-1 py-3 min-w-0">
<Archive className="w-5 h-5 flex-shrink-0 text-neutral-500" aria-hidden="true" />
<span className="text-sm font-semibold text-neutral-700">CSDb-ng</span>
</div>
<button onClick={onClose} className="p-1.5 my-1.5 rounded-lg hover:bg-neutral-100 text-neutral-500 transition-colors" aria-label="Close search">
<X className="w-5 h-5" />
{hasSearched && (
<button
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'}`}
title="Filter & sort results"
>
<SlidersHorizontal className="w-4 h-4" />
{activeFilters > 0 && (
<span className="absolute -top-0.5 -right-0.5 w-3.5 h-3.5 rounded-full bg-blue-600 text-white text-[9px] flex items-center justify-center font-bold">
{activeFilters}
</span>
)}
</button>
)}
</div>
{/* Header */}
{/* Search input */}
<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">
@ -222,6 +261,8 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
onChange={e => setQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !isSearching && doSearch(query)}
placeholder="Search CSDb-ng…"
inputMode="search"
enterKeyHint="search"
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}
/>
@ -257,6 +298,33 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
)}
</div>
{/* Filter + sort bar */}
<div className={`overflow-hidden flex-shrink-0 transition-all duration-200 ease-in-out ${showFilter ? 'max-h-16 opacity-100' : 'max-h-0 opacity-0'}`}>
<div className="bg-neutral-50 border-b border-neutral-200 px-4 py-2 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 results…"
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>
<button
onClick={() => setSortDir(d => d === 'asc' ? 'desc' : 'asc')}
className={`text-xs px-2 py-1 rounded border flex-shrink-0 border-blue-400 bg-blue-50 text-blue-700`}
>
Name {sortDir === 'asc' ? '↑' : '↓'}
</button>
</div>
</div>
{/* Body */}
<div
ref={scrollRef}
@ -283,13 +351,16 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
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' : ''}
{visibleResults.length}{results.length !== visibleResults.length ? ` of ${results.length}` : ''} result{visibleResults.length !== 1 ? 's' : ''}
</p>
{visibleResults.map(row => (
<button
<div
key={row.id}
className="flex items-stretch border-b border-neutral-100 border-l-2 border-l-transparent hover:bg-blue-50 hover:border-l-blue-400 transition-colors"
>
<button
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"
className="flex-1 pl-4 pr-2 py-3 flex items-center gap-3 text-left min-w-0"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
@ -299,12 +370,30 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
</div>
<ChevronRight className="w-4 h-4 text-neutral-300 flex-shrink-0" />
</button>
<button
onClick={() => setActionRow(row)}
className="px-3 text-neutral-300 hover:text-neutral-600 flex-shrink-0 transition-colors"
aria-label="Actions"
>
<MoreVertical className="w-4 h-4" />
</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>
<p className="text-sm text-neutral-500">
{results.length > 0 ? 'No results match the current filter' : 'No results'}
</p>
{results.length > 0 && (filterText || tagFilter) && (
<button
onClick={() => { setFilterText(''); setTagFilter(null); }}
className="mt-2 text-xs text-blue-600 hover:underline"
>
Clear filters
</button>
)}
</div>
)
)}
@ -319,6 +408,27 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
</div>
</div>
{/* Actions dialog */}
<Dialog open={actionRow !== null} onOpenChange={open => !open && setActionRow(null)}>
<DialogContent className="max-w-sm">
<DialogHeader className="overflow-hidden min-w-0">
<DialogTitle className="sr-only">{actionRow?.name}</DialogTitle>
<p className="text-lg font-semibold leading-none pr-6 overflow-hidden min-w-0">
<MarqueeText>{actionRow?.name}</MarqueeText>
</p>
<DialogDescription>{actionRow ? typeLabel(actionRow.tags) : ''}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<button
onClick={() => { openRelease(actionRow!); setActionRow(null); }}
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3"
>
<FolderOpen className="w-4 h-4 text-blue-600" /> <span>Browse Downloads</span>
</button>
</div>
</DialogContent>
</Dialog>
{/* Release detail dialog */}
<Dialog open={selectedRow !== null} onOpenChange={open => !open && setSelectedRow(null)}>
<DialogContent className="max-w-sm flex flex-col max-h-[90dvh]">

View File

@ -1,9 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Search, Loader2, HardDrive, Download, ChevronRight, ChevronDown, Trophy, Calendar, Users, RefreshCw, HelpCircle, X } from 'lucide-react';
import { Search, Loader2, HardDrive, Download, ChevronRight, ChevronDown, Trophy, Calendar, Users, RefreshCw, HelpCircle, X, SlidersHorizontal, MoreVertical, FolderOpen } 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';
import { EntryIcon } from './MediaEntry';
// ─── API ──────────────────────────────────────────────────────────────────────
@ -16,8 +17,7 @@ function leetFetch(path: string, query?: Record<string, string>) {
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',
'Client-Id': 'Commodore',
},
});
}
@ -29,6 +29,17 @@ async function leetJson<T>(path: string, query?: Record<string, string>): Promis
return data as T;
}
// CommoServe may wrap arrays in an object — unwrap the first array value found.
function extractArray<T>(data: unknown): T[] {
if (Array.isArray(data)) return data as T[];
if (data && typeof data === 'object') {
for (const v of Object.values(data as object)) {
if (Array.isArray(v) && v.length > 0) return v as T[];
}
}
return [];
}
// ─── Types ────────────────────────────────────────────────────────────────────
interface ContentItem {
@ -55,13 +66,6 @@ interface ContentEntry {
date: number;
}
interface CategoryMapping {
id: number;
name?: string;
title?: string;
[k: string]: unknown;
}
interface PresetValue {
aqlKey: string;
name?: string;
@ -87,6 +91,9 @@ const PRESET_PREFIX: Record<string, string | null> = {
order: null,
};
type SortField = 'name' | 'year' | 'rating';
type SortDir = 'asc' | 'desc';
// ─── Props ────────────────────────────────────────────────────────────────────
interface SearchCommoServeProps {
@ -105,6 +112,10 @@ const _store = {
hasSearched: false,
categoryFilter: null as number | null,
scrollTop: 0,
showFilter: false,
filterText: '',
sortField: 'name' as SortField,
sortDir: 'asc' as SortDir,
};
// ─── Helpers ──────────────────────────────────────────────────────────────────
@ -158,9 +169,7 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
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 [corsBlocked, setCorsBlocked] = useState(false);
const [presets, setPresets] = useState<PresetGroup[]>([]);
const [activePreset, setActivePreset] = useState<PresetGroup | null>(null);
@ -168,17 +177,26 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
const [entries, setEntries] = useState<ContentEntry[] | null>(null);
const [loadingEntries, setLoadingEntries] = useState(false);
const [actionItem, setActionItem] = useState<ContentItem | null>(null);
const [mountEntry, setMountEntry] = useState<{ item: ContentItem; entry: ContentEntry } | null>(null);
const [downloading, setDownloading] = useState<number | null>(null);
const [showFilter, setShowFilter] = useState(() => _store.showFilter);
const [filterText, setFilterText] = useState(() => _store.filterText);
const [sortField, setSortField] = useState<SortField>(() => _store.sortField);
const [sortDir, setSortDir] = useState<SortDir>(() => _store.sortDir);
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]);
_store.showFilter = showFilter;
_store.filterText = filterText;
_store.sortField = sortField;
_store.sortDir = sortDir;
}, [query, results, offset, hasMore, hasSearched, showFilter, filterText, sortField, sortDir]);
useEffect(() => {
if (_store.scrollTop > 0 && scrollRef.current)
@ -186,44 +204,38 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
}, []);
useEffect(() => {
leetJson<CategoryMapping[]>('/search/categories').then(setCategories).catch(() => {});
leetJson<PresetGroup[]>('/search/aql/presets').then(setPresets).catch(() => {});
leetJson<unknown>('/search/aql/presets').then(d => setPresets(extractArray<PresetGroup>(d))).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) => {
const doSearch = async (q: string, 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[]>(
const aql = q.trim();
const raw = await leetJson<unknown>(
`/search/aql/${fromOffset}/${PAGE_SIZE}`,
aql ? { query: aql } : undefined,
);
const data = extractArray<ContentItem>(raw);
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');
if (/failed to fetch|networkerror/i.test(e?.message ?? '')) setCorsBlocked(true);
else setSearchError(e?.message ?? 'Search failed');
} finally {
setIsSearching(false);
setIsLoadingMore(false);
}
};
const handleSearch = () => doSearch(query, categoryFilter, 0);
const handleLoadMore = () => doSearch(query, categoryFilter, offset, true);
const handleSearch = () => { setFilterText(''); doSearch(query, 0); };
const handleLoadMore = () => doSearch(query, offset, true);
const applyPreset = (group: PresetGroup, value: PresetValue) => {
const prefix = PRESET_PREFIX[group.type];
@ -233,9 +245,37 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
const trimmed = query.trim();
const next = trimmed ? `${trimmed} ${token}` : token;
setQuery(next);
doSearch(next, categoryFilter, 0);
doSearch(next, 0);
};
// ── Filter / sort ────────────────────────────────────────────────────────────
const toggleSort = (field: SortField) => {
if (sortField === field) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setSortField(field); setSortDir('asc'); }
};
const visibleResults = useMemo(() => {
const needle = filterText.trim().toLowerCase();
let list = needle
? results.filter(r =>
r.name.toLowerCase().includes(needle) ||
(r.group?.toLowerCase().includes(needle)) ||
(r.handle?.toLowerCase().includes(needle))
)
: results;
list = [...list].sort((a, b) => {
let cmp: number;
if (sortField === 'year') cmp = (a.year ?? 0) - (b.year ?? 0);
else if (sortField === 'rating') cmp = (a.siteRating ?? 0) - (b.siteRating ?? 0);
else cmp = a.name.localeCompare(b.name);
return sortDir === 'asc' ? cmp : -cmp;
});
return list;
}, [results, filterText, sortField, sortDir]);
const activeFilters = filterText ? 1 : 0;
// ── Item entries ─────────────────────────────────────────────────────────────
const openItem = async (item: ContentItem) => {
@ -261,8 +301,7 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
const downloadToSd = async (item: ContentItem, entry: ContentEntry) => {
setDownloading(entry.id);
try {
const url = downloadUrl(item, entry);
const res = await fetch(url);
const res = await fetch(downloadUrl(item, entry));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.arrayBuffer();
const dest = joinPath(DOWNLOAD_DIR, entryFilename(entry));
@ -302,22 +341,38 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
return s;
}, [config]);
// ── Render ────────────────────────────────────────────────────────────────────
return (
<>
<div className="flex flex-col h-full overflow-hidden">
{/* Panel header */}
<div className="flex-shrink-0 flex items-center border-b border-neutral-200/70 px-4">
{/* Panel header — X on the left so it's never behind a top-right camera */}
<div className="flex-shrink-0 flex items-center gap-2 border-b border-neutral-200/70 px-4">
<button onClick={onClose} className="p-1.5 my-1.5 rounded-lg hover:bg-neutral-100 text-neutral-500 transition-colors flex-shrink-0" aria-label="Close search">
<X className="w-5 h-5" />
</button>
<div className="flex items-center gap-2 flex-1 py-3 min-w-0">
<img src={`${import.meta.env.BASE_URL}assets/favicon.cbm.png`} className="w-5 h-5 flex-shrink-0 object-contain" alt="" aria-hidden="true" />
<span className="text-sm font-semibold text-neutral-700">CommoServe</span>
</div>
<button onClick={onClose} className="p-1.5 my-1.5 rounded-lg hover:bg-neutral-100 text-neutral-500 transition-colors" aria-label="Close search">
<X className="w-5 h-5" />
{hasSearched && (
<button
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'}`}
title="Filter & sort results"
>
<SlidersHorizontal className="w-4 h-4" />
{activeFilters > 0 && (
<span className="absolute -top-0.5 -right-0.5 w-3.5 h-3.5 rounded-full bg-blue-600 text-white text-[9px] flex items-center justify-center font-bold">
{activeFilters}
</span>
)}
</button>
)}
</div>
{/* Header */}
{/* Search input + presets + categories */}
<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">
@ -329,6 +384,8 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
onChange={e => setQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !isSearching && handleSearch()}
placeholder="name:manic* group:ultimate year:1983…"
inputMode="search"
enterKeyHint="search"
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}
/>
@ -367,29 +424,38 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
</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>
{/* Filter + sort bar */}
<div className={`overflow-hidden flex-shrink-0 transition-all duration-200 ease-in-out ${showFilter ? 'max-h-24 opacity-100' : 'max-h-0 opacity-0'}`}>
<div className="bg-neutral-50 border-b border-neutral-200 px-4 py-2 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 results…"
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', 'year', 'rating'] 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' : f === 'year' ? 'Year' : 'Rating'}
{sortField === f ? (sortDir === 'asc' ? ' ↑' : ' ↓') : ''}
</button>
))}
</div>
</div>
{/* Body */}
<div
@ -397,14 +463,25 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
className="flex-1 overflow-y-auto"
onScroll={e => { _store.scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; }}
>
{isSearching && !hasSearched && (
{corsBlocked && (
<div className="flex flex-col items-center justify-center py-16 px-8 gap-3 text-center">
<span className="text-3xl">🚫</span>
<p className="text-sm font-medium text-neutral-700">CommoServe is not accessible</p>
<p className="text-xs text-neutral-400 leading-relaxed">
The CommoServe server does not allow requests from this browser origin (CORS policy).
This service may only be reachable directly from your Meatloaf device.
</p>
</div>
)}
{!corsBlocked && 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 && (
{!corsBlocked && 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">
@ -413,26 +490,26 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
</div>
)}
{!searchError && hasSearched && (
{!corsBlocked && !searchError && hasSearched && (
<>
{results.length > 0 ? (
{visibleResults.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 ? '+' : ''}
{visibleResults.length}{results.length !== visibleResults.length ? ` of ${results.length}` : ''} result{visibleResults.length !== 1 ? 's' : ''}{hasMore ? '+' : ''}
</p>
{results.map(item => {
const catLabel = categoryName[item.category] ?? `Cat ${item.category}`;
return (
<button
{visibleResults.map(item => (
<div
key={item.id}
className="flex items-stretch border-b border-neutral-100 border-l-2 border-l-transparent hover:bg-blue-50 hover:border-l-blue-400 transition-colors"
>
<button
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"
className="flex-1 pl-4 pr-2 py-3 flex items-center gap-3 text-left min-w-0"
>
<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">
@ -462,8 +539,15 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
<ChevronRight className="w-4 h-4 text-neutral-300" />
</div>
</button>
);
})}
<button
onClick={() => setActionItem(item)}
className="px-3 text-neutral-300 hover:text-neutral-600 flex-shrink-0 transition-colors"
aria-label="Actions"
>
<MoreVertical className="w-4 h-4" />
</button>
</div>
))}
{hasMore && (
<div className="p-4 flex justify-center">
@ -481,13 +565,20 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
) : (
<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>
<p className="text-sm text-neutral-500">
{results.length > 0 ? 'No results match the current filter' : 'No results'}
</p>
{results.length > 0 && filterText && (
<button onClick={() => setFilterText('')} className="mt-2 text-xs text-blue-600 hover:underline">
Clear filter
</button>
)}
</div>
)}
</>
)}
{!hasSearched && !isSearching && (
{!corsBlocked && !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>
@ -501,6 +592,29 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
</div>
</div>
{/* Actions dialog */}
<Dialog open={actionItem !== null} onOpenChange={open => !open && setActionItem(null)}>
<DialogContent className="max-w-sm">
<DialogHeader className="overflow-hidden min-w-0">
<DialogTitle className="sr-only">{actionItem?.name}</DialogTitle>
<p className="text-lg font-semibold leading-none pr-6 overflow-hidden min-w-0">
<MarqueeText>{actionItem?.name}</MarqueeText>
</p>
<DialogDescription>
{[actionItem?.group, actionItem?.year, actionItem?.event].filter(Boolean).join(' · ')}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<button
onClick={() => { openItem(actionItem!); setActionItem(null); }}
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3"
>
<FolderOpen className="w-4 h-4 text-blue-600" /> <span>Browse Files</span>
</button>
</div>
</DialogContent>
</Dialog>
{/* Item entries dialog */}
<Dialog open={selectedItem !== null} onOpenChange={open => !open && setSelectedItem(null)}>
<DialogContent className="max-w-sm flex flex-col max-h-[90dvh]">
@ -533,6 +647,7 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
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'}`}
>
<EntryIcon entry={{ name: fname, type: 'file', path: entry.path, size: entry.size, lastModified: new Date(entry.date * 1000), contentType: '' }} />
<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>

View File

@ -339,8 +339,11 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
return (
<>
<div className="flex flex-col h-full overflow-hidden">
{/* Panel header */}
<div className="flex-shrink-0 flex items-center border-b border-neutral-200/70 px-4">
{/* Panel header — X on the left so it's never behind a top-right camera */}
<div className="flex-shrink-0 flex items-center gap-2 border-b border-neutral-200/70 px-4">
<button onClick={onClose} className="p-1.5 my-1.5 rounded-lg hover:bg-neutral-100 text-neutral-500 transition-colors flex-shrink-0" aria-label="Close search">
<X className="w-5 h-5" />
</button>
<div className="flex items-center gap-2 flex-1 py-3 min-w-0">
<img src={`${import.meta.env.BASE_URL}favicon.ico`} className="w-5 h-5 flex-shrink-0 object-contain" alt="" aria-hidden="true" />
<span className="text-sm font-semibold text-neutral-700">Local</span>
@ -364,9 +367,6 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
)}
</button>
)}
<button onClick={onClose} className="p-1.5 ml-1 rounded-lg hover:bg-neutral-100 text-neutral-500 transition-colors" aria-label="Close search">
<X className="w-5 h-5" />
</button>
</div>
</div>

View File

@ -10,7 +10,6 @@ import {
openLocateDb,
isLocateDbLoaded,
subscribeLoadProgress,
type DbProgress,
} from '../locate-db';
interface SearchPaneProps {
@ -48,6 +47,7 @@ export default function SearchPane({ config, setConfig, initialTab, onClose, onO
return subscribeLoadProgress(s => {
if (s.phase === 'idle') setLoadState({ kind: 'idle', received: 0, total: null });
else if (s.phase === 'ready') setLoadState({ kind: 'ready', received: 0, total: null });
else if (s.phase === 'error') setLoadState({ kind: 'error', received: 0, total: null });
else setLoadState({ kind: s.phase, received: s.received, total: s.total });
});
}, []);
@ -89,7 +89,14 @@ export default function SearchPane({ config, setConfig, initialTab, onClose, onO
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"
className="fixed inset-x-0 top-0 bg-white/80 backdrop-blur-md flex flex-col overflow-hidden"
style={{
// 100dvh (dynamic viewport height) shrinks automatically when the
// software keyboard opens, so content is never hidden behind it.
height: '100dvh',
paddingTop: 'env(safe-area-inset-top)',
paddingBottom: 'env(safe-area-inset-bottom)',
}}
>
{/* Locate-database load bar. Visible only while a transfer is in
flight; collapses to zero height otherwise so it doesn't take

View File

@ -43,7 +43,7 @@ from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
import urllib.request, urllib.parse, urllib.error, urllib.parse
from time import timezone, strftime, localtime, gmtime
import os, sys, re, shutil, struct, threading, uuid, hashlib, mimetypes, base64, socket
import os, sys, re, shutil, struct, threading, uuid, hashlib, mimetypes, base64, socket, json
_ws_lock = threading.Lock()
_ws_clients: set = set() # connected WebSocket sockets
@ -541,6 +541,24 @@ class DAVRequestHandler(BaseHTTPRequestHandler):
return False
def do_OPTIONS(self):
# CORS preflight for /proxy-download (called by browser before POST)
if self.path == '/proxy-download':
self.send_response(204)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
self.send_header('Content-Length', '0')
self.end_headers()
return
# CORS preflight for /leet/ and /commoserve/ proxies
if self.path.startswith('/leet/') or self.path.startswith('/commoserve/'):
self.send_response(204)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
self.send_header('Content-Length', '0')
self.end_headers()
return
if self.WebAuth():
return
self.send_response(200, DAVRequestHandler.server_version)
@ -834,11 +852,55 @@ class DAVRequestHandler(BaseHTTPRequestHandler):
_ws_clients.discard(sock)
_log('WS', f'{client} disconnected (clients: {len(_ws_clients)})')
# ── Leet API proxy ─────────────────────────────────────────────────────────
# Browsers cannot override the User-Agent header (Fetch spec §forbidden
# header names). Assembly64 returns HTTP 463 for non-matching UAs.
# commoserve.files.commodore.net also lacks CORS headers on AQL endpoints.
#
# Routes:
# GET /leet/* → https://hackerswithstyle.se/leet/*
# GET /commoserve/* → https://commoserve.files.commodore.net/leet/*
def _proxy_leet(self, target: str):
try:
req = urllib.request.Request(target, headers={
'User-Agent': 'Assembly Query',
'Client-Id': 'Ultimate',
'Accept': 'application/json',
})
with urllib.request.urlopen(req, timeout=15) as resp:
body = resp.read()
ct = resp.headers.get('Content-Type', 'application/octet-stream')
self.send_response(200)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Content-Type', ct)
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
_log('LEET', f'{self.path}{len(body)} bytes')
except Exception as e:
_log('LEET', f'Proxy error: {self.path}{e}')
body = json.dumps({'error': str(e)}).encode('utf-8')
self.send_response(502)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self, onlyhead=False):
if (self.path == '/ws'
and self.headers.get('Upgrade', '').lower() == 'websocket'):
self._handle_websocket()
return
if self.path.startswith('/leet/'):
self._proxy_leet('https://hackerswithstyle.se' + self.path)
return
if self.path.startswith('/commoserve/'):
# /commoserve/search/categories → /leet/search/categories on commoserve server
leet_path = '/leet' + self.path[len('/commoserve'):]
self._proxy_leet('https://commoserve.files.commodore.net' + leet_path)
return
if self.WebAuth():
return
path, elem = self.path_elem()
@ -936,6 +998,64 @@ class DAVRequestHandler(BaseHTTPRequestHandler):
self.send_header('Content-length', '0')
self.end_headers()
# ── Proxy download ─────────────────────────────────────────────────────────
# The browser cannot fetch third-party binary URLs that return duplicate
# Access-Control-Allow-Origin headers (e.g. Assembly64 /search/bin/).
# POST /proxy-download { "url": "...", "dest": "/sd/downloads/file.d64",
# "headers": { "Client-Id": "Ultimate", ... } }
# The server fetches the URL with Python (no CORS restrictions) and saves
# the binary to the filesystem under the WebDAV root.
def _json_response(self, status: int, data: dict):
body = json.dumps(data).encode('utf-8')
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(body)
def do_POST(self):
if self.path != '/proxy-download':
self.send_error(404, 'Not found')
return
try:
size = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(size)
req_data = json.loads(body)
url = req_data['url']
dest = req_data['dest']
extra_headers = req_data.get('headers', {})
except Exception as e:
self._json_response(400, {'error': f'Bad request: {e}'})
return
# Map virtual dest path (/sd/downloads/foo.d64) to filesystem path
vpath = urllib.parse.unquote(dest).lstrip('/')
if '..' in vpath.split('/'):
self._json_response(400, {'error': 'Invalid path'})
return
fspath = os.path.join(self.server.root.fsname, *vpath.split('/'))
# Fetch the URL server-side (no browser CORS restrictions apply here)
try:
req = urllib.request.Request(url, headers=extra_headers)
with urllib.request.urlopen(req, timeout=30) as resp:
data = resp.read()
except Exception as e:
_log('PROXY', f'Fetch failed: {url}{e}')
self._json_response(502, {'error': f'Fetch failed: {e}'})
return
# Save to filesystem, creating directories as needed
try:
os.makedirs(os.path.dirname(fspath), exist_ok=True)
with open(fspath, 'wb') as f:
f.write(data)
except Exception as e:
_log('PROXY', f'Save failed: {fspath}{e}')
self._json_response(500, {'error': f'Save failed: {e}'})
return
_log('PROXY', f'{url}{fspath} ({len(data)} bytes)')
self._json_response(200, {'ok': True, 'bytes': len(data)})
def split_path(self, path):
"""Splits path string in form '/dir1/dir2/file' into parts"""
p = path.split('/')[1:]