mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-05 17:08:26 +00:00
feat(downloads): add media quality and queue reordering
This commit is contained in:
@@ -33,12 +33,15 @@ import { localeDirection, localePluralVariant, resolveAppLocale } from '../i18n/
|
||||
import {
|
||||
canSubmitMetadataRows,
|
||||
appendRequestUrlsAfterVersion,
|
||||
commonMediaQualitiesForRows,
|
||||
mediaFileNameForSelectedFormat,
|
||||
mediaQualityForRow,
|
||||
mediaFormatSelectorForRow,
|
||||
metadataSummaryState,
|
||||
playlistFilePrefix,
|
||||
reconcileDownloadRows,
|
||||
refreshFailedMetadataRows,
|
||||
selectExactMediaQuality,
|
||||
updateRowIfCurrent,
|
||||
type AddDownloadDraftRow
|
||||
} from '../utils/addDownloadMetadata';
|
||||
@@ -519,6 +522,7 @@ export const AddDownloadsModal = () => {
|
||||
const isApproximate = !exactBytes && approxBytes > 0;
|
||||
return {
|
||||
name: `${quality} ${container}`,
|
||||
quality,
|
||||
ext: f.ext,
|
||||
bytes,
|
||||
isApproximate,
|
||||
@@ -1187,6 +1191,7 @@ export const AddDownloadsModal = () => {
|
||||
isMedia: item.isMedia,
|
||||
resumable: item.resumable,
|
||||
mediaFormatSelector: formatSelector,
|
||||
mediaQuality: mediaQualityForRow(item),
|
||||
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined),
|
||||
sizeBytes: item.sizeBytes
|
||||
}, action);
|
||||
@@ -1285,6 +1290,14 @@ export const AddDownloadsModal = () => {
|
||||
};
|
||||
|
||||
const selectedItems = parsedItems.filter(item => item.selected !== false);
|
||||
const selectedReadyMediaItems = selectedItems.filter(item =>
|
||||
item.isMedia && item.status === 'ready' && Boolean(item.formats?.length)
|
||||
);
|
||||
const bulkQualityOptions = commonMediaQualitiesForRows(selectedReadyMediaItems);
|
||||
const applyBulkMediaQuality = (quality: string) => {
|
||||
const selectedIds = selectedReadyMediaItems.map(item => item.id);
|
||||
setParsedItems(items => selectExactMediaQuality(items, selectedIds, quality));
|
||||
};
|
||||
const allRowsSelected = parsedItems.length > 0 && selectedItems.length === parsedItems.length;
|
||||
const requiredBytes = selectedItems.reduce((acc, item) => acc + (item.sizeBytes || 0), 0);
|
||||
const hasApproximateSize = selectedItems.some(item =>
|
||||
@@ -1524,7 +1537,14 @@ export const AddDownloadsModal = () => {
|
||||
aria-label={t($ => $.addDownloads.selectItem, { file: item.file })}
|
||||
className="me-2 shrink-0 accent-purple-500"
|
||||
/>
|
||||
<div className="flex-[2] text-text-primary font-medium truncate pe-2" title={item.file}>{item.file}</div>
|
||||
<div className="flex-[2] min-w-0 flex items-center gap-2 text-text-primary font-medium truncate pe-2" title={item.file}>
|
||||
<span className="truncate">{item.file}</span>
|
||||
{item.isMedia && item.status === 'ready' && mediaQualityForRow(item) ? (
|
||||
<span className="add-download-quality-chip shrink-0" title={t($ => $.addDownloads.quality)}>
|
||||
{mediaQualityForRow(item)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={`flex-1 font-mono ${item.status === 'loading' ? 'text-text-muted/50' : 'text-text-muted'}`}>{item.size || t($ => $.addDownloads.unknown)}</div>
|
||||
<div className={`flex-[1.5] font-medium ${item.status === 'metadata-error' || item.status === 'invalid' ? 'text-red-500' : item.status === 'loading' ? 'text-orange-400' : 'text-blue-500'}`}>
|
||||
{item.status === 'loading' ? (
|
||||
@@ -1554,6 +1574,34 @@ export const AddDownloadsModal = () => {
|
||||
<div className="add-download-settings w-[45%] flex flex-col overflow-y-auto">
|
||||
<div className="p-6 space-y-5">
|
||||
|
||||
{selectedReadyMediaItems.length > 1 ? (
|
||||
<section className="add-download-section add-download-media-section relative overflow-hidden p-4">
|
||||
<div className="flex flex-col gap-1.5 relative z-10">
|
||||
<label className="text-[10px] uppercase font-bold tracking-wider text-text-muted">
|
||||
{t($ => $.addDownloads.applyQualityToSelected)}
|
||||
</label>
|
||||
{bulkQualityOptions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5" role="group" aria-label={t($ => $.addDownloads.applyQualityToSelected)}>
|
||||
{bulkQualityOptions.map(quality => (
|
||||
<button
|
||||
key={quality}
|
||||
type="button"
|
||||
onClick={() => applyBulkMediaQuality(quality)}
|
||||
className="add-download-quality-chip add-download-quality-chip-button"
|
||||
>
|
||||
{quality}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] text-text-muted">
|
||||
{t($ => $.addDownloads.noCommonQuality)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* Media Format (Dynamic) */}
|
||||
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isMedia && (
|
||||
<section className="add-download-section add-download-media-section relative overflow-hidden p-4">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
||||
import { Play, Pause, MoreVertical, Clock, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import { Play, Pause, MoreVertical, Clock, ArrowUp, ArrowDown, GripVertical } from 'lucide-react';
|
||||
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
|
||||
import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -32,7 +32,10 @@ interface DownloadItemProps {
|
||||
handleResume: (item: DownloadItemType) => void;
|
||||
getCategoryIcon: (category: string) => React.ReactNode;
|
||||
isSelected: boolean;
|
||||
isQueueReorderable: boolean;
|
||||
isQueueDragSource: boolean;
|
||||
onMoveInQueue: (id: string, direction: 'up' | 'down') => void;
|
||||
onQueueDragStart: (id: string, event: React.PointerEvent<HTMLButtonElement>) => void;
|
||||
onClick: (e: React.MouseEvent, item: DownloadItemType) => void;
|
||||
}
|
||||
|
||||
@@ -50,7 +53,10 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
handleResume,
|
||||
getCategoryIcon,
|
||||
isSelected,
|
||||
isQueueReorderable,
|
||||
isQueueDragSource,
|
||||
onMoveInQueue,
|
||||
onQueueDragStart,
|
||||
onClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -60,6 +66,11 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
const [isRowFocused, setIsRowFocused] = React.useState(false);
|
||||
const [actionPosition, setActionPosition] = React.useState<React.CSSProperties | undefined>();
|
||||
const isActionVisible = isRowHovered || isRowFocused;
|
||||
const mediaQualityLabel = (() => {
|
||||
if (!download.isMedia || typeof download.mediaQuality !== 'string') return undefined;
|
||||
const normalized = download.mediaQuality.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
return normalized.length > 0 && normalized.length <= 48 ? normalized : undefined;
|
||||
})();
|
||||
|
||||
const updateActionPosition = React.useCallback(() => {
|
||||
const row = rowRef.current;
|
||||
@@ -182,12 +193,32 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
style={columnStyle('File Name')}
|
||||
>
|
||||
<div className="download-cell-content">
|
||||
{isQueueReorderable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="download-queue-drag-handle app-icon-button"
|
||||
aria-label={t($ => $.downloadTable.queueDragHandle, { fileName: download.fileName })}
|
||||
title={t($ => $.downloadTable.queueDragHandle, { fileName: download.fileName })}
|
||||
onPointerDown={event => {
|
||||
event.stopPropagation();
|
||||
onQueueDragStart(download.id, event);
|
||||
}}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<GripVertical size={13} aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
<span className="shrink-0 text-text-muted">
|
||||
{getCategoryIcon(download.category)}
|
||||
</span>
|
||||
<span className="download-file-name" title={download.fileName}>
|
||||
{download.fileName}
|
||||
</span>
|
||||
{mediaQualityLabel ? (
|
||||
<span className="download-quality-chip shrink-0" title={t($ => $.addDownloads.quality)}>
|
||||
{mediaQualityLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
@@ -314,7 +345,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 && (
|
||||
{isQueueReorderable && queueIndex !== -1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onMoveInQueue(download.id, 'up')}
|
||||
@@ -360,7 +391,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
return (
|
||||
<div
|
||||
ref={rowRef}
|
||||
className={`download-row group cursor-default relative ${isActionVisible ? 'has-visible-actions' : ''} ${index % 2 !== 0 ? 'striped' : ''} ${isSelected ? 'is-selected' : ''}`}
|
||||
data-download-id={download.id}
|
||||
className={`download-row group cursor-default relative ${isActionVisible ? 'has-visible-actions' : ''} ${index % 2 !== 0 ? 'striped' : ''} ${isSelected ? 'is-selected' : ''} ${isQueueDragSource ? 'is-queue-drag-source' : ''}`}
|
||||
style={{ gridTemplateColumns: tableGridTemplate, minWidth: tableMinWidth }}
|
||||
tabIndex={0}
|
||||
onMouseEnter={() => {
|
||||
@@ -384,6 +416,20 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
}
|
||||
}}
|
||||
onClick={(e) => onClick(e, download)}
|
||||
onKeyDown={event => {
|
||||
if (
|
||||
isQueueReorderable &&
|
||||
event.altKey &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.shiftKey &&
|
||||
(event.key === 'ArrowUp' || event.key === 'ArrowDown')
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onMoveInQueue(download.id, event.key === 'ArrowUp' ? 'up' : 'down');
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
|
||||
|
||||
@@ -49,6 +49,9 @@ import {
|
||||
type DownloadColumnAlignment,
|
||||
type DownloadTableColumnKey
|
||||
} from '../utils/downloadTableColumns';
|
||||
import {
|
||||
targetIndexForBoundary
|
||||
} from '../utils/queueOrdering';
|
||||
|
||||
interface DownloadTableProps {
|
||||
filter: SidebarFilter;
|
||||
@@ -100,9 +103,28 @@ interface ColumnLayoutBounds {
|
||||
right: number;
|
||||
}
|
||||
|
||||
interface QueueDragState {
|
||||
pointerId: number;
|
||||
sourceId: string;
|
||||
queueId: string;
|
||||
ids: string[];
|
||||
startY: number;
|
||||
active: boolean;
|
||||
targetIndex: number;
|
||||
markerTop: number;
|
||||
}
|
||||
|
||||
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
const { t } = useTranslation();
|
||||
const { downloads, queues, assignToQueue, openDeleteModal, redownload, moveInQueue } = useDownloadStore();
|
||||
const {
|
||||
downloads,
|
||||
queues,
|
||||
assignToQueue,
|
||||
openDeleteModal,
|
||||
redownload,
|
||||
moveInQueue,
|
||||
moveManyInQueueToPosition
|
||||
} = useDownloadStore();
|
||||
const progressMap = useDownloadProgressStore(state => state.progressMap);
|
||||
const { addToast } = useToast();
|
||||
const isMac = navigator.userAgent.includes('Mac');
|
||||
@@ -153,6 +175,11 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
const selectedIdsRef = useRef(selectedIds);
|
||||
const lastSelectedIdRef = useRef(lastSelectedId);
|
||||
const sortedDownloadsRef = useRef<DownloadItem[]>([]);
|
||||
const queueListElementRef = useRef<HTMLDivElement | null>(null);
|
||||
const queueReorderableDownloadsRef = useRef<DownloadItem[]>([]);
|
||||
const queueDragStateRef = useRef<QueueDragState | null>(null);
|
||||
const queueDragCleanupRef = useRef<(() => void) | null>(null);
|
||||
const [queueDragState, setQueueDragState] = useState<QueueDragState | null>(null);
|
||||
selectedIdsRef.current = selectedIds;
|
||||
lastSelectedIdRef.current = lastSelectedId;
|
||||
const [columnWidths, setColumnWidths] = useState(() => {
|
||||
@@ -195,6 +222,10 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
);
|
||||
const tableMinWidthWithPadding = 'calc(' + tableMinWidth + 'px + var(--download-row-padding-x) + var(--download-row-padding-x))';
|
||||
const downloadsViewRef = useRef<HTMLDivElement>(null);
|
||||
const queueListRef = useCallback((element: HTMLDivElement | null) => {
|
||||
queueListElementRef.current = element;
|
||||
animationParent(element);
|
||||
}, [animationParent]);
|
||||
|
||||
const updateColumnDragState = (next: ColumnDragState | null) => {
|
||||
columnDragStateRef.current = next;
|
||||
@@ -545,7 +576,10 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
useEffect(() => () => {
|
||||
resizeCleanupRef.current?.();
|
||||
columnDragCleanupRef.current?.();
|
||||
queueDragCleanupRef.current?.();
|
||||
columnDragCleanupRef.current = null;
|
||||
queueDragCleanupRef.current = null;
|
||||
queueDragStateRef.current = null;
|
||||
const captureTarget = columnDragCaptureTargetRef.current;
|
||||
const capturePointerId = columnDragCapturePointerIdRef.current;
|
||||
columnDragTargetRef.current = null;
|
||||
@@ -632,6 +666,140 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
addToast({ message: `${message}: ${detail}`, variant: 'error', isActionable: true });
|
||||
}, [addToast]);
|
||||
|
||||
const queueRowForId = (id: string): HTMLElement | null => {
|
||||
const list = queueListElementRef.current;
|
||||
if (!list) return null;
|
||||
return Array.from(list.children).find(element =>
|
||||
element instanceof HTMLElement && element.dataset.downloadId === id
|
||||
) as HTMLElement | undefined || null;
|
||||
};
|
||||
|
||||
const queueDropPosition = (
|
||||
clientY: number,
|
||||
items: DownloadItem[],
|
||||
selectedIds: ReadonlySet<string>
|
||||
): { targetIndex: number; markerTop: number } => {
|
||||
const list = queueListElementRef.current;
|
||||
if (!list || items.length === 0) return { targetIndex: 0, markerTop: 0 };
|
||||
const listRect = list.getBoundingClientRect();
|
||||
let boundaryIndex = items.length;
|
||||
let markerTop = 0;
|
||||
for (let index = 0; index < items.length; index += 1) {
|
||||
const row = queueRowForId(items[index].id);
|
||||
if (!row) continue;
|
||||
const rect = row.getBoundingClientRect();
|
||||
if (clientY < rect.top + rect.height / 2) {
|
||||
boundaryIndex = index;
|
||||
markerTop = rect.top - listRect.top + list.scrollTop;
|
||||
break;
|
||||
}
|
||||
markerTop = rect.bottom - listRect.top + list.scrollTop;
|
||||
}
|
||||
return {
|
||||
targetIndex: targetIndexForBoundary(items, selectedIds, boundaryIndex),
|
||||
markerTop: Math.max(0, markerTop)
|
||||
};
|
||||
};
|
||||
|
||||
const finishQueueDrag = (cancelled = false) => {
|
||||
const current = queueDragStateRef.current;
|
||||
if (!current) return;
|
||||
queueDragCleanupRef.current?.();
|
||||
queueDragCleanupRef.current = null;
|
||||
queueDragStateRef.current = null;
|
||||
setQueueDragState(null);
|
||||
|
||||
if (!cancelled && current.active) {
|
||||
void moveManyInQueueToPosition(current.ids, current.queueId, current.targetIndex)
|
||||
.catch(error => showInteractionError(t($ => $.downloadTable.queueReorderFailed), error));
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
queueRowForId(current.sourceId)?.focus({ preventScroll: true });
|
||||
});
|
||||
};
|
||||
|
||||
const handleQueueDragStart = (
|
||||
id: string,
|
||||
event: React.PointerEvent<HTMLButtonElement>
|
||||
) => {
|
||||
if (!queueReorderingEnabled || event.button !== 0) return;
|
||||
const currentDownloads = useDownloadStore.getState().downloads;
|
||||
const source = currentDownloads.find(download => download.id === id);
|
||||
if (!source) return;
|
||||
|
||||
const reorderableById = new Map(
|
||||
queueReorderableDownloadsRef.current.map(download => [download.id, download])
|
||||
);
|
||||
if (!reorderableById.has(id)) return;
|
||||
|
||||
const currentSelectedIds = selectedIdsRef.current;
|
||||
const ids = (currentSelectedIds.has(id) ? Array.from(currentSelectedIds) : [id])
|
||||
.filter(selectedId => {
|
||||
const selected = reorderableById.get(selectedId);
|
||||
return selected && (selected.queueId || MAIN_QUEUE_ID) === (source.queueId || MAIN_QUEUE_ID);
|
||||
});
|
||||
if (!ids.includes(id)) return;
|
||||
|
||||
if (!currentSelectedIds.has(id)) {
|
||||
setSelectedIds(new Set([id]));
|
||||
setLastSelectedId(id);
|
||||
}
|
||||
|
||||
queueDragCleanupRef.current?.();
|
||||
const queueId = source.queueId || MAIN_QUEUE_ID;
|
||||
const selectedIdSet = new Set(ids);
|
||||
const initialItems = queueReorderableDownloadsRef.current
|
||||
.filter(download => (download.queueId || MAIN_QUEUE_ID) === queueId);
|
||||
const initialPosition = queueDropPosition(event.clientY, initialItems, selectedIdSet);
|
||||
const initialState: QueueDragState = {
|
||||
pointerId: event.pointerId,
|
||||
sourceId: id,
|
||||
queueId,
|
||||
ids,
|
||||
startY: event.clientY,
|
||||
active: false,
|
||||
...initialPosition
|
||||
};
|
||||
queueDragStateRef.current = initialState;
|
||||
setQueueDragState(initialState);
|
||||
|
||||
const pointerMove = (pointerEvent: PointerEvent) => {
|
||||
if (pointerEvent.pointerId !== event.pointerId) return;
|
||||
const drag = queueDragStateRef.current;
|
||||
if (!drag) return;
|
||||
const distance = Math.abs(pointerEvent.clientY - drag.startY);
|
||||
if (!drag.active && distance < 5) return;
|
||||
|
||||
const items = queueReorderableDownloadsRef.current
|
||||
.filter(download => (download.queueId || MAIN_QUEUE_ID) === drag.queueId);
|
||||
const nextPosition = queueDropPosition(pointerEvent.clientY, items, new Set(drag.ids));
|
||||
const nextState = { ...drag, ...nextPosition, active: true };
|
||||
queueDragStateRef.current = nextState;
|
||||
setQueueDragState(nextState);
|
||||
pointerEvent.preventDefault();
|
||||
};
|
||||
const pointerUp = (pointerEvent: PointerEvent) => {
|
||||
if (pointerEvent.pointerId === event.pointerId) finishQueueDrag();
|
||||
};
|
||||
const pointerCancel = (pointerEvent: PointerEvent) => {
|
||||
if (pointerEvent.pointerId === event.pointerId) finishQueueDrag(true);
|
||||
};
|
||||
const cancel = () => finishQueueDrag(true);
|
||||
window.addEventListener('pointermove', pointerMove);
|
||||
window.addEventListener('pointerup', pointerUp);
|
||||
window.addEventListener('pointercancel', pointerCancel);
|
||||
window.addEventListener('blur', cancel);
|
||||
document.addEventListener('visibilitychange', cancel);
|
||||
queueDragCleanupRef.current = () => {
|
||||
window.removeEventListener('pointermove', pointerMove);
|
||||
window.removeEventListener('pointerup', pointerUp);
|
||||
window.removeEventListener('pointercancel', pointerCancel);
|
||||
window.removeEventListener('blur', cancel);
|
||||
document.removeEventListener('visibilitychange', cancel);
|
||||
};
|
||||
};
|
||||
|
||||
const getDownloadPath = useCallback(async (item: DownloadItem) => {
|
||||
const fileName = item.fileName?.trim();
|
||||
if (!fileName) return null;
|
||||
@@ -722,6 +890,38 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
: sortDownloads(filteredDownloads, isQueueFilter ? queueSortConfig! : sortConfig),
|
||||
[filteredDownloads, isQueueFilter, queueSortConfig, sortConfig]);
|
||||
|
||||
const queueReorderingEnabled = isQueueFilter && !queueSortConfig;
|
||||
const queueReorderableDownloads = useMemo(
|
||||
() => queueReorderingEnabled
|
||||
? sortedDownloads.filter(download =>
|
||||
download.status !== 'completed' &&
|
||||
!(isActiveDownloadStatus(download.status) && download.status !== 'queued')
|
||||
)
|
||||
: [],
|
||||
[queueReorderingEnabled, sortedDownloads]
|
||||
);
|
||||
const queueReorderableIds = useMemo(
|
||||
() => new Set(queueReorderableDownloads.map(download => download.id)),
|
||||
[queueReorderableDownloads]
|
||||
);
|
||||
queueReorderableDownloadsRef.current = queueReorderableDownloads;
|
||||
|
||||
useEffect(() => {
|
||||
const current = queueDragStateRef.current;
|
||||
if (!current) return;
|
||||
const currentQueueIds = new Set(
|
||||
queueReorderableDownloads
|
||||
.filter(download => (download.queueId || MAIN_QUEUE_ID) === current.queueId)
|
||||
.map(download => download.id)
|
||||
);
|
||||
if (
|
||||
!queueReorderingEnabled ||
|
||||
current.ids.some(id => !currentQueueIds.has(id))
|
||||
) {
|
||||
finishQueueDrag(true);
|
||||
}
|
||||
}, [queueReorderableDownloads, queueReorderingEnabled]);
|
||||
|
||||
const selectedDownloads = useMemo(
|
||||
() => filteredDownloads.filter(download => selectedIds.has(download.id)),
|
||||
[filteredDownloads, selectedIds]
|
||||
@@ -1063,6 +1263,11 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
<div className="downloads-title">
|
||||
{getFilterTitle()}
|
||||
<span className="downloads-count">{sortedDownloads.length}</span>
|
||||
{queueReorderingEnabled && queueReorderableDownloads.length > 0 ? (
|
||||
<span className="downloads-queue-reorder-hint">
|
||||
{t($ => $.downloadTable.queueReorderHint)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="downloads-summary">
|
||||
<span className="downloads-summary-scope">{summaryScopeLabel}</span>
|
||||
@@ -1188,7 +1393,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
)}
|
||||
|
||||
<div className="download-table-body" style={{ minWidth: tableMinWidthWithPadding }}>
|
||||
<div className="download-table-list" ref={animationParent}>
|
||||
<div className="download-table-list" ref={queueListRef}>
|
||||
{sortedDownloads.length === 0 ? (
|
||||
<div className="downloads-empty-state">
|
||||
<ArrowDownCircle aria-hidden="true" />
|
||||
@@ -1235,10 +1440,20 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
handleResume={handleResume}
|
||||
getCategoryIcon={getCategoryIcon}
|
||||
isSelected={selectedIds.has(d.id)}
|
||||
isQueueReorderable={queueReorderableIds.has(d.id)}
|
||||
isQueueDragSource={Boolean(queueDragState?.active && queueDragState.ids.includes(d.id))}
|
||||
onMoveInQueue={handleMoveInQueue}
|
||||
onQueueDragStart={handleQueueDragStart}
|
||||
onClick={handleItemClick}
|
||||
/>
|
||||
))}
|
||||
{queueDragState?.active ? (
|
||||
<div
|
||||
className="download-queue-drop-marker"
|
||||
style={{ top: `${queueDragState.markerTop}px` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex-1 min-h-0 bg-transparent pointer-events-none" />
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user