mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-24 17:56:28 +00:00
fix(ui): refine page transitions and bulk actions
This commit is contained in:
@@ -2,7 +2,12 @@ import React from 'react';
|
|||||||
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
||||||
import { Play, Pause, MoreVertical, Clock } from 'lucide-react';
|
import { Play, Pause, MoreVertical, Clock } from 'lucide-react';
|
||||||
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
|
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
|
||||||
import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
|
import {
|
||||||
|
canPauseDownload,
|
||||||
|
canStartDownload,
|
||||||
|
formatDownloadActionCount,
|
||||||
|
type DownloadActionCounts,
|
||||||
|
} from '../utils/downloadActions';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useSettingsStore } from '../store/useSettingsStore';
|
import { useSettingsStore } from '../store/useSettingsStore';
|
||||||
import { formatDateTime } from '../utils/dateTime';
|
import { formatDateTime } from '../utils/dateTime';
|
||||||
@@ -30,8 +35,11 @@ interface DownloadItemProps {
|
|||||||
setContextMenu: (menu: { x: number; y: number; id: string }) => void;
|
setContextMenu: (menu: { x: number; y: number; id: string }) => void;
|
||||||
handlePause: (id: string, skipConfirm?: boolean) => void;
|
handlePause: (id: string, skipConfirm?: boolean) => void;
|
||||||
handleResume: (item: DownloadItemType) => void;
|
handleResume: (item: DownloadItemType) => void;
|
||||||
|
handlePauseSelected: () => void;
|
||||||
|
handleResumeSelected: () => void;
|
||||||
getCategoryIcon: (category: string) => React.ReactNode;
|
getCategoryIcon: (category: string) => React.ReactNode;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
selectedActionCounts: DownloadActionCounts;
|
||||||
isQueueReorderable: boolean;
|
isQueueReorderable: boolean;
|
||||||
isQueueDragSource: boolean;
|
isQueueDragSource: boolean;
|
||||||
onMoveInQueue: (id: string, direction: 'up' | 'down') => void;
|
onMoveInQueue: (id: string, direction: 'up' | 'down') => void;
|
||||||
@@ -49,8 +57,11 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
setContextMenu,
|
setContextMenu,
|
||||||
handlePause,
|
handlePause,
|
||||||
handleResume,
|
handleResume,
|
||||||
|
handlePauseSelected,
|
||||||
|
handleResumeSelected,
|
||||||
getCategoryIcon,
|
getCategoryIcon,
|
||||||
isSelected,
|
isSelected,
|
||||||
|
selectedActionCounts,
|
||||||
isQueueReorderable,
|
isQueueReorderable,
|
||||||
isQueueDragSource,
|
isQueueDragSource,
|
||||||
onMoveInQueue,
|
onMoveInQueue,
|
||||||
@@ -67,6 +78,15 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
const [isActionFocused, setIsActionFocused] = React.useState(false);
|
const [isActionFocused, setIsActionFocused] = React.useState(false);
|
||||||
const [actionPosition, setActionPosition] = React.useState<React.CSSProperties | undefined>();
|
const [actionPosition, setActionPosition] = React.useState<React.CSSProperties | undefined>();
|
||||||
const hasRowActions = download.status !== 'completed';
|
const hasRowActions = download.status !== 'completed';
|
||||||
|
const pauseSelectionCount = isSelected && selectedActionCounts.pause > 1
|
||||||
|
? selectedActionCounts.pause
|
||||||
|
: null;
|
||||||
|
const resumeSelectionCount = isSelected && selectedActionCounts.resume > 1
|
||||||
|
? selectedActionCounts.resume
|
||||||
|
: null;
|
||||||
|
const selectedCountLabel = (count: number | null) => count === null
|
||||||
|
? null
|
||||||
|
: t($ => $.downloadTable.summary.selected, { count });
|
||||||
const isActionVisible = hasRowActions && (
|
const isActionVisible = hasRowActions && (
|
||||||
isRowHovered ||
|
isRowHovered ||
|
||||||
isActionHovered ||
|
isActionHovered ||
|
||||||
@@ -358,13 +378,41 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{canPauseDownload(download.status) && (
|
{canPauseDownload(download.status) && (
|
||||||
<button onClick={() => handlePause(download.id)} className="app-icon-button h-7 w-7" title={t($ => $.downloads.actions.pause)}>
|
<button
|
||||||
|
onClick={() => pauseSelectionCount !== null ? handlePauseSelected() : handlePause(download.id)}
|
||||||
|
className="app-icon-button h-7 w-7"
|
||||||
|
title={pauseSelectionCount === null
|
||||||
|
? t($ => $.downloads.actions.pause)
|
||||||
|
: `${t($ => $.downloads.actions.pause)} (${selectedCountLabel(pauseSelectionCount)})`}
|
||||||
|
aria-label={pauseSelectionCount === null
|
||||||
|
? t($ => $.downloads.actions.pause)
|
||||||
|
: `${t($ => $.downloads.actions.pause)} (${selectedCountLabel(pauseSelectionCount)})`}
|
||||||
|
>
|
||||||
<Pause size={14} fill="currentColor" />
|
<Pause size={14} fill="currentColor" />
|
||||||
|
{pauseSelectionCount !== null ? (
|
||||||
|
<span className="download-row-action-badge" aria-hidden="true">
|
||||||
|
{formatDownloadActionCount(pauseSelectionCount)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canStartDownload(download.status) && (
|
{canStartDownload(download.status) && (
|
||||||
<button onClick={() => handleResume(download)} className="app-icon-button h-7 w-7" title={download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)}>
|
<button
|
||||||
|
onClick={() => resumeSelectionCount !== null ? handleResumeSelected() : handleResume(download)}
|
||||||
|
className="app-icon-button h-7 w-7"
|
||||||
|
title={resumeSelectionCount === null
|
||||||
|
? download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)
|
||||||
|
: `${t($ => $.downloadTable.startResume)} (${selectedCountLabel(resumeSelectionCount)})`}
|
||||||
|
aria-label={resumeSelectionCount === null
|
||||||
|
? download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)
|
||||||
|
: `${t($ => $.downloadTable.startResume)} (${selectedCountLabel(resumeSelectionCount)})`}
|
||||||
|
>
|
||||||
<Play size={14} fill="currentColor" />
|
<Play size={14} fill="currentColor" />
|
||||||
|
{resumeSelectionCount !== null ? (
|
||||||
|
<span className="download-row-action-badge" aria-hidden="true">
|
||||||
|
{formatDownloadActionCount(resumeSelectionCount)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
canPauseDownload,
|
canPauseDownload,
|
||||||
canRedownload,
|
canRedownload,
|
||||||
canStartDownload
|
canStartDownload,
|
||||||
|
countDownloadActions
|
||||||
} from '../utils/downloadActions';
|
} from '../utils/downloadActions';
|
||||||
import { isActiveDownloadStatus, isTransferActiveStatus } from '../utils/downloads';
|
import { isActiveDownloadStatus, isTransferActiveStatus } from '../utils/downloads';
|
||||||
import { summarizeDownloads, type DownloadSummary } from '../utils/downloadSummary';
|
import { summarizeDownloads, type DownloadSummary } from '../utils/downloadSummary';
|
||||||
@@ -1594,6 +1595,10 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
() => filteredDownloads.filter(download => selectedIds.has(download.id)),
|
() => filteredDownloads.filter(download => selectedIds.has(download.id)),
|
||||||
[filteredDownloads, selectedIds]
|
[filteredDownloads, selectedIds]
|
||||||
);
|
);
|
||||||
|
const selectedActionCounts = useMemo(
|
||||||
|
() => countDownloadActions(selectedDownloads),
|
||||||
|
[selectedDownloads]
|
||||||
|
);
|
||||||
const summaryDownloads = selectedDownloads.length > 0 ? selectedDownloads : filteredDownloads;
|
const summaryDownloads = selectedDownloads.length > 0 ? selectedDownloads : filteredDownloads;
|
||||||
const downloadSummary = useMemo(
|
const downloadSummary = useMemo(
|
||||||
() => summarizeDownloads(summaryDownloads, progressMap),
|
() => summarizeDownloads(summaryDownloads, progressMap),
|
||||||
@@ -1799,14 +1804,47 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
}
|
}
|
||||||
}, [showInteractionError]);
|
}, [showInteractionError]);
|
||||||
|
|
||||||
const resumeItemsSequentially = async (items: DownloadItem[]) => {
|
const resumeItemsSequentially = useCallback(async (items: DownloadItem[]) => {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const current = useDownloadStore.getState().downloads.find(download => download.id === item.id);
|
const current = useDownloadStore.getState().downloads.find(download => download.id === item.id);
|
||||||
if (current && canStartDownload(current.status)) {
|
if (current && canStartDownload(current.status)) {
|
||||||
await handleResume(current);
|
await handleResume(current);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}, [handleResume]);
|
||||||
|
|
||||||
|
const getCurrentSelectedDownloads = useCallback(() => {
|
||||||
|
const selected = selectedIdsRef.current;
|
||||||
|
return useDownloadStore.getState().downloads.filter(download => selected.has(download.id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePauseSelected = useCallback(async () => {
|
||||||
|
const items = getCurrentSelectedDownloads().filter(download => canPauseDownload(download.status));
|
||||||
|
if (items.length === 0) return;
|
||||||
|
|
||||||
|
const nonResumableCount = items.filter(download => download.resumable === false).length;
|
||||||
|
if (nonResumableCount > 0) {
|
||||||
|
const confirmPause = window.confirm(
|
||||||
|
nonResumableCount === 1
|
||||||
|
? t($ => $.downloadTable.nonResumableOne)
|
||||||
|
: t($ => $.downloadTable.nonResumableMany, { count: nonResumableCount })
|
||||||
|
);
|
||||||
|
if (!confirmPause) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(items.map(item => {
|
||||||
|
const current = useDownloadStore.getState().downloads.find(download => download.id === item.id);
|
||||||
|
return current && canPauseDownload(current.status)
|
||||||
|
? handlePause(current.id, true)
|
||||||
|
: Promise.resolve();
|
||||||
|
}));
|
||||||
|
}, [getCurrentSelectedDownloads, handlePause, t]);
|
||||||
|
|
||||||
|
const handleResumeSelected = useCallback(() => {
|
||||||
|
const items = getCurrentSelectedDownloads().filter(download => canStartDownload(download.status));
|
||||||
|
if (items.length === 0) return;
|
||||||
|
void resumeItemsSequentially(items);
|
||||||
|
}, [getCurrentSelectedDownloads, resumeItemsSequentially]);
|
||||||
|
|
||||||
const handleDelete = (ids: string | string[]) => {
|
const handleDelete = (ids: string | string[]) => {
|
||||||
openDeleteModal(ids);
|
openDeleteModal(ids);
|
||||||
@@ -2010,7 +2048,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="downloads-table flex-1 flex flex-col">
|
<div className="app-page-transition-content downloads-table flex-1 flex flex-col">
|
||||||
<div className="download-table-scroll">
|
<div className="download-table-scroll">
|
||||||
<div
|
<div
|
||||||
ref={headerRef}
|
ref={headerRef}
|
||||||
@@ -2161,8 +2199,11 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
setContextMenu={handleContextMenu}
|
setContextMenu={handleContextMenu}
|
||||||
handlePause={handlePause}
|
handlePause={handlePause}
|
||||||
handleResume={handleResume}
|
handleResume={handleResume}
|
||||||
|
handlePauseSelected={handlePauseSelected}
|
||||||
|
handleResumeSelected={handleResumeSelected}
|
||||||
getCategoryIcon={getCategoryIcon}
|
getCategoryIcon={getCategoryIcon}
|
||||||
isSelected={selectedIds.has(d.id)}
|
isSelected={selectedIds.has(d.id)}
|
||||||
|
selectedActionCounts={selectedActionCounts}
|
||||||
isQueueReorderable={queueReorderableIds.has(d.id)}
|
isQueueReorderable={queueReorderableIds.has(d.id)}
|
||||||
isQueueDragSource={Boolean(queueDragState?.active && queueDragState.ids.includes(d.id))}
|
isQueueDragSource={Boolean(queueDragState?.active && queueDragState.ids.includes(d.id))}
|
||||||
onMoveInQueue={handleMoveInQueue}
|
onMoveInQueue={handleMoveInQueue}
|
||||||
@@ -2274,7 +2315,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
void resumeItemsSequentially(itemsToResume);
|
handleResumeSelected();
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
||||||
>
|
>
|
||||||
@@ -2286,18 +2327,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
const nonResumableCount = itemsToPause.filter(d => d.resumable === false).length;
|
void handlePauseSelected();
|
||||||
if (nonResumableCount > 0) {
|
|
||||||
const confirmPause = window.confirm(
|
|
||||||
nonResumableCount === 1
|
|
||||||
? t($ => $.downloadTable.nonResumableOne)
|
|
||||||
: t($ => $.downloadTable.nonResumableMany, { count: nonResumableCount })
|
|
||||||
);
|
|
||||||
if (!confirmPause) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
itemsToPause.forEach(d => handlePause(d.id, true));
|
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ export default function LogsView() {
|
|||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
className="logs-console flex-1 overflow-y-auto p-3 font-mono text-[11px] leading-[1.5] select-text"
|
className="app-page-transition-content logs-console flex-1 overflow-y-auto p-3 font-mono text-[11px] leading-[1.5] select-text"
|
||||||
style={{ userSelect: 'text', WebkitUserSelect: 'text' }}
|
style={{ userSelect: 'text', WebkitUserSelect: 'text' }}
|
||||||
>
|
>
|
||||||
{logs.length === 0 && (
|
{logs.length === 0 && (
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ export default function SchedulerView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="app-page-transition-content flex-1 overflow-y-auto p-6">
|
||||||
<div className={`max-w-[760px] space-y-4 ${draft.enabled ? '' : 'opacity-50'}`}>
|
<div className={`max-w-[760px] space-y-4 ${draft.enabled ? '' : 'opacity-50'}`}>
|
||||||
<section className="app-card p-5">
|
<section className="app-card p-5">
|
||||||
<div className="mb-5 flex items-center gap-2 font-semibold text-text-primary">
|
<div className="mb-5 flex items-center gap-2 font-semibold text-text-primary">
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ export default function SpeedLimiterView() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="app-page-transition-content flex-1 overflow-y-auto p-6">
|
||||||
<section className={`app-card max-w-[760px] p-5 ${enabled ? '' : 'opacity-55'}`}>
|
<section className={`app-card max-w-[760px] p-5 ${enabled ? '' : 'opacity-55'}`}>
|
||||||
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
|
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
|
||||||
<Gauge size={18} className="text-accent" /> {t($ => $.speedLimiter.globalSpeedLimit)}
|
<Gauge size={18} className="text-accent" /> {t($ => $.speedLimiter.globalSpeedLimit)}
|
||||||
|
|||||||
+31
-5
@@ -1732,7 +1732,7 @@ html[data-list-density="relaxed"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-page-transition,
|
.settings-page-transition,
|
||||||
.app-page-transition {
|
.app-page-transition .app-page-transition-content {
|
||||||
animation: app-page-in 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
animation: app-page-in 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
transform-origin: top center;
|
transform-origin: top center;
|
||||||
}
|
}
|
||||||
@@ -2935,10 +2935,12 @@ body.is-queue-dragging * {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0 2px;
|
padding: 0 2px;
|
||||||
border: 1px solid hsl(var(--border-color) / 0.88);
|
border: 1px solid hsl(var(--text-primary) / 0.32);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: hsl(var(--surface-raised));
|
background: hsl(var(--surface-overlay));
|
||||||
box-shadow: 0 1px 2px hsl(var(--shadow-color));
|
box-shadow:
|
||||||
|
0 0 0 1px hsl(var(--main-bg) / 0.72),
|
||||||
|
0 2px 8px hsl(var(--shadow-color));
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -2953,11 +2955,35 @@ body.is-queue-dragging * {
|
|||||||
.download-row-actions .app-icon-button,
|
.download-row-actions .app-icon-button,
|
||||||
.download-row-actions .app-icon-button:hover:not(:disabled),
|
.download-row-actions .app-icon-button:hover:not(:disabled),
|
||||||
.download-row-actions .app-icon-button:active:not(:disabled) {
|
.download-row-actions .app-icon-button:active:not(:disabled) {
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: hsl(var(--text-secondary));
|
color: hsl(var(--text-primary));
|
||||||
transition: background-color 120ms ease, box-shadow 120ms ease, color 120ms ease, transform 100ms ease;
|
transition: background-color 120ms ease, box-shadow 120ms ease, color 120ms ease, transform 100ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.download-row-action-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -5px;
|
||||||
|
inset-inline-end: -5px;
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 3px;
|
||||||
|
border: 1px solid hsl(var(--surface-overlay));
|
||||||
|
border-radius: 999px;
|
||||||
|
background: hsl(var(--accent-color));
|
||||||
|
color: hsl(var(--accent-foreground));
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 750;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.download-row-actions,
|
.download-row-actions,
|
||||||
.download-row-actions .app-icon-button {
|
.download-row-actions .app-icon-button {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
canPauseDownload,
|
canPauseDownload,
|
||||||
canRedownload,
|
canRedownload,
|
||||||
canStartDownload,
|
canStartDownload,
|
||||||
|
countDownloadActions,
|
||||||
|
formatDownloadActionCount,
|
||||||
getPauseResumeAction,
|
getPauseResumeAction,
|
||||||
isIdentityLocked,
|
isIdentityLocked,
|
||||||
isTransferLocked,
|
isTransferLocked,
|
||||||
@@ -48,4 +50,24 @@ describe('download action policy', () => {
|
|||||||
expect(isIdentityLocked('completed')).toBe(true);
|
expect(isIdentityLocked('completed')).toBe(true);
|
||||||
expect(isTransferLocked('completed')).toBe(false);
|
expect(isTransferLocked('completed')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('counts only eligible actions for a multi-selection', () => {
|
||||||
|
const counts = countDownloadActions([
|
||||||
|
{ status: 'queued' },
|
||||||
|
{ status: 'downloading' },
|
||||||
|
{ status: 'paused' },
|
||||||
|
{ status: 'ready' },
|
||||||
|
{ status: 'staged' },
|
||||||
|
{ status: 'failed' },
|
||||||
|
{ status: 'completed' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(counts).toEqual({ pause: 2, resume: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps large action badges compact without changing the accessible count', () => {
|
||||||
|
expect(formatDownloadActionCount(2)).toBe('2');
|
||||||
|
expect(formatDownloadActionCount(99)).toBe('99');
|
||||||
|
expect(formatDownloadActionCount(100)).toBe('99+');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,6 +26,27 @@ export const canStartDownload = (status: DownloadStatus): boolean =>
|
|||||||
export const canPauseDownload = (status: DownloadStatus): boolean =>
|
export const canPauseDownload = (status: DownloadStatus): boolean =>
|
||||||
PAUSABLE_STATUSES.has(status);
|
PAUSABLE_STATUSES.has(status);
|
||||||
|
|
||||||
|
export interface DownloadActionCounts {
|
||||||
|
pause: number;
|
||||||
|
resume: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count the actions that a bulk pause/resume control can actually apply.
|
||||||
|
* Keep this derived from the same predicates used by the individual row
|
||||||
|
* buttons so a badge never promises to affect an ineligible item.
|
||||||
|
*/
|
||||||
|
export const countDownloadActions = (
|
||||||
|
downloads: ReadonlyArray<{ status: DownloadStatus }>
|
||||||
|
): DownloadActionCounts => downloads.reduce<DownloadActionCounts>((counts, download) => {
|
||||||
|
if (canPauseDownload(download.status)) counts.pause += 1;
|
||||||
|
if (canStartDownload(download.status)) counts.resume += 1;
|
||||||
|
return counts;
|
||||||
|
}, { pause: 0, resume: 0 });
|
||||||
|
|
||||||
|
export const formatDownloadActionCount = (count: number): string =>
|
||||||
|
count > 99 ? '99+' : String(count);
|
||||||
|
|
||||||
export type PauseResumeAction = 'pause' | 'resume';
|
export type PauseResumeAction = 'pause' | 'resume';
|
||||||
|
|
||||||
export const getPauseResumeAction = (status: DownloadStatus): PauseResumeAction | null => {
|
export const getPauseResumeAction = (status: DownloadStatus): PauseResumeAction | null => {
|
||||||
|
|||||||
Reference in New Issue
Block a user