From c289ffd47799624f1afc50b8250080cfa1fd0614 Mon Sep 17 00:00:00 2001 From: Jaime Idolpx Date: Mon, 15 Jun 2026 14:43:06 -0400 Subject: [PATCH] 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. --- src/app/App.tsx | 35 ++- src/app/components/SearchAssembly64.tsx | 269 +++++++++++++++++------- src/app/components/SearchCSDbNG.tsx | 158 +++++++++++--- src/app/components/SearchCommoServe.tsx | 229 +++++++++++++++----- src/app/components/SearchLocal.tsx | 10 +- webdav3.py | 44 ++++ 6 files changed, 565 insertions(+), 180 deletions(-) diff --git a/src/app/App.tsx b/src/app/App.tsx index 72fccd9..0a37c41 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -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(null); - const [isFullscreen, setIsFullscreen] = useState(false); const [fileManagerInitialPath, setFileManagerInitialPath] = useState(undefined); const [fileManagerReturnPage, setFileManagerReturnPage] = useState('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: { setFileManagerInitialPath(path); setFileManagerReturnPage('status'); setCurrentPage('file-manager'); }} />, devices: setDevicesOpenId(null)} />, @@ -254,13 +250,6 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) { Meatloaf
-
+ {hasSearched && ( + + )} - {/* Header */} + + {/* Search input + presets + categories */}
- {/* Search input */}
@@ -363,11 +403,10 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
- {/* Presets */} {presets.length > 0 && (
{presets - .filter(group => PRESET_PREFIX[group.type] !== null) // hide sort/order for now + .filter(group => PRESET_PREFIX[group.type] !== null) .map((group, i) => ( + )} +
+ {(['name', 'year', 'rating'] as SortField[]).map(f => ( + + ))} +
+
+ {/* Body */}
- {results.length > 0 ? ( + {visibleResults.length > 0 ? ( <>

{isSearching && } - {results.length} result{results.length !== 1 ? 's' : ''}{hasMore ? '+' : ''} + {visibleResults.length}{results.length !== visibleResults.length ? ` of ${results.length}` : ''} result{visibleResults.length !== 1 ? 's' : ''}{hasMore ? '+' : ''}

- {results.map(item => { + {visibleResults.map(item => { const catLabel = categoryName[item.category] ?? `Cat ${item.category}`; return ( - + + +
); })} @@ -496,7 +576,14 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA ) : (
-

No results

+

+ {results.length > 0 ? 'No results match the current filter' : 'No results'} +

+ {results.length > 0 && filterText && ( + + )}
)} @@ -516,6 +603,29 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA + {/* Actions dialog */} + !open && setActionItem(null)}> + + + {actionItem?.name} +

+ {actionItem?.name} +

+ + {[actionItem?.group, actionItem?.year, actionItem?.event].filter(Boolean).join(' · ')} + +
+
+ +
+
+
+ {/* Item entries dialog */} !open && setSelectedItem(null)}> @@ -550,7 +660,7 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA 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'}`} > - +
{fname}
{humanFileSize(entry.size)}
@@ -626,7 +736,6 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
{/* AQL help dialog */} - @@ -672,9 +781,9 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
{activePreset?.values.map((v, i) => { - const label = v.name ?? v.aqlKey; + const label = v.name ?? v.aqlKey; const prefix = PRESET_PREFIX[activePreset.type]; - const token = v.aqlKey.includes(':') ? v.aqlKey : `${prefix}:${v.aqlKey}`; + const token = v.aqlKey.includes(':') ? v.aqlKey : `${prefix}:${v.aqlKey}`; return (
- + {hasSearched && ( + + )}
- {/* Header */} + + {/* Search input */}
@@ -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 )}
+ {/* Filter + sort bar */} +
+
+
+ + 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 && ( + + )} +
+ +
+
+ {/* Body */}
0 ? ( <>

- {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' : ''}

{visibleResults.map(row => ( -
- - + + + +
))} ) : (
-

No results

+

+ {results.length > 0 ? 'No results match the current filter' : 'No results'} +

+ {results.length > 0 && (filterText || tagFilter) && ( + + )}
) )} @@ -319,6 +408,27 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
+ {/* Actions dialog */} + !open && setActionRow(null)}> + + + {actionRow?.name} +

+ {actionRow?.name} +

+ {actionRow ? typeLabel(actionRow.tags) : ''} +
+
+ +
+
+
+ {/* Release detail dialog */} !open && setSelectedRow(null)}> diff --git a/src/app/components/SearchCommoServe.tsx b/src/app/components/SearchCommoServe.tsx index e88bec7..a865ab7 100644 --- a/src/app/components/SearchCommoServe.tsx +++ b/src/app/components/SearchCommoServe.tsx @@ -1,5 +1,5 @@ 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'; @@ -16,7 +16,6 @@ function leetFetch(path: string, query?: Record) { if (query) Object.entries(query).forEach(([k, v]) => url.searchParams.set(k, v)); return fetch(url.toString(), { headers: { - 'Host': 'commoserve.files.commodore.net', 'User-Agent': 'Assembly Query', 'Client-Id': 'Commodore', }, @@ -88,6 +87,9 @@ const PRESET_PREFIX: Record = { order: null, }; +type SortField = 'name' | 'year' | 'rating'; +type SortDir = 'asc' | 'desc'; + // ─── Props ──────────────────────────────────────────────────────────────────── interface SearchCommoServeProps { @@ -106,6 +108,10 @@ const _store = { hasSearched: false, categoryFilter: null as number | null, scrollTop: 0, + showFilter: false, + filterText: '', + sortField: 'name' as SortField, + sortDir: 'asc' as SortDir, }; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -170,9 +176,15 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC const [entries, setEntries] = useState(null); const [loadingEntries, setLoadingEntries] = useState(false); + const [actionItem, setActionItem] = useState(null); const [mountEntry, setMountEntry] = useState<{ item: ContentItem; entry: ContentEntry } | null>(null); const [downloading, setDownloading] = useState(null); + const [showFilter, setShowFilter] = useState(() => _store.showFilter); + const [filterText, setFilterText] = useState(() => _store.filterText); + const [sortField, setSortField] = useState(() => _store.sortField); + const [sortDir, setSortDir] = useState(() => _store.sortDir); + useEffect(() => { _store.query = query; _store.results = results; @@ -180,7 +192,11 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC _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) @@ -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 applyPreset = (group: PresetGroup, value: PresetValue) => { @@ -240,6 +256,34 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC 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) => { @@ -311,17 +355,32 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC return ( <>
- {/* Panel header */} -
+ {/* Panel header — X on the left so it's never behind a top-right camera */} +
+
CommoServe
- + {hasSearched && ( + + )}
- {/* Header */} + + {/* Search input + presets + categories */}
@@ -333,6 +392,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} /> @@ -395,6 +456,37 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC )}
+ {/* Filter + sort bar */} +
+
+
+ + 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 && ( + + )} +
+ {(['name', 'year', 'rating'] as SortField[]).map(f => ( + + ))} +
+
+ {/* Body */}
- {results.length > 0 ? ( + {visibleResults.length > 0 ? ( <>

{isSearching && } - {results.length} result{results.length !== 1 ? 's' : ''}{hasMore ? '+' : ''} + {visibleResults.length}{results.length !== visibleResults.length ? ` of ${results.length}` : ''} result{visibleResults.length !== 1 ? 's' : ''}{hasMore ? '+' : ''}

- {results.map(item => { + {visibleResults.map(item => { const catLabel = categoryName[item.category] ?? `Cat ${item.category}`; return ( - + + +
); })} @@ -496,7 +599,14 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC ) : (
-

No results

+

+ {results.length > 0 ? 'No results match the current filter' : 'No results'} +

+ {results.length > 0 && filterText && ( + + )}
)} @@ -516,6 +626,29 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
+ {/* Actions dialog */} + !open && setActionItem(null)}> + + + {actionItem?.name} +

+ {actionItem?.name} +

+ + {[actionItem?.group, actionItem?.year, actionItem?.event].filter(Boolean).join(' · ')} + +
+
+ +
+
+
+ {/* Item entries dialog */} !open && setSelectedItem(null)}> diff --git a/src/app/components/SearchLocal.tsx b/src/app/components/SearchLocal.tsx index 11c7a07..bf77857 100644 --- a/src/app/components/SearchLocal.tsx +++ b/src/app/components/SearchLocal.tsx @@ -339,8 +339,11 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder } return ( <>
- {/* Panel header */} -
+ {/* Panel header — X on the left so it's never behind a top-right camera */} +
+
Local @@ -364,9 +367,6 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder } )} )} -
diff --git a/webdav3.py b/webdav3.py index b600bad..d2df5a3 100644 --- a/webdav3.py +++ b/webdav3.py @@ -550,6 +550,15 @@ class DAVRequestHandler(BaseHTTPRequestHandler): self.send_header('Content-Length', '0') self.end_headers() 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(): return self.send_response(200, DAVRequestHandler.server_version) @@ -843,11 +852,46 @@ 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. + # Requests to /leet/ 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): if (self.path == '/ws' and self.headers.get('Upgrade', '').lower() == 'websocket'): self._handle_websocket() return + if self.path.startswith('/leet/'): + self._proxy_leet() + return if self.WebAuth(): return path, elem = self.path_elem()