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.
This commit is contained in:
Jaime Idolpx 2026-06-15 14:43:06 -04:00
parent e00081a9f6
commit c289ffd477
6 changed files with 565 additions and 180 deletions

View File

@ -1,5 +1,5 @@
import { lazy, Suspense, useEffect, useState } from 'react'; 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 { AnimatePresence } from 'motion/react';
import { Toaster, toast } from 'sonner'; import { Toaster, toast } from 'sonner';
import StatusPage from './components/StatusPage'; import StatusPage from './components/StatusPage';
@ -57,7 +57,6 @@ export default function App() {
const { config, setConfig, saveStatus, pendingCount, flushNow, reload } = useSettings(); const { config, setConfig, saveStatus, pendingCount, flushNow, reload } = useSettings();
const [searchPanel, setSearchPanel] = useState<'local' | 'assembly64' | 'commoserve' | 'csdb' | 'last' | null>(null); const [searchPanel, setSearchPanel] = useState<'local' | 'assembly64' | 'commoserve' | 'csdb' | 'last' | null>(null);
const [devicesOpenId, setDevicesOpenId] = useState<string | null>(null); const [devicesOpenId, setDevicesOpenId] = useState<string | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [fileManagerInitialPath, setFileManagerInitialPath] = useState<string | undefined>(undefined); const [fileManagerInitialPath, setFileManagerInitialPath] = useState<string | undefined>(undefined);
const [fileManagerReturnPage, setFileManagerReturnPage] = useState<Page>('apps'); const [fileManagerReturnPage, setFileManagerReturnPage] = useState<Page>('apps');
@ -83,24 +82,21 @@ export default function App() {
} }
}, [saveStatus, pendingCount, flushNow, reload]); }, [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(() => { useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement); if (!navigator.maxTouchPoints) return;
document.addEventListener('fullscreenchange', onChange); const request = () => {
document.addEventListener('webkitfullscreenchange', onChange); if (document.fullscreenElement) return;
return () => { (document.documentElement.requestFullscreen?.() ??
document.removeEventListener('fullscreenchange', onChange); (document.documentElement as any).webkitRequestFullscreen?.())
document.removeEventListener('webkitfullscreenchange', onChange); ?.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 = { const pages = {
status: <StatusPage config={config} setConfig={setConfig} onOpenFileManager={(path) => { setFileManagerInitialPath(path); setFileManagerReturnPage('status'); setCurrentPage('file-manager'); }} />, 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)} />, 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" /> <img src={logoSvg} alt="Meatloaf" className="h-full max-h-[56px] w-auto object-contain" />
</button> </button>
<div className="flex items-center gap-3"> <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 <button
onClick={() => setSearchPanel('last')} onClick={() => setSearchPanel('last')}
className="p-2 hover:bg-[#5e5e5e] rounded-lg" className="p-2 hover:bg-[#5e5e5e] rounded-lg"

View File

@ -1,9 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Search, Loader2, HardDrive, 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 { 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';
import { MarqueeText } from './ui/marquee-text'; import { MarqueeText } from './ui/marquee-text';
import { EntryIcon } from './MediaEntry';
// ─── API ────────────────────────────────────────────────────────────────────── // ─── API ──────────────────────────────────────────────────────────────────────
@ -14,16 +15,10 @@ 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"
// 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(), { return fetch(url.toString(), {
headers: { headers: {
'Host': 'hackerswithstyle.se',
'User-Agent': 'Assembly Query', 'User-Agent': 'Assembly Query',
'Client-Id': 'Ultimate', 'Client-Id': 'meatloaf',
}, },
}); });
} }
@ -76,14 +71,11 @@ interface PresetValue {
} }
interface PresetGroup { interface PresetGroup {
type: string; // 'repo' | 'category' | 'subcat' | 'rating' | 'type' | 'date' | 'latest' | 'sort' | 'order' type: string;
description: string; // Human-readable group label shown on the chip description: string;
values: PresetValue[]; 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> = { const PRESET_PREFIX: Record<string, string | null> = {
repo: 'repo', repo: 'repo',
category: 'category', category: 'category',
@ -96,6 +88,9 @@ const PRESET_PREFIX: Record<string, string | null> = {
order: null, order: null,
}; };
type SortField = 'name' | 'year' | 'rating';
type SortDir = 'asc' | 'desc';
// ─── Props ──────────────────────────────────────────────────────────────────── // ─── Props ────────────────────────────────────────────────────────────────────
interface SearchAssembly64Props { interface SearchAssembly64Props {
@ -114,6 +109,10 @@ const _store = {
hasSearched: false, hasSearched: false,
categoryFilter: null as number | null, categoryFilter: null as number | null,
scrollTop: 0, scrollTop: 0,
showFilter: false,
filterText: '',
sortField: 'name' as SortField,
sortDir: 'asc' as SortDir,
}; };
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@ -122,7 +121,6 @@ function entryFilename(e: ContentEntry): string {
return basename(e.path) || e.path; return basename(e.path) || e.path;
} }
function RatingStars({ value, max = 10 }: { value?: number; max?: number }) { function RatingStars({ value, max = 10 }: { value?: number; max?: number }) {
if (!value) return null; if (!value) return null;
const pct = Math.round((value / max) * 5); const pct = Math.round((value / max) * 5);
@ -174,9 +172,15 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
const [entries, setEntries] = useState<ContentEntry[] | null>(null); const [entries, setEntries] = useState<ContentEntry[] | null>(null);
const [loadingEntries, setLoadingEntries] = useState(false); const [loadingEntries, setLoadingEntries] = useState(false);
const [actionItem, setActionItem] = useState<ContentItem | null>(null);
const [mountEntry, setMountEntry] = useState<{ item: ContentItem; entry: ContentEntry } | null>(null); const [mountEntry, setMountEntry] = useState<{ item: ContentItem; entry: ContentEntry } | null>(null);
const [isMounting, setIsMounting] = useState(false); 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(() => { useEffect(() => {
_store.query = query; _store.query = query;
_store.results = results; _store.results = results;
@ -184,7 +188,11 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
_store.hasMore = hasMore; _store.hasMore = hasMore;
_store.hasSearched = hasSearched; _store.hasSearched = hasSearched;
_store.categoryFilter = categoryFilter; _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(() => { useEffect(() => {
if (_store.scrollTop > 0 && scrollRef.current) if (_store.scrollTop > 0 && scrollRef.current)
@ -193,7 +201,7 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
useEffect(() => { useEffect(() => {
leetJson<CategoryMapping[]>('/search/categories').then(setCategories).catch(() => {}); 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(() => { const categoryName = useMemo(() => {
@ -228,26 +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); 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 applyPreset = (group: PresetGroup, value: PresetValue) => {
const prefix = PRESET_PREFIX[group.type]; const prefix = PRESET_PREFIX[group.type];
setActivePreset(null); setActivePreset(null);
if (prefix === null || prefix === undefined) return; // sort/order: not a filter if (prefix === null || prefix === undefined) return;
// 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 token = value.aqlKey.includes(':') ? value.aqlKey : `${prefix}:${value.aqlKey}`;
const trimmed = query.trim(); const trimmed = query.trim();
const next = trimmed ? `${trimmed} ${token}` : token; const next = trimmed ? `${trimmed} ${token}` : token;
setQuery(next); setQuery(next);
doSearch(next); doSearch(next, categoryFilter, 0);
doSearch(q, 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 ───────────────────────────────────────────────────────────── // ── Item entries ─────────────────────────────────────────────────────────────
const openItem = async (item: ContentItem) => { const openItem = async (item: ContentItem) => {
@ -255,8 +285,6 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
setEntries(null); setEntries(null);
setLoadingEntries(true); setLoadingEntries(true);
try { 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[] }>( const data = await leetJson<ContentEntry[] | { contentEntry: ContentEntry[] }>(
`/search/entries/${item.id}/${item.category}`, `/search/entries/${item.id}/${item.category}`,
); );
@ -270,9 +298,7 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
} }
}; };
// ── Download + Mount ──────────────────────────────────────────────────────── // ── Mount ────────────────────────────────────────────────────────────────────
// Selecting a device downloads the file to /sd/downloads/ then sets the
// device URL to the local path. The remote leet URL is never stored.
const mountOnDevice = async (deviceType: 'drive' | 'meatloaf', key: string) => { const mountOnDevice = async (deviceType: 'drive' | 'meatloaf', key: string) => {
if (!mountEntry) return; if (!mountEntry) return;
@ -326,10 +352,24 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
<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" /> <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> <span className="text-sm font-semibold text-neutral-700">Assembly64</span>
</div> </div>
{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> </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-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="flex gap-2 mb-2">
<div className="relative flex-1"> <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" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400 pointer-events-none" />
@ -363,11 +403,10 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
</button> </button>
</div> </div>
{/* Presets */}
{presets.length > 0 && ( {presets.length > 0 && (
<div className="flex gap-1.5 mb-2 overflow-x-auto pb-0.5" style={{ scrollbarWidth: 'none' }}> <div className="flex gap-1.5 mb-2 overflow-x-auto pb-0.5" style={{ scrollbarWidth: 'none' }}>
{presets {presets
.filter(group => PRESET_PREFIX[group.type] !== null) // hide sort/order for now .filter(group => PRESET_PREFIX[group.type] !== null)
.map((group, i) => ( .map((group, i) => (
<button <button
key={`${group.type}-${i}`} key={`${group.type}-${i}`}
@ -381,7 +420,6 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
</div> </div>
)} )}
{/* Category filter */}
{categories.length > 0 && ( {categories.length > 0 && (
<div className="flex gap-1.5 overflow-x-auto pb-0.5" style={{ scrollbarWidth: 'none' }}> <div className="flex gap-1.5 overflow-x-auto pb-0.5" style={{ scrollbarWidth: 'none' }}>
<button <button
@ -406,6 +444,37 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
)} )}
</div> </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 */} {/* Body */}
<div <div
ref={scrollRef} ref={scrollRef}
@ -430,19 +499,22 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
{!searchError && hasSearched && ( {!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"> <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" />} {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> </p>
{results.map(item => { {visibleResults.map(item => {
const catLabel = categoryName[item.category] ?? `Cat ${item.category}`; const catLabel = categoryName[item.category] ?? `Cat ${item.category}`;
return ( return (
<button <div
key={item.id} 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)} 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-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
@ -477,6 +549,14 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
<ChevronRight className="w-4 h-4 text-neutral-300" /> <ChevronRight className="w-4 h-4 text-neutral-300" />
</div> </div>
</button> </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>
); );
})} })}
@ -496,7 +576,14 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
) : ( ) : (
<div className="py-16 text-center"> <div className="py-16 text-center">
<Search className="w-10 h-10 mx-auto mb-3 text-neutral-300" /> <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> </div>
)} )}
</> </>
@ -516,6 +603,29 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
</div> </div>
</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 */} {/* Item entries dialog */}
<Dialog open={selectedItem !== null} onOpenChange={open => !open && setSelectedItem(null)}> <Dialog open={selectedItem !== null} onOpenChange={open => !open && setSelectedItem(null)}>
<DialogContent className="max-w-sm flex flex-col max-h-[90dvh]"> <DialogContent className="max-w-sm flex flex-col max-h-[90dvh]">
@ -550,7 +660,7 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
onClick={() => setMountEntry({ item: selectedItem!, entry })} 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'}`} 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'}`}
> >
<HardDrive className={`w-4 h-4 flex-shrink-0 ${isMounted ? 'text-blue-500' : 'text-neutral-400'}`} /> <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="flex-1 min-w-0">
<div className="text-sm font-medium text-neutral-800 truncate">{fname}</div> <div className="text-sm font-medium text-neutral-800 truncate">{fname}</div>
<div className="text-xs text-neutral-400">{humanFileSize(entry.size)}</div> <div className="text-xs text-neutral-400">{humanFileSize(entry.size)}</div>
@ -626,7 +736,6 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
</Dialog> </Dialog>
{/* AQL help dialog */} {/* AQL help dialog */}
<Dialog open={showAqlHelp} onOpenChange={setShowAqlHelp}> <Dialog open={showAqlHelp} onOpenChange={setShowAqlHelp}>
<DialogContent className="max-w-sm flex flex-col max-h-[90dvh]"> <DialogContent className="max-w-sm flex flex-col max-h-[90dvh]">
<DialogHeader className="flex-shrink-0"> <DialogHeader className="flex-shrink-0">

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, X, Archive } from 'lucide-react'; import { Search, Loader2, HardDrive, Download, ChevronRight, X, Archive, SlidersHorizontal, MoreVertical, FolderOpen } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { basename, joinPath, putFileContents } from '../webdav'; import { basename, joinPath, putFileContents } from '../webdav';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
@ -41,6 +41,8 @@ interface CsdbRelease {
DownloadLinks: CsdbDownloadLink[]; DownloadLinks: CsdbDownloadLink[];
} }
type SortDir = 'asc' | 'desc';
// ─── Props ──────────────────────────────────────────────────────────────────── // ─── Props ────────────────────────────────────────────────────────────────────
interface SearchCSDbNGProps { interface SearchCSDbNGProps {
@ -57,6 +59,9 @@ const _store = {
hasSearched: false, hasSearched: false,
tagFilter: null as string | null, tagFilter: null as string | null,
scrollTop: 0, scrollTop: 0,
showFilter: false,
filterText: '',
sortDir: 'asc' as SortDir,
}; };
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@ -82,15 +87,23 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
const [release, setRelease] = useState<CsdbRelease | null>(null); const [release, setRelease] = useState<CsdbRelease | null>(null);
const [loadingRelease, setLoadingRelease] = useState(false); const [loadingRelease, setLoadingRelease] = useState(false);
const [actionRow, setActionRow] = useState<CsdbRow | null>(null);
const [mountEntry, setMountEntry] = useState<{ row: CsdbRow; link: CsdbDownloadLink } | null>(null); const [mountEntry, setMountEntry] = useState<{ row: CsdbRow; link: CsdbDownloadLink } | null>(null);
const [downloading, setDownloading] = useState<string | 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(() => { useEffect(() => {
_store.query = query; _store.query = query;
_store.results = results; _store.results = results;
_store.hasSearched = hasSearched; _store.hasSearched = hasSearched;
_store.tagFilter = tagFilter; _store.tagFilter = tagFilter;
}, [query, results, hasSearched, tagFilter]); _store.showFilter = showFilter;
_store.filterText = filterText;
_store.sortDir = sortDir;
}, [query, results, hasSearched, tagFilter, showFilter, filterText, sortDir]);
useEffect(() => { useEffect(() => {
if (_store.scrollTop > 0 && scrollRef.current) if (_store.scrollTop > 0 && scrollRef.current)
@ -104,6 +117,7 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
setIsSearching(true); setIsSearching(true);
setSearchError(null); setSearchError(null);
setTagFilter(null); setTagFilter(null);
setFilterText('');
try { try {
const res = await fetch(`${CSDB_API}/search/${encodeURIComponent(q.trim())}`); const res = await fetch(`${CSDB_API}/search/${encodeURIComponent(q.trim())}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
@ -191,27 +205,52 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
return [...set].sort(); return [...set].sort();
}, [results]); }, [results]);
const visibleResults = useMemo(() => const visibleResults = useMemo(() => {
tagFilter ? results.filter(r => typeLabel(r.tags) === tagFilter) : results, const needle = filterText.trim().toLowerCase();
[results, tagFilter] 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 ──────────────────────────────────────────────────────────────────── // ── Render ────────────────────────────────────────────────────────────────────
return ( return (
<> <>
<div className="flex flex-col h-full overflow-hidden"> <div className="flex flex-col h-full overflow-hidden">
{/* Panel header */} {/* Panel header — X on the left so it's never behind a top-right camera */}
<div className="flex-shrink-0 flex items-center border-b border-neutral-200/70 px-4"> <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"> <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" /> <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> <span className="text-sm font-semibold text-neutral-700">CSDb-ng</span>
</div> </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"> {hasSearched && (
<X className="w-5 h-5" /> <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> </button>
)}
</div> </div>
{/* Header */}
{/* Search input */}
<div className="flex-shrink-0 px-4 pt-3 pb-3 border-b border-neutral-200/70"> <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="flex gap-2 mb-2">
<div className="relative flex-1"> <div className="relative flex-1">
@ -222,6 +261,8 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
onChange={e => setQuery(e.target.value)} onChange={e => setQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !isSearching && doSearch(query)} onKeyDown={e => e.key === 'Enter' && !isSearching && doSearch(query)}
placeholder="Search CSDb-ng…" 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" 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} disabled={isSearching}
/> />
@ -257,6 +298,33 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
)} )}
</div> </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 */} {/* Body */}
<div <div
ref={scrollRef} ref={scrollRef}
@ -283,13 +351,16 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
visibleResults.length > 0 ? ( visibleResults.length > 0 ? (
<> <>
<p className="text-xs text-neutral-400 px-4 py-2 border-b border-neutral-100"> <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> </p>
{visibleResults.map(row => ( {visibleResults.map(row => (
<button <div
key={row.id} 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)} 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-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
@ -299,12 +370,30 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
</div> </div>
<ChevronRight className="w-4 h-4 text-neutral-300 flex-shrink-0" /> <ChevronRight className="w-4 h-4 text-neutral-300 flex-shrink-0" />
</button> </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"> <div className="py-16 text-center">
<Search className="w-10 h-10 mx-auto mb-3 text-neutral-300" /> <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> </div>
) )
)} )}
@ -319,6 +408,27 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
</div> </div>
</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 */} {/* Release detail dialog */}
<Dialog open={selectedRow !== null} onOpenChange={open => !open && setSelectedRow(null)}> <Dialog open={selectedRow !== null} onOpenChange={open => !open && setSelectedRow(null)}>
<DialogContent className="max-w-sm flex flex-col max-h-[90dvh]"> <DialogContent className="max-w-sm flex flex-col max-h-[90dvh]">

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, 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 { 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';
@ -16,7 +16,6 @@ function leetFetch(path: string, query?: Record<string, string>) {
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));
return fetch(url.toString(), { return fetch(url.toString(), {
headers: { headers: {
'Host': 'commoserve.files.commodore.net',
'User-Agent': 'Assembly Query', 'User-Agent': 'Assembly Query',
'Client-Id': 'Commodore', 'Client-Id': 'Commodore',
}, },
@ -88,6 +87,9 @@ const PRESET_PREFIX: Record<string, string | null> = {
order: null, order: null,
}; };
type SortField = 'name' | 'year' | 'rating';
type SortDir = 'asc' | 'desc';
// ─── Props ──────────────────────────────────────────────────────────────────── // ─── Props ────────────────────────────────────────────────────────────────────
interface SearchCommoServeProps { interface SearchCommoServeProps {
@ -106,6 +108,10 @@ const _store = {
hasSearched: false, hasSearched: false,
categoryFilter: null as number | null, categoryFilter: null as number | null,
scrollTop: 0, scrollTop: 0,
showFilter: false,
filterText: '',
sortField: 'name' as SortField,
sortDir: 'asc' as SortDir,
}; };
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@ -170,9 +176,15 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
const [entries, setEntries] = useState<ContentEntry[] | null>(null); const [entries, setEntries] = useState<ContentEntry[] | null>(null);
const [loadingEntries, setLoadingEntries] = useState(false); const [loadingEntries, setLoadingEntries] = useState(false);
const [actionItem, setActionItem] = useState<ContentItem | null>(null);
const [mountEntry, setMountEntry] = useState<{ item: ContentItem; entry: ContentEntry } | null>(null); const [mountEntry, setMountEntry] = useState<{ item: ContentItem; entry: ContentEntry } | null>(null);
const [downloading, setDownloading] = useState<number | 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(() => { useEffect(() => {
_store.query = query; _store.query = query;
_store.results = results; _store.results = results;
@ -180,7 +192,11 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
_store.hasMore = hasMore; _store.hasMore = hasMore;
_store.hasSearched = hasSearched; _store.hasSearched = hasSearched;
_store.categoryFilter = categoryFilter; _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(() => { useEffect(() => {
if (_store.scrollTop > 0 && scrollRef.current) if (_store.scrollTop > 0 && scrollRef.current)
@ -226,7 +242,7 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
} }
}; };
const handleSearch = () => doSearch(query, categoryFilter, 0); const handleSearch = () => { setFilterText(''); doSearch(query, categoryFilter, 0); };
const handleLoadMore = () => doSearch(query, categoryFilter, offset, true); const handleLoadMore = () => doSearch(query, categoryFilter, offset, true);
const applyPreset = (group: PresetGroup, value: PresetValue) => { const applyPreset = (group: PresetGroup, value: PresetValue) => {
@ -240,6 +256,34 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
doSearch(next, 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 ───────────────────────────────────────────────────────────── // ── Item entries ─────────────────────────────────────────────────────────────
const openItem = async (item: ContentItem) => { const openItem = async (item: ContentItem) => {
@ -311,17 +355,32 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
return ( return (
<> <>
<div className="flex flex-col h-full overflow-hidden"> <div className="flex flex-col h-full overflow-hidden">
{/* Panel header */} {/* Panel header — X on the left so it's never behind a top-right camera */}
<div className="flex-shrink-0 flex items-center border-b border-neutral-200/70 px-4"> <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"> <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" /> <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> <span className="text-sm font-semibold text-neutral-700">CommoServe</span>
</div> </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"> {hasSearched && (
<X className="w-5 h-5" /> <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> </button>
)}
</div> </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-shrink-0 px-4 pt-3 pb-3 border-b border-neutral-200/70">
<div className="flex gap-2 mb-2"> <div className="flex gap-2 mb-2">
<div className="relative flex-1"> <div className="relative flex-1">
@ -333,6 +392,8 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
onChange={e => setQuery(e.target.value)} onChange={e => setQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !isSearching && handleSearch()} onKeyDown={e => e.key === 'Enter' && !isSearching && handleSearch()}
placeholder="name:manic* group:ultimate year:1983…" 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" 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} disabled={isSearching}
/> />
@ -395,6 +456,37 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
)} )}
</div> </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 */} {/* Body */}
<div <div
ref={scrollRef} ref={scrollRef}
@ -430,19 +522,22 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
{!corsBlocked && !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"> <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" />} {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> </p>
{results.map(item => { {visibleResults.map(item => {
const catLabel = categoryName[item.category] ?? `Cat ${item.category}`; const catLabel = categoryName[item.category] ?? `Cat ${item.category}`;
return ( return (
<button <div
key={item.id} 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)} 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-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
@ -477,6 +572,14 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
<ChevronRight className="w-4 h-4 text-neutral-300" /> <ChevronRight className="w-4 h-4 text-neutral-300" />
</div> </div>
</button> </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>
); );
})} })}
@ -496,7 +599,14 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
) : ( ) : (
<div className="py-16 text-center"> <div className="py-16 text-center">
<Search className="w-10 h-10 mx-auto mb-3 text-neutral-300" /> <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> </div>
)} )}
</> </>
@ -516,6 +626,29 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
</div> </div>
</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 */} {/* Item entries dialog */}
<Dialog open={selectedItem !== null} onOpenChange={open => !open && setSelectedItem(null)}> <Dialog open={selectedItem !== null} onOpenChange={open => !open && setSelectedItem(null)}>
<DialogContent className="max-w-sm flex flex-col max-h-[90dvh]"> <DialogContent className="max-w-sm flex flex-col max-h-[90dvh]">

View File

@ -339,8 +339,11 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
return ( return (
<> <>
<div className="flex flex-col h-full overflow-hidden"> <div className="flex flex-col h-full overflow-hidden">
{/* Panel header */} {/* Panel header — X on the left so it's never behind a top-right camera */}
<div className="flex-shrink-0 flex items-center border-b border-neutral-200/70 px-4"> <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"> <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" /> <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> <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>
)} )}
<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>
</div> </div>

View File

@ -550,6 +550,15 @@ class DAVRequestHandler(BaseHTTPRequestHandler):
self.send_header('Content-Length', '0') self.send_header('Content-Length', '0')
self.end_headers() self.end_headers()
return return
# CORS preflight for /leet/ proxy
if self.path.startswith('/leet/'):
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(): if self.WebAuth():
return return
self.send_response(200, DAVRequestHandler.server_version) self.send_response(200, DAVRequestHandler.server_version)
@ -843,11 +852,46 @@ class DAVRequestHandler(BaseHTTPRequestHandler):
_ws_clients.discard(sock) _ws_clients.discard(sock)
_log('WS', f'{client} disconnected (clients: {len(_ws_clients)})') _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.
# Requests to /leet/<path> are forwarded to hackerswithstyle.se with the
# required headers added server-side.
def _proxy_leet(self):
target = 'https://hackerswithstyle.se' + self.path
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('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('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): def do_GET(self, onlyhead=False):
if (self.path == '/ws' if (self.path == '/ws'
and self.headers.get('Upgrade', '').lower() == 'websocket'): and self.headers.get('Upgrade', '').lower() == 'websocket'):
self._handle_websocket() self._handle_websocket()
return return
if self.path.startswith('/leet/'):
self._proxy_leet()
return
if self.WebAuth(): if self.WebAuth():
return return
path, elem = self.path_elem() path, elem = self.path_elem()