feat(SearchComponents, SearchAssembly64, SearchCSDbNG, SearchCommoServe): add clearDirectory function and enhance download progress tracking
This commit is contained in:
parent
76962cc9bd
commit
30c96b8428
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -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…'}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user