mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-02 15:39:37 +00:00
feat(downloads): add details controls and aggregate summaries
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { useDownloadStore, DownloadItem, MAIN_QUEUE_ID } from '../store/useDownloadStore';
|
||||
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { SidebarFilter } from './Sidebar';
|
||||
@@ -21,6 +22,8 @@ import {
|
||||
canStartDownload
|
||||
} from '../utils/downloadActions';
|
||||
import { isActiveDownloadStatus, isTransferActiveStatus } from '../utils/downloads';
|
||||
import { formatDownloadBytes } from '../utils/downloadProgress';
|
||||
import { summarizeDownloads } from '../utils/downloadSummary';
|
||||
import { readClipboardDownloadUrls } from '../utils/clipboard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -100,6 +103,7 @@ interface ColumnLayoutBounds {
|
||||
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
const { t } = useTranslation();
|
||||
const { downloads, queues, assignToQueue, openDeleteModal, redownload, moveInQueue } = useDownloadStore();
|
||||
const progressMap = useDownloadProgressStore(state => state.progressMap);
|
||||
const { addToast } = useToast();
|
||||
const isMac = navigator.userAgent.includes('Mac');
|
||||
const [isReadingClipboard, setIsReadingClipboard] = useState(false);
|
||||
@@ -718,6 +722,26 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
: sortDownloads(filteredDownloads, isQueueFilter ? queueSortConfig! : sortConfig),
|
||||
[filteredDownloads, isQueueFilter, queueSortConfig, sortConfig]);
|
||||
|
||||
const selectedDownloads = useMemo(
|
||||
() => filteredDownloads.filter(download => selectedIds.has(download.id)),
|
||||
[filteredDownloads, selectedIds]
|
||||
);
|
||||
const summaryDownloads = selectedDownloads.length > 0 ? selectedDownloads : filteredDownloads;
|
||||
const downloadSummary = useMemo(
|
||||
() => summarizeDownloads(summaryDownloads, progressMap),
|
||||
[summaryDownloads, progressMap]
|
||||
);
|
||||
const summaryScopeLabel = selectedDownloads.length > 0
|
||||
? t($ => $.downloadTable.summary.selected, { count: downloadSummary.itemCount })
|
||||
: t($ => $.downloadTable.summary.items, { count: downloadSummary.itemCount });
|
||||
const formatSummaryBytes = (value: number | null, isEstimated = false): string => {
|
||||
if (value === null) return t($ => $.downloadTable.summary.unknown);
|
||||
const formatted = formatDownloadBytes(value);
|
||||
return isEstimated
|
||||
? t($ => $.downloadTable.summary.estimated, { value: formatted })
|
||||
: formatted;
|
||||
};
|
||||
|
||||
// Each row used to derive this by filtering and sorting the complete store
|
||||
// independently. That made a 1000-entry playlist perform O(n^2 log n) work
|
||||
// on every download update. Compute the same queue membership once and pass
|
||||
@@ -1040,6 +1064,21 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
{getFilterTitle()}
|
||||
<span className="downloads-count">{sortedDownloads.length}</span>
|
||||
</div>
|
||||
<div className="downloads-summary">
|
||||
<span className="downloads-summary-scope">{summaryScopeLabel}</span>
|
||||
<span className="downloads-summary-metric">
|
||||
<span className="downloads-summary-label">{t($ => $.downloadTable.summary.downloaded)}</span>
|
||||
<span className="downloads-summary-value">{formatSummaryBytes(downloadSummary.downloadedBytes)}</span>
|
||||
</span>
|
||||
<span className="downloads-summary-metric">
|
||||
<span className="downloads-summary-label">{t($ => $.downloadTable.summary.remaining)}</span>
|
||||
<span className="downloads-summary-value">{formatSummaryBytes(downloadSummary.remainingBytes, downloadSummary.remainingIsEstimated)}</span>
|
||||
</span>
|
||||
<span className="downloads-summary-metric">
|
||||
<span className="downloads-summary-label">{t($ => $.downloadTable.summary.active)}</span>
|
||||
<span className="downloads-summary-value">{downloadSummary.activeCount}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="downloads-table flex-1 flex flex-col">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle,
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { resolveCategoryDestination } from '../utils/downloadLocations';
|
||||
import {
|
||||
getPauseResumeAction,
|
||||
isIdentityLocked as getIdentityLocked,
|
||||
isTransferLocked as getTransferLocked
|
||||
} from '../utils/downloadActions';
|
||||
@@ -79,6 +80,7 @@ export const PropertiesModal = () => {
|
||||
const [mirrors, setMirrors] = useState('');
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [isPauseResumePending, setIsPauseResumePending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPropertiesDownloadId) {
|
||||
@@ -205,6 +207,38 @@ export const PropertiesModal = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePauseResume = async () => {
|
||||
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === item.id);
|
||||
const action = currentItem ? getPauseResumeAction(currentItem.status) : null;
|
||||
if (!currentItem || !action || isPauseResumePending) return;
|
||||
|
||||
if (action === 'pause' && currentItem.resumable === false) {
|
||||
const confirmPause = window.confirm(t($ => $.downloadTable.nonResumableOne));
|
||||
if (!confirmPause) return;
|
||||
}
|
||||
|
||||
setErrorMessage('');
|
||||
setIsPauseResumePending(true);
|
||||
try {
|
||||
if (action === 'pause') {
|
||||
await useDownloadStore.getState().pauseDownload(currentItem.id);
|
||||
} else {
|
||||
const resumed = await useDownloadStore.getState().resumeDownload(currentItem.id);
|
||||
if (!resumed) {
|
||||
throw new Error(t($ => $.downloadTable.backendRejectedStart));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
const message = action === 'pause'
|
||||
? t($ => $.downloadTable.pauseFailed)
|
||||
: t($ => $.downloadTable.resumeFailed, { fileName: currentItem.fileName });
|
||||
setErrorMessage(t($ => $.downloadTable.interactionError, { message, detail }));
|
||||
} finally {
|
||||
setIsPauseResumePending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const identityLocked = getIdentityLocked(item.status);
|
||||
const transferLocked = getTransferLocked(item.status);
|
||||
const configuredConnections = resolveDownloadConnections(item.connections, perServerConnections);
|
||||
@@ -262,6 +296,11 @@ export const PropertiesModal = () => {
|
||||
return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value;
|
||||
})();
|
||||
const statusLabel = t($ => $.downloads.status[item.status]);
|
||||
const pauseResumeAction = getPauseResumeAction(item.status);
|
||||
const pauseResumeLabel = pauseResumeAction === 'pause'
|
||||
? t($ => $.downloadTable.pause)
|
||||
: t($ => $.downloadTable.resume);
|
||||
const PauseResumeIcon = pauseResumeAction === 'pause' ? Pause : Play;
|
||||
const sizeDescription = sizeDisplay.totalIsEstimate
|
||||
? t($ => $.downloads.size.downloadedOfApproximate, {
|
||||
downloaded: sizeDisplay.downloaded ?? '',
|
||||
@@ -513,12 +552,27 @@ export const PropertiesModal = () => {
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedPropertiesDownloadId(null)}
|
||||
className="app-button px-4 text-xs"
|
||||
>
|
||||
{t($ => $.properties.cancel)}
|
||||
</button>
|
||||
{pauseResumeAction && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handlePauseResume()}
|
||||
disabled={isPauseResumePending}
|
||||
aria-label={pauseResumeLabel}
|
||||
title={pauseResumeLabel}
|
||||
className={`app-button px-4 text-xs ${isPauseResumePending ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<PauseResumeIcon size={14} fill="currentColor" />
|
||||
{pauseResumeLabel}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={transferLocked}
|
||||
className={`app-button app-button-primary px-4 text-xs ${transferLocked ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
|
||||
Reference in New Issue
Block a user