Compare commits
No commits in common. "936dc5db12e47781c69e446c942e3b8c8a1a0c2b" and "c3af4406bfc68b332af051fede45e7367a2aecc3" have entirely different histories.
936dc5db12
...
c3af4406bf
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,4 +4,3 @@ files/*
|
|||
node_modules/*
|
||||
package-lock.json
|
||||
__pycache__/*
|
||||
api/*
|
||||
|
|
@ -7,11 +7,6 @@ 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);
|
||||
|
|
|
|||
|
|
@ -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 } 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, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
import { Toaster, toast } from 'sonner';
|
||||
import StatusPage from './components/StatusPage';
|
||||
|
|
@ -57,6 +57,7 @@ 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');
|
||||
|
||||
|
|
@ -82,21 +83,24 @@ 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(() => {
|
||||
if (!navigator.maxTouchPoints) return;
|
||||
const request = () => {
|
||||
if (document.fullscreenElement) return;
|
||||
(document.documentElement.requestFullscreen?.() ??
|
||||
(document.documentElement as any).webkitRequestFullscreen?.())
|
||||
?.catch?.(() => {});
|
||||
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener('fullscreenchange', onChange);
|
||||
document.addEventListener('webkitfullscreenchange', onChange);
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', onChange);
|
||||
document.removeEventListener('webkitfullscreenchange', onChange);
|
||||
};
|
||||
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)} />,
|
||||
|
|
@ -250,6 +254,13 @@ 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"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Search, Loader2, HardDrive, ChevronRight, ChevronDown, Trophy, Calendar, Users, RefreshCw, HelpCircle, X, SlidersHorizontal, MoreVertical, FolderOpen } from 'lucide-react';
|
||||
import { Search, Loader2, HardDrive, Download, ChevronRight, ChevronDown, Trophy, Calendar, Users, RefreshCw, HelpCircle, X } 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 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -15,10 +14,15 @@ 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -71,11 +75,14 @@ interface PresetValue {
|
|||
}
|
||||
|
||||
interface PresetGroup {
|
||||
type: string;
|
||||
description: string;
|
||||
type: string; // 'repo' | 'category' | 'subcat' | 'rating' | 'type' | 'date' | 'latest' | 'sort' | 'order'
|
||||
description: string; // Human-readable group label shown on the chip
|
||||
values: PresetValue[];
|
||||
}
|
||||
|
||||
// Each preset group maps to a search-token prefix. 'sort' and 'order' are not
|
||||
// filters (they're result-ordering directives) so they get an empty prefix
|
||||
// marker and the renderer skips them.
|
||||
const PRESET_PREFIX: Record<string, string | null> = {
|
||||
repo: 'repo',
|
||||
category: 'category',
|
||||
|
|
@ -88,9 +95,6 @@ const PRESET_PREFIX: Record<string, string | null> = {
|
|||
order: null,
|
||||
};
|
||||
|
||||
type SortField = 'name' | 'year' | 'rating';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SearchAssembly64Props {
|
||||
|
|
@ -109,10 +113,6 @@ const _store = {
|
|||
hasSearched: false,
|
||||
categoryFilter: null as number | null,
|
||||
scrollTop: 0,
|
||||
showFilter: false,
|
||||
filterText: '',
|
||||
sortField: 'name' as SortField,
|
||||
sortDir: 'asc' as SortDir,
|
||||
};
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
|
@ -121,6 +121,10 @@ 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);
|
||||
|
|
@ -172,14 +176,8 @@ 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 [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);
|
||||
const [downloading, setDownloading] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
_store.query = query;
|
||||
|
|
@ -188,11 +186,7 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
|
|||
_store.hasMore = hasMore;
|
||||
_store.hasSearched = hasSearched;
|
||||
_store.categoryFilter = categoryFilter;
|
||||
_store.showFilter = showFilter;
|
||||
_store.filterText = filterText;
|
||||
_store.sortField = sortField;
|
||||
_store.sortDir = sortDir;
|
||||
}, [query, results, offset, hasMore, hasSearched, categoryFilter, showFilter, filterText, sortField, sortDir]);
|
||||
}, [query, results, offset, hasMore, hasSearched, categoryFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (_store.scrollTop > 0 && scrollRef.current)
|
||||
|
|
@ -201,7 +195,7 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
|
|||
|
||||
useEffect(() => {
|
||||
leetJson<CategoryMapping[]>('/search/categories').then(setCategories).catch(() => {});
|
||||
leetJson<PresetGroup[]>('/search/aql/presets').then(setPresets).catch(() => {});
|
||||
leetJson<Preset[]>('/search/aql/presets').then(setPresets).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const categoryName = useMemo(() => {
|
||||
|
|
@ -236,48 +230,25 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
|
|||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => { setFilterText(''); doSearch(query, categoryFilter, 0); };
|
||||
const handleLoadMore = () => doSearch(query, categoryFilter, offset, true);
|
||||
const handleSearch = () => doSearch(query, categoryFilter, 0);
|
||||
|
||||
// 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;
|
||||
if (prefix === null || prefix === undefined) return; // sort/order: not a filter
|
||||
// Some aqlKeys are self-describing ("subcat:c64comdemos"); use them
|
||||
// verbatim. Otherwise prepend the group's prefix.
|
||||
const token = value.aqlKey.includes(':') ? value.aqlKey : `${prefix}:${value.aqlKey}`;
|
||||
const trimmed = query.trim();
|
||||
const next = trimmed ? `${trimmed} ${token}` : token;
|
||||
setQuery(next);
|
||||
doSearch(next, categoryFilter, 0);
|
||||
doSearch(next);
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
const openItem = async (item: ContentItem) => {
|
||||
|
|
@ -285,6 +256,8 @@ 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}`,
|
||||
);
|
||||
|
|
@ -298,35 +271,41 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
|
|||
}
|
||||
};
|
||||
|
||||
// ── Mount ────────────────────────────────────────────────────────────────────
|
||||
// ── Download to SD ───────────────────────────────────────────────────────────
|
||||
|
||||
const mountOnDevice = async (deviceType: 'drive' | 'meatloaf', key: string) => {
|
||||
if (!mountEntry) return;
|
||||
const { item, entry } = mountEntry;
|
||||
setIsMounting(true);
|
||||
const downloadToSd = async (item: ContentItem, entry: ContentEntry) => {
|
||||
setDownloading(entry.id);
|
||||
try {
|
||||
const res = await leetFetch(`/search/bin/${item.id}/${item.category}/${entry.id}`);
|
||||
const url = downloadUrl(item, entry);
|
||||
const res = await fetch(url);
|
||||
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 = dest;
|
||||
dev.url = downloadUrl(item, entry);
|
||||
delete dev.media_set;
|
||||
if (!dev.enabled) dev.enabled = 1;
|
||||
setConfig(newConfig);
|
||||
setMountEntry(null);
|
||||
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);
|
||||
}
|
||||
toast.success(`Mounted "${entryFilename(entry)}" on ${deviceType} #${key}`);
|
||||
};
|
||||
|
||||
const mountedUrls = useMemo(() => {
|
||||
|
|
@ -343,33 +322,19 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
|
|||
return (
|
||||
<>
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* 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>
|
||||
{/* Panel header */}
|
||||
<div className="flex-shrink-0 flex items-center border-b border-neutral-200/70 px-4">
|
||||
<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>
|
||||
{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 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" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search input + presets + categories */}
|
||||
{/* Header */}
|
||||
<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" />
|
||||
|
|
@ -380,8 +345,6 @@ 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}
|
||||
/>
|
||||
|
|
@ -403,10 +366,11 @@ 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)
|
||||
.filter(group => PRESET_PREFIX[group.type] !== null) // hide sort/order for now
|
||||
.map((group, i) => (
|
||||
<button
|
||||
key={`${group.type}-${i}`}
|
||||
|
|
@ -420,6 +384,7 @@ 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
|
||||
|
|
@ -444,37 +409,6 @@ 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}
|
||||
|
|
@ -499,22 +433,19 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
|
|||
|
||||
{!searchError && hasSearched && (
|
||||
<>
|
||||
{visibleResults.length > 0 ? (
|
||||
{results.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" />}
|
||||
{visibleResults.length}{results.length !== visibleResults.length ? ` of ${results.length}` : ''} result{visibleResults.length !== 1 ? 's' : ''}{hasMore ? '+' : ''}
|
||||
{results.length} result{results.length !== 1 ? 's' : ''}{hasMore ? '+' : ''}
|
||||
</p>
|
||||
{visibleResults.map(item => {
|
||||
{results.map(item => {
|
||||
const catLabel = categoryName[item.category] ?? `Cat ${item.category}`;
|
||||
return (
|
||||
<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
|
||||
key={item.id}
|
||||
onClick={() => openItem(item)}
|
||||
className="flex-1 pl-4 pr-2 py-3 flex items-center gap-3 text-left min-w-0"
|
||||
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"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
|
|
@ -549,14 +480,6 @@ 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>
|
||||
);
|
||||
})}
|
||||
|
||||
|
|
@ -576,14 +499,7 @@ 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">
|
||||
{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>
|
||||
)}
|
||||
<p className="text-sm text-neutral-500">No results</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -603,29 +519,6 @@ 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]">
|
||||
|
|
@ -652,21 +545,34 @@ 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 localPath = joinPath(DOWNLOAD_DIR, fname);
|
||||
const isMounted = mountedUrls.has(localPath);
|
||||
const isMounted = mountedUrls.has(downloadUrl(selectedItem!, entry));
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={entry.id}
|
||||
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 ${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>
|
||||
</div>
|
||||
{isMounted && <span className="text-xs text-blue-600 flex-shrink-0">Mounted</span>}
|
||||
<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" />}
|
||||
</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>
|
||||
|
|
@ -685,13 +591,7 @@ 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')
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Search, Loader2, HardDrive, Download, ChevronRight, X, Archive, SlidersHorizontal, MoreVertical, FolderOpen } from 'lucide-react';
|
||||
import { Search, Loader2, HardDrive, Download, ChevronRight, X, Archive } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { basename, joinPath, putFileContents } from '../webdav';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
||||
|
|
@ -41,8 +41,6 @@ interface CsdbRelease {
|
|||
DownloadLinks: CsdbDownloadLink[];
|
||||
}
|
||||
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SearchCSDbNGProps {
|
||||
|
|
@ -59,9 +57,6 @@ const _store = {
|
|||
hasSearched: false,
|
||||
tagFilter: null as string | null,
|
||||
scrollTop: 0,
|
||||
showFilter: false,
|
||||
filterText: '',
|
||||
sortDir: 'asc' as SortDir,
|
||||
};
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
|
@ -87,23 +82,15 @@ 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;
|
||||
_store.showFilter = showFilter;
|
||||
_store.filterText = filterText;
|
||||
_store.sortDir = sortDir;
|
||||
}, [query, results, hasSearched, tagFilter, showFilter, filterText, sortDir]);
|
||||
}, [query, results, hasSearched, tagFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (_store.scrollTop > 0 && scrollRef.current)
|
||||
|
|
@ -117,7 +104,6 @@ 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}`);
|
||||
|
|
@ -205,52 +191,27 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
|
|||
return [...set].sort();
|
||||
}, [results]);
|
||||
|
||||
const visibleResults = useMemo(() => {
|
||||
const needle = filterText.trim().toLowerCase();
|
||||
let list = results.filter(r =>
|
||||
(!tagFilter || typeLabel(r.tags) === tagFilter) &&
|
||||
(!needle || r.name.toLowerCase().includes(needle))
|
||||
const visibleResults = useMemo(() =>
|
||||
tagFilter ? results.filter(r => typeLabel(r.tags) === tagFilter) : results,
|
||||
[results, tagFilter]
|
||||
);
|
||||
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 — 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>
|
||||
{/* Panel header */}
|
||||
<div className="flex-shrink-0 flex items-center border-b border-neutral-200/70 px-4">
|
||||
<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>
|
||||
{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 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" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
{/* Header */}
|
||||
<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">
|
||||
|
|
@ -261,8 +222,6 @@ 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}
|
||||
/>
|
||||
|
|
@ -298,33 +257,6 @@ 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}
|
||||
|
|
@ -351,16 +283,13 @@ 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}{results.length !== visibleResults.length ? ` of ${results.length}` : ''} result{visibleResults.length !== 1 ? 's' : ''}
|
||||
{visibleResults.length}{tagFilter ? ` of ${results.length}` : ''} result{visibleResults.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
{visibleResults.map(row => (
|
||||
<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
|
||||
key={row.id}
|
||||
onClick={() => openRelease(row)}
|
||||
className="flex-1 pl-4 pr-2 py-3 flex items-center gap-3 text-left min-w-0"
|
||||
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"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
|
|
@ -370,30 +299,12 @@ 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">
|
||||
{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>
|
||||
)}
|
||||
<p className="text-sm text-neutral-500">No results</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
|
@ -408,27 +319,6 @@ 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]">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Search, Loader2, HardDrive, Download, ChevronRight, ChevronDown, Trophy, Calendar, Users, RefreshCw, HelpCircle, X, SlidersHorizontal, MoreVertical, FolderOpen } from 'lucide-react';
|
||||
import { Search, Loader2, HardDrive, Download, ChevronRight, ChevronDown, Trophy, Calendar, Users, RefreshCw, HelpCircle, X } 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 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -17,7 +16,8 @@ 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': 'Commodore',
|
||||
'Client-Id': 'Ultimate',
|
||||
'User-Agent': 'Assembly Query',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -29,17 +29,6 @@ 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 {
|
||||
|
|
@ -66,6 +55,13 @@ interface ContentEntry {
|
|||
date: number;
|
||||
}
|
||||
|
||||
interface CategoryMapping {
|
||||
id: number;
|
||||
name?: string;
|
||||
title?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
interface PresetValue {
|
||||
aqlKey: string;
|
||||
name?: string;
|
||||
|
|
@ -91,9 +87,6 @@ const PRESET_PREFIX: Record<string, string | null> = {
|
|||
order: null,
|
||||
};
|
||||
|
||||
type SortField = 'name' | 'year' | 'rating';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SearchCommoServeProps {
|
||||
|
|
@ -112,10 +105,6 @@ const _store = {
|
|||
hasSearched: false,
|
||||
categoryFilter: null as number | null,
|
||||
scrollTop: 0,
|
||||
showFilter: false,
|
||||
filterText: '',
|
||||
sortField: 'name' as SortField,
|
||||
sortDir: 'asc' as SortDir,
|
||||
};
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
|
@ -169,7 +158,9 @@ 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 [corsBlocked, setCorsBlocked] = useState(false);
|
||||
const [categoryFilter, setCategoryFilter] = useState<number | null>(() => _store.categoryFilter);
|
||||
|
||||
const [categories, setCategories] = useState<CategoryMapping[]>([]);
|
||||
const [presets, setPresets] = useState<PresetGroup[]>([]);
|
||||
const [activePreset, setActivePreset] = useState<PresetGroup | null>(null);
|
||||
|
||||
|
|
@ -177,26 +168,17 @@ 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.showFilter = showFilter;
|
||||
_store.filterText = filterText;
|
||||
_store.sortField = sortField;
|
||||
_store.sortDir = sortDir;
|
||||
}, [query, results, offset, hasMore, hasSearched, showFilter, filterText, sortField, sortDir]);
|
||||
_store.categoryFilter = categoryFilter;
|
||||
}, [query, results, offset, hasMore, hasSearched, categoryFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (_store.scrollTop > 0 && scrollRef.current)
|
||||
|
|
@ -204,38 +186,44 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
leetJson<unknown>('/search/aql/presets').then(d => setPresets(extractArray<PresetGroup>(d))).catch(() => {});
|
||||
leetJson<CategoryMapping[]>('/search/categories').then(setCategories).catch(() => {});
|
||||
leetJson<PresetGroup[]>('/search/aql/presets').then(setPresets).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, fromOffset: number, append = false) => {
|
||||
const doSearch = async (q: string, cat: number | null, fromOffset: number, append = false) => {
|
||||
if (!append) setIsSearching(true);
|
||||
else setIsLoadingMore(true);
|
||||
setSearchError(null);
|
||||
try {
|
||||
const aql = q.trim();
|
||||
const raw = await leetJson<unknown>(
|
||||
let aql = q.trim();
|
||||
if (cat !== null) aql = aql ? `${aql} category:${cat}` : `category:${cat}`;
|
||||
const data = await leetJson<ContentItem[]>(
|
||||
`/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) {
|
||||
if (/failed to fetch|networkerror/i.test(e?.message ?? '')) setCorsBlocked(true);
|
||||
else setSearchError(e?.message ?? 'Search failed');
|
||||
setSearchError(e?.message ?? 'Search failed');
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => { setFilterText(''); doSearch(query, 0); };
|
||||
const handleLoadMore = () => doSearch(query, offset, true);
|
||||
const handleSearch = () => doSearch(query, categoryFilter, 0);
|
||||
const handleLoadMore = () => doSearch(query, categoryFilter, offset, true);
|
||||
|
||||
const applyPreset = (group: PresetGroup, value: PresetValue) => {
|
||||
const prefix = PRESET_PREFIX[group.type];
|
||||
|
|
@ -245,37 +233,9 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
|
|||
const trimmed = query.trim();
|
||||
const next = trimmed ? `${trimmed} ${token}` : token;
|
||||
setQuery(next);
|
||||
doSearch(next, 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) => {
|
||||
|
|
@ -301,7 +261,8 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
|
|||
const downloadToSd = async (item: ContentItem, entry: ContentEntry) => {
|
||||
setDownloading(entry.id);
|
||||
try {
|
||||
const res = await fetch(downloadUrl(item, entry));
|
||||
const url = downloadUrl(item, entry);
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.arrayBuffer();
|
||||
const dest = joinPath(DOWNLOAD_DIR, entryFilename(entry));
|
||||
|
|
@ -341,38 +302,22 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
|
|||
return s;
|
||||
}, [config]);
|
||||
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* 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>
|
||||
{/* Panel header */}
|
||||
<div className="flex-shrink-0 flex items-center border-b border-neutral-200/70 px-4">
|
||||
<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>
|
||||
{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 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" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search input + presets + categories */}
|
||||
{/* Header */}
|
||||
<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">
|
||||
|
|
@ -384,8 +329,6 @@ 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}
|
||||
/>
|
||||
|
|
@ -424,37 +367,28 @@ 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 => (
|
||||
{categories.length > 0 && (
|
||||
<div className="flex gap-1.5 overflow-x-auto pb-0.5" style={{ scrollbarWidth: 'none' }}>
|
||||
<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'}`}
|
||||
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'}`}
|
||||
>
|
||||
{f === 'name' ? 'Name' : f === 'year' ? 'Year' : 'Rating'}
|
||||
{sortField === f ? (sortDir === 'asc' ? ' ↑' : ' ↓') : ''}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
|
|
@ -463,25 +397,14 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
|
|||
className="flex-1 overflow-y-auto"
|
||||
onScroll={e => { _store.scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; }}
|
||||
>
|
||||
{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 && (
|
||||
{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>
|
||||
)}
|
||||
|
||||
{!corsBlocked && searchError && (
|
||||
{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">
|
||||
|
|
@ -490,26 +413,26 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!corsBlocked && !searchError && hasSearched && (
|
||||
{!searchError && hasSearched && (
|
||||
<>
|
||||
{visibleResults.length > 0 ? (
|
||||
{results.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" />}
|
||||
{visibleResults.length}{results.length !== visibleResults.length ? ` of ${results.length}` : ''} result{visibleResults.length !== 1 ? 's' : ''}{hasMore ? '+' : ''}
|
||||
{results.length} result{results.length !== 1 ? 's' : ''}{hasMore ? '+' : ''}
|
||||
</p>
|
||||
{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"
|
||||
>
|
||||
{results.map(item => {
|
||||
const catLabel = categoryName[item.category] ?? `Cat ${item.category}`;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => openItem(item)}
|
||||
className="flex-1 pl-4 pr-2 py-3 flex items-center gap-3 text-left min-w-0"
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
|
|
@ -539,15 +462,8 @@ 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">
|
||||
|
|
@ -565,20 +481,13 @@ 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">
|
||||
{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>
|
||||
)}
|
||||
<p className="text-sm text-neutral-500">No results</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!corsBlocked && !hasSearched && !isSearching && (
|
||||
{!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>
|
||||
|
|
@ -592,29 +501,6 @@ 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]">
|
||||
|
|
@ -647,7 +533,6 @@ 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>
|
||||
|
|
|
|||
|
|
@ -339,11 +339,8 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
|||
return (
|
||||
<>
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* 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>
|
||||
{/* Panel header */}
|
||||
<div className="flex-shrink-0 flex items-center border-b border-neutral-200/70 px-4">
|
||||
<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>
|
||||
|
|
@ -367,6 +364,9 @@ 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
openLocateDb,
|
||||
isLocateDbLoaded,
|
||||
subscribeLoadProgress,
|
||||
type DbProgress,
|
||||
} from '../locate-db';
|
||||
|
||||
interface SearchPaneProps {
|
||||
|
|
@ -47,7 +48,6 @@ 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,14 +89,7 @@ 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-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)',
|
||||
}}
|
||||
className="fixed inset-0 bg-white/80 backdrop-blur-md flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Locate-database load bar. Visible only while a transfer is in
|
||||
flight; collapses to zero height otherwise so it doesn't take
|
||||
|
|
|
|||
122
webdav3.py
122
webdav3.py
|
|
@ -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, json
|
||||
import os, sys, re, shutil, struct, threading, uuid, hashlib, mimetypes, base64, socket
|
||||
|
||||
_ws_lock = threading.Lock()
|
||||
_ws_clients: set = set() # connected WebSocket sockets
|
||||
|
|
@ -541,24 +541,6 @@ 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)
|
||||
|
|
@ -852,55 +834,11 @@ 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()
|
||||
|
|
@ -998,64 +936,6 @@ 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:]
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user