fix(ui): refine page transitions and bulk actions

This commit is contained in:
NimBold
2026-07-28 22:39:00 +03:30
parent 62f8c83c57
commit 807663cf03
8 changed files with 175 additions and 28 deletions
+51 -3
View File
@@ -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
+47 -17
View File
@@ -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"
> >
+1 -1
View File
@@ -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 && (
+1 -1
View File
@@ -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">
+1 -1
View File
@@ -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
View File
@@ -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 {
+22
View File
@@ -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+');
});
}); });
+21
View File
@@ -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 => {