feat(SearchComponents, SearchAssembly64, SearchCSDbNG, SearchCommoServe): add clearDirectory function and enhance download progress tracking

This commit is contained in:
Jaime Idolpx 2026-06-16 16:35:10 -04:00
parent 76962cc9bd
commit 30c96b8428
4 changed files with 66 additions and 12 deletions

View File

@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Search, Loader2, HardDrive, ChevronRight, ChevronDown, Trophy, Calendar, Users, RefreshCw, HelpCircle, X, SlidersHorizontal, MoreVertical, FolderOpen } from 'lucide-react'; import { Search, Loader2, HardDrive, ChevronRight, ChevronDown, Trophy, Calendar, Users, RefreshCw, HelpCircle, X, SlidersHorizontal, MoreVertical, FolderOpen } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { humanFileSize, basename, joinPath, putFileContents, streamFetch, type FetchProgress } from '../webdav'; import { humanFileSize, basename, clearDirectory, joinPath, putFileContents, streamFetch, type FetchProgress } from '../webdav';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
import { MarqueeText } from './ui/marquee-text'; import { MarqueeText } from './ui/marquee-text';
import { EntryIcon } from './MediaEntry'; import { EntryIcon } from './MediaEntry';
@ -175,7 +175,7 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
const [actionItem, setActionItem] = useState<ContentItem | null>(null); const [actionItem, setActionItem] = useState<ContentItem | null>(null);
const [mountEntry, setMountEntry] = useState<{ item: ContentItem; entry: ContentEntry } | null>(null); const [mountEntry, setMountEntry] = useState<{ item: ContentItem; entry: ContentEntry } | null>(null);
const [isMounting, setIsMounting] = useState(false); const [isMounting, setIsMounting] = useState(false);
const [mountProgress, setMountProgress] = useState<FetchProgress & { phase: 'fetching' | 'saving' } | null>(null); const [mountProgress, setMountProgress] = useState<FetchProgress & { phase: 'fetching' | 'saving' | 'image' } | null>(null);
const [showFilter, setShowFilter] = useState(() => _store.showFilter); const [showFilter, setShowFilter] = useState(() => _store.showFilter);
const [filterText, setFilterText] = useState(() => _store.filterText); const [filterText, setFilterText] = useState(() => _store.filterText);
@ -314,12 +314,27 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
setIsMounting(true); setIsMounting(true);
setMountProgress({ received: 0, total: 0, phase: 'fetching' }); setMountProgress({ received: 0, total: 0, phase: 'fetching' });
try { try {
await clearDirectory(DOWNLOAD_DIR);
const res = await leetFetch(`/search/bin/${item.id}/${item.category}/${entry.id}`); const res = await leetFetch(`/search/bin/${item.id}/${item.category}/${entry.id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await streamFetch(res, p => setMountProgress({ ...p, phase: 'fetching' })); const data = await streamFetch(res, p => setMountProgress({ ...p, phase: 'fetching' }));
const dest = joinPath(DOWNLOAD_DIR, entryFilename(entry)); const mainFname = entryFilename(entry);
const dest = joinPath(DOWNLOAD_DIR, mainFname);
setMountProgress(p => p && { ...p, phase: 'saving' }); setMountProgress(p => p && { ...p, phase: 'saving' });
await putFileContents(dest, data); await putFileContents(dest, data);
const imgEntry = (entries ?? []).find(e => e.id !== entry.id && /\.(png|jpg|jpeg|gif|bmp)$/i.test(e.path));
if (imgEntry) {
setMountProgress(p => p && { ...p, phase: 'image' });
try {
const imgRes = await leetFetch(`/search/bin/${item.id}/${item.category}/${imgEntry.id}`);
if (imgRes.ok) {
const imgData = await imgRes.arrayBuffer();
const mainBase = mainFname.replace(/\.[^.]+$/, '');
const imgExt = imgEntry.path.split('.').pop()?.toLowerCase() ?? 'png';
await putFileContents(joinPath(DOWNLOAD_DIR, `${mainBase}.${imgExt}`), imgData);
}
} catch { /* non-fatal */ }
}
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 = {};
@ -703,10 +718,10 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
<div className="overflow-y-auto flex-1 min-h-0"> <div className="overflow-y-auto flex-1 min-h-0">
{isMounting && ( {isMounting && (
<div className="flex flex-col items-center justify-center py-10 gap-4"> <div className="flex flex-col items-center justify-center py-10 gap-4">
{mountProgress?.phase === 'saving' ? ( {mountProgress?.phase === 'saving' || mountProgress?.phase === 'image' ? (
<> <>
<Loader2 className="w-7 h-7 text-blue-500 animate-spin" /> <Loader2 className="w-7 h-7 text-blue-500 animate-spin" />
<p className="text-sm text-neutral-500">Saving to device</p> <p className="text-sm text-neutral-500">{mountProgress.phase === 'image' ? 'Saving cover image…' : 'Saving to device…'}</p>
</> </>
) : ( ) : (
<> <>

View File

@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Search, Loader2, HardDrive, ChevronRight, X, SlidersHorizontal, MoreVertical, FolderOpen } from 'lucide-react'; import { Search, Loader2, HardDrive, ChevronRight, X, SlidersHorizontal, MoreVertical, FolderOpen } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { basename, humanFileSize, joinPath, putFileContents, streamFetch, type FetchProgress } from '../webdav'; import { basename, clearDirectory, humanFileSize, joinPath, putFileContents, streamFetch, type FetchProgress } from '../webdav';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
import { MarqueeText } from './ui/marquee-text'; import { MarqueeText } from './ui/marquee-text';
import { EntryIcon } from './MediaEntry'; import { EntryIcon } from './MediaEntry';
@ -94,7 +94,7 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
const [actionRow, setActionRow] = useState<CsdbRow | null>(null); const [actionRow, setActionRow] = useState<CsdbRow | null>(null);
const [mountEntry, setMountEntry] = useState<{ row: CsdbRow; link: CsdbDownloadLink } | null>(null); const [mountEntry, setMountEntry] = useState<{ row: CsdbRow; link: CsdbDownloadLink } | null>(null);
const [isMounting, setIsMounting] = useState(false); const [isMounting, setIsMounting] = useState(false);
const [mountProgress, setMountProgress] = useState<FetchProgress & { phase: 'fetching' | 'saving' } | null>(null); const [mountProgress, setMountProgress] = useState<FetchProgress & { phase: 'fetching' | 'saving' | 'image' } | null>(null);
const [showFilter, setShowFilter] = useState(() => _store.showFilter); const [showFilter, setShowFilter] = useState(() => _store.showFilter);
const [filterText, setFilterText] = useState(() => _store.filterText); const [filterText, setFilterText] = useState(() => _store.filterText);
@ -163,6 +163,7 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
setIsMounting(true); setIsMounting(true);
setMountProgress({ received: 0, total: 0, phase: 'fetching' }); setMountProgress({ received: 0, total: 0, phase: 'fetching' });
try { try {
await clearDirectory(DOWNLOAD_DIR);
const url = resolveLink(link.Link); const url = resolveLink(link.Link);
const res = await fetch(url); const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
@ -171,6 +172,18 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
const dest = joinPath(DOWNLOAD_DIR, fname); const dest = joinPath(DOWNLOAD_DIR, fname);
setMountProgress(p => p && { ...p, phase: 'saving' }); setMountProgress(p => p && { ...p, phase: 'saving' });
await putFileContents(dest, data); await putFileContents(dest, data);
if (release?.ScreenShot?.[0]) {
setMountProgress(p => p && { ...p, phase: 'image' });
try {
const imgRes = await fetch(release.ScreenShot[0]);
if (imgRes.ok) {
const imgData = await imgRes.arrayBuffer();
const mainBase = fname.replace(/\.[^.]+$/, '');
const imgExt = release.ScreenShot[0].split('.').pop()?.split('?')[0]?.toLowerCase() ?? 'png';
await putFileContents(joinPath(DOWNLOAD_DIR, `${mainBase}.${imgExt}`), imgData);
}
} catch { /* non-fatal */ }
}
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 = {};
@ -497,10 +510,10 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
<div className="overflow-y-auto flex-1 min-h-0"> <div className="overflow-y-auto flex-1 min-h-0">
{isMounting && ( {isMounting && (
<div className="flex flex-col items-center justify-center py-10 gap-4"> <div className="flex flex-col items-center justify-center py-10 gap-4">
{mountProgress?.phase === 'saving' ? ( {mountProgress?.phase === 'saving' || mountProgress?.phase === 'image' ? (
<> <>
<Loader2 className="w-7 h-7 text-blue-500 animate-spin" /> <Loader2 className="w-7 h-7 text-blue-500 animate-spin" />
<p className="text-sm text-neutral-500">Saving to device</p> <p className="text-sm text-neutral-500">{mountProgress.phase === 'image' ? 'Saving cover image…' : 'Saving to device…'}</p>
</> </>
) : ( ) : (
<> <>

View File

@ -1,7 +1,7 @@
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, SlidersHorizontal, MoreVertical, FolderOpen } from 'lucide-react'; import { Search, Loader2, HardDrive, Download, ChevronRight, ChevronDown, Trophy, Calendar, Users, RefreshCw, HelpCircle, X, SlidersHorizontal, MoreVertical, FolderOpen } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { humanFileSize, basename, joinPath, putFileContents, streamFetch, type FetchProgress } from '../webdav'; import { humanFileSize, basename, clearDirectory, joinPath, putFileContents, streamFetch, type FetchProgress } from '../webdav';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
import { MarqueeText } from './ui/marquee-text'; import { MarqueeText } from './ui/marquee-text';
import { EntryIcon } from './MediaEntry'; import { EntryIcon } from './MediaEntry';
@ -179,7 +179,7 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
const [actionItem, setActionItem] = useState<ContentItem | null>(null); const [actionItem, setActionItem] = useState<ContentItem | null>(null);
const [mountEntry, setMountEntry] = useState<{ item: ContentItem; entry: ContentEntry } | null>(null); const [mountEntry, setMountEntry] = useState<{ item: ContentItem; entry: ContentEntry } | null>(null);
const [downloading, setDownloading] = useState<number | null>(null); const [downloading, setDownloading] = useState<number | null>(null);
const [downloadProgress, setDownloadProgress] = useState<FetchProgress & { phase: 'fetching' | 'saving' } | null>(null); const [downloadProgress, setDownloadProgress] = useState<FetchProgress & { phase: 'fetching' | 'saving' | 'image' } | null>(null);
const [showFilter, setShowFilter] = useState(() => _store.showFilter); const [showFilter, setShowFilter] = useState(() => _store.showFilter);
const [filterText, setFilterText] = useState(() => _store.filterText); const [filterText, setFilterText] = useState(() => _store.filterText);
@ -308,12 +308,27 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
setDownloading(entry.id); setDownloading(entry.id);
setDownloadProgress({ received: 0, total: 0, phase: 'fetching' }); setDownloadProgress({ received: 0, total: 0, phase: 'fetching' });
try { try {
await clearDirectory(DOWNLOAD_DIR);
const res = await fetch(downloadUrl(item, entry)); const res = await fetch(downloadUrl(item, entry));
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await streamFetch(res, p => setDownloadProgress({ ...p, phase: 'fetching' })); const data = await streamFetch(res, p => setDownloadProgress({ ...p, phase: 'fetching' }));
const dest = joinPath(DOWNLOAD_DIR, entryFilename(entry)); const mainFname = entryFilename(entry);
const dest = joinPath(DOWNLOAD_DIR, mainFname);
setDownloadProgress(p => p && { ...p, phase: 'saving' }); setDownloadProgress(p => p && { ...p, phase: 'saving' });
await putFileContents(dest, data); await putFileContents(dest, data);
const imgEntry = (entries ?? []).find(e => e.id !== entry.id && /\.(png|jpg|jpeg|gif|bmp)$/i.test(e.path));
if (imgEntry) {
setDownloadProgress(p => p && { ...p, phase: 'image' });
try {
const imgRes = await fetch(downloadUrl(item, imgEntry));
if (imgRes.ok) {
const imgData = await imgRes.arrayBuffer();
const mainBase = mainFname.replace(/\.[^.]+$/, '');
const imgExt = imgEntry.path.split('.').pop()?.toLowerCase() ?? 'png';
await putFileContents(joinPath(DOWNLOAD_DIR, `${mainBase}.${imgExt}`), imgData);
}
} catch { /* non-fatal */ }
}
toast.success(`Saved to ${dest}`); toast.success(`Saved to ${dest}`);
} catch (e: any) { } catch (e: any) {
toast.error(`Download failed: ${e?.message ?? e}`); toast.error(`Download failed: ${e?.message ?? e}`);
@ -669,6 +684,8 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
<div className="text-xs text-neutral-500"> <div className="text-xs text-neutral-500">
{downloadProgress.phase === 'saving' {downloadProgress.phase === 'saving'
? 'Saving to device…' ? 'Saving to device…'
: downloadProgress.phase === 'image'
? 'Saving cover image…'
: downloadProgress.total : downloadProgress.total
? `${humanFileSize(downloadProgress.received)} / ${humanFileSize(downloadProgress.total)}` ? `${humanFileSize(downloadProgress.received)} / ${humanFileSize(downloadProgress.total)}`
: humanFileSize(downloadProgress.received) || 'Downloading…'} : humanFileSize(downloadProgress.received) || 'Downloading…'}

View File

@ -427,6 +427,15 @@ export function humanFileSize(bytes: number): string {
export type FetchProgress = { received: number; total: number }; export type FetchProgress = { received: number; total: number };
export async function clearDirectory(path: string): Promise<void> {
try {
const entries = await listDirectory(path);
await Promise.all(entries.filter(e => e.type === 'file').map(e => deletePath(e.path)));
} catch {
// Directory may not exist yet — ignore
}
}
export async function streamFetch( export async function streamFetch(
res: Response, res: Response,
onProgress: (p: FetchProgress) => void, onProgress: (p: FetchProgress) => void,