feat(SearchAssembly64): refactor download and mount functionality for improved clarity and state management
This commit is contained in:
parent
8957254471
commit
e6f4ecdc29
|
|
@ -7,6 +7,11 @@ self.addEventListener('activate', event => {
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('fetch', 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(
|
event.respondWith(
|
||||||
caches.match(event.request).then(response => {
|
caches.match(event.request).then(response => {
|
||||||
return response || fetch(event.request);
|
return response || fetch(event.request);
|
||||||
|
|
|
||||||
|
|
@ -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, ChevronRight, ChevronDown, Trophy, Calendar, Users, RefreshCw, HelpCircle, X } 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';
|
||||||
|
|
@ -121,9 +121,6 @@ function entryFilename(e: ContentEntry): string {
|
||||||
return basename(e.path) || e.path;
|
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 }) {
|
function RatingStars({ value, max = 10 }: { value?: number; max?: number }) {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
|
|
@ -177,7 +174,7 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
|
||||||
const [loadingEntries, setLoadingEntries] = useState(false);
|
const [loadingEntries, setLoadingEntries] = useState(false);
|
||||||
|
|
||||||
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 [isMounting, setIsMounting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
_store.query = query;
|
_store.query = query;
|
||||||
|
|
@ -272,41 +269,37 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Download to SD ───────────────────────────────────────────────────────────
|
// ── Download + 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 downloadToSd = async (item: ContentItem, entry: ContentEntry) => {
|
const mountOnDevice = async (deviceType: 'drive' | 'meatloaf', key: string) => {
|
||||||
setDownloading(entry.id);
|
if (!mountEntry) return;
|
||||||
|
const { item, entry } = mountEntry;
|
||||||
|
setIsMounting(true);
|
||||||
try {
|
try {
|
||||||
const url = downloadUrl(item, entry);
|
const res = await leetFetch(`/search/bin/${item.id}/${item.category}/${entry.id}`);
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const data = await res.arrayBuffer();
|
const data = await res.arrayBuffer();
|
||||||
const dest = joinPath(DOWNLOAD_DIR, entryFilename(entry));
|
const dest = joinPath(DOWNLOAD_DIR, entryFilename(entry));
|
||||||
await putFileContents(dest, data);
|
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));
|
const newConfig = JSON.parse(JSON.stringify(config));
|
||||||
if (!newConfig.devices) newConfig.devices = {};
|
if (!newConfig.devices) newConfig.devices = {};
|
||||||
if (!newConfig.devices.iec) newConfig.devices.iec = {};
|
if (!newConfig.devices.iec) newConfig.devices.iec = {};
|
||||||
if (!newConfig.devices.iec[key]) newConfig.devices.iec[key] = { type: deviceType };
|
if (!newConfig.devices.iec[key]) newConfig.devices.iec[key] = { type: deviceType };
|
||||||
const dev = newConfig.devices.iec[key];
|
const dev = newConfig.devices.iec[key];
|
||||||
dev.url = downloadUrl(item, entry);
|
dev.url = dest;
|
||||||
delete dev.media_set;
|
delete dev.media_set;
|
||||||
if (!dev.enabled) dev.enabled = 1;
|
if (!dev.enabled) dev.enabled = 1;
|
||||||
setConfig(newConfig);
|
setConfig(newConfig);
|
||||||
setMountEntry(null);
|
setMountEntry(null);
|
||||||
toast.success(`Mounted "${entryFilename(entry)}" on ${deviceType} #${key}`);
|
setSelectedItem(null);
|
||||||
|
toast.success(`Downloaded and mounted "${entryFilename(entry)}" on device #${key}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(`Mount failed: ${e?.message ?? e}`);
|
||||||
|
} finally {
|
||||||
|
setIsMounting(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const mountedUrls = useMemo(() => {
|
const mountedUrls = useMemo(() => {
|
||||||
|
|
@ -546,34 +539,21 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
|
||||||
<div className="flex flex-col gap-2 py-1">
|
<div className="flex flex-col gap-2 py-1">
|
||||||
{entries.map(entry => {
|
{entries.map(entry => {
|
||||||
const fname = entryFilename(entry);
|
const fname = entryFilename(entry);
|
||||||
const isMounted = mountedUrls.has(downloadUrl(selectedItem!, entry));
|
const localPath = joinPath(DOWNLOAD_DIR, fname);
|
||||||
|
const isMounted = mountedUrls.has(localPath);
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
className={`px-4 py-3 rounded-lg border flex items-center gap-3 ${isMounted ? 'border-blue-300 bg-blue-50' : 'border-neutral-200'}`}
|
onClick={() => setMountEntry({ item: selectedItem!, entry })}
|
||||||
|
className={`px-4 py-3 rounded-lg border flex items-center gap-3 text-left w-full transition-colors ${isMounted ? 'border-blue-300 bg-blue-50' : 'border-neutral-200 hover:bg-blue-50 hover:border-blue-300'}`}
|
||||||
>
|
>
|
||||||
|
<HardDrive className={`w-4 h-4 flex-shrink-0 ${isMounted ? 'text-blue-500' : 'text-neutral-400'}`} />
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
<button
|
{isMounted && <span className="text-xs text-blue-600 flex-shrink-0">Mounted</span>}
|
||||||
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>
|
||||||
<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>
|
</div>
|
||||||
|
|
@ -592,7 +572,13 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="overflow-y-auto flex-1 min-h-0">
|
<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 allDevices = Object.entries(config?.devices?.iec ?? {});
|
||||||
const drives = allDevices
|
const drives = allDevices
|
||||||
.filter(([, v]: [string, any]) => (v as any)?.type === 'drive')
|
.filter(([, v]: [string, any]) => (v as any)?.type === 'drive')
|
||||||
|
|
@ -637,6 +623,7 @@ 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">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user