diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index cbdb5f3..246db3a 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -2,7 +2,12 @@ import React from 'react'; import { useDownloadProgressStore } from '../store/downloadProgressStore'; import { Play, Pause, MoreVertical, Clock } from 'lucide-react'; 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 { useSettingsStore } from '../store/useSettingsStore'; import { formatDateTime } from '../utils/dateTime'; @@ -30,8 +35,11 @@ interface DownloadItemProps { setContextMenu: (menu: { x: number; y: number; id: string }) => void; handlePause: (id: string, skipConfirm?: boolean) => void; handleResume: (item: DownloadItemType) => void; + handlePauseSelected: () => void; + handleResumeSelected: () => void; getCategoryIcon: (category: string) => React.ReactNode; isSelected: boolean; + selectedActionCounts: DownloadActionCounts; isQueueReorderable: boolean; isQueueDragSource: boolean; onMoveInQueue: (id: string, direction: 'up' | 'down') => void; @@ -49,8 +57,11 @@ export const DownloadItem = React.memo(({ setContextMenu, handlePause, handleResume, + handlePauseSelected, + handleResumeSelected, getCategoryIcon, isSelected, + selectedActionCounts, isQueueReorderable, isQueueDragSource, onMoveInQueue, @@ -67,6 +78,15 @@ export const DownloadItem = React.memo(({ const [isActionFocused, setIsActionFocused] = React.useState(false); const [actionPosition, setActionPosition] = React.useState(); 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 && ( isRowHovered || isActionHovered || @@ -358,13 +378,41 @@ export const DownloadItem = React.memo(({ }} > {canPauseDownload(download.status) && ( - )} {canStartDownload(download.status) && ( - )} -
+
{t($ => $.speedLimiter.globalSpeedLimit)} diff --git a/src/index.css b/src/index.css index 0bb6a01..067269b 100644 --- a/src/index.css +++ b/src/index.css @@ -1732,7 +1732,7 @@ html[data-list-density="relaxed"] { } .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); transform-origin: top center; } @@ -2935,10 +2935,12 @@ body.is-queue-dragging * { max-width: 100%; min-width: 0; padding: 0 2px; - border: 1px solid hsl(var(--border-color) / 0.88); + border: 1px solid hsl(var(--text-primary) / 0.32); border-radius: 6px; - background: hsl(var(--surface-raised)); - box-shadow: 0 1px 2px hsl(var(--shadow-color)); + background: hsl(var(--surface-overlay)); + box-shadow: + 0 0 0 1px hsl(var(--main-bg) / 0.72), + 0 2px 8px hsl(var(--shadow-color)); visibility: hidden; opacity: 0; pointer-events: none; @@ -2953,11 +2955,35 @@ body.is-queue-dragging * { .download-row-actions .app-icon-button, .download-row-actions .app-icon-button:hover:not(:disabled), .download-row-actions .app-icon-button:active:not(:disabled) { + position: relative; + overflow: visible; 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; } +.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) { .download-row-actions, .download-row-actions .app-icon-button { diff --git a/src/utils/downloadActions.test.ts b/src/utils/downloadActions.test.ts index da7c9d3..02104a0 100644 --- a/src/utils/downloadActions.test.ts +++ b/src/utils/downloadActions.test.ts @@ -3,6 +3,8 @@ import { canPauseDownload, canRedownload, canStartDownload, + countDownloadActions, + formatDownloadActionCount, getPauseResumeAction, isIdentityLocked, isTransferLocked, @@ -48,4 +50,24 @@ describe('download action policy', () => { expect(isIdentityLocked('completed')).toBe(true); 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+'); + }); }); diff --git a/src/utils/downloadActions.ts b/src/utils/downloadActions.ts index c14f85d..718b5b4 100644 --- a/src/utils/downloadActions.ts +++ b/src/utils/downloadActions.ts @@ -26,6 +26,27 @@ export const canStartDownload = (status: DownloadStatus): boolean => export const canPauseDownload = (status: DownloadStatus): boolean => 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((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 const getPauseResumeAction = (status: DownloadStatus): PauseResumeAction | null => {