feat(ui): add customizable download table columns

Add persisted column reordering, positional alignment, animated drag feedback, and resize cleanup for the download table. Keep row actions at the trailing edge, preserve RTL/LTR behavior, and include localized column controls and sidebar alignment fixes.
This commit is contained in:
NimBold
2026-07-21 22:31:34 +03:30
parent 81381a5a93
commit a98cce0980
12 changed files with 1334 additions and 557 deletions
+140 -110
View File
@@ -9,12 +9,19 @@ import {
formatDownloadTotal,
resolveDownloadSizeDisplay
} from '../utils/downloadProgress';
import {
COLUMN_ALIGNMENT_JUSTIFY,
type DownloadColumnAlignment,
type DownloadTableColumnKey
} from '../utils/downloadTableColumns';
interface DownloadItemProps {
download: DownloadItemType;
index: number;
queueIndex: number;
queueLength: number;
columnOrder: DownloadTableColumnKey[];
columnAlignments: Record<DownloadTableColumnKey, DownloadColumnAlignment>;
tableGridTemplate: string;
tableMinWidth: number;
setContextMenu: (menu: { x: number; y: number; id: string }) => void;
@@ -31,6 +38,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
index,
queueIndex,
queueLength,
columnOrder,
columnAlignments,
tableGridTemplate,
tableMinWidth,
setContextMenu,
@@ -83,35 +92,34 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
unit: sizeDisplay.unit ?? '',
});
return (
<div
className={`download-row group cursor-default relative ${index % 2 !== 0 ? 'striped' : ''} ${isSelected ? 'is-selected' : ''}`}
style={{ gridTemplateColumns: tableGridTemplate, minWidth: tableMinWidth }}
onClick={(e) => onClick(e, download)}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
}}
>
<div className="download-file-cell">
<span className="shrink-0 text-text-muted">
{getCategoryIcon(download.category)}
</span>
<span className="download-file-name" title={download.fileName}>
{download.fileName}
</span>
</div>
const columnStyle = (key: DownloadTableColumnKey): React.CSSProperties => ({
'--column-justify': COLUMN_ALIGNMENT_JUSTIFY[columnAlignments[key]],
} as React.CSSProperties);
const cells: Record<DownloadTableColumnKey, React.ReactNode> = {
'File Name': (
<div
className="download-cell-truncate download-size-cell tabular-nums"
title={hasDownloadedAmount
? downloadedSizeLabel
: completedSizeLabel}
aria-label={hasDownloadedAmount
? downloadedSizeLabel
: completedSizeLabel}
className="download-column-cell download-file-cell download-column-file-name"
style={columnStyle('File Name')}
>
<div className="download-size-content">
<div className="download-cell-content">
<span className="shrink-0 text-text-muted">
{getCategoryIcon(download.category)}
</span>
<span className="download-file-name" title={download.fileName}>
{download.fileName}
</span>
</div>
</div>
),
Size: (
<div
className="download-column-cell download-cell-truncate download-size-cell tabular-nums"
style={columnStyle('Size')}
title={hasDownloadedAmount ? downloadedSizeLabel : completedSizeLabel}
aria-label={hasDownloadedAmount ? downloadedSizeLabel : completedSizeLabel}
>
<div className="download-cell-content download-size-content">
{hasDownloadedAmount ? (
<span className="download-size-progress">
<span className={downloadProgressColorClass(download.status)}>{sizeDisplay.downloaded}</span>
@@ -125,16 +133,22 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
</span>
</div>
</div>
<div className="download-status-cell">
),
Status: (
<div
className="download-column-cell download-status-cell"
style={columnStyle('Status')}
>
{download.status === 'completed' ? (
<span className="download-status download-status-completed" title={downloadStatusLabel}>{downloadStatusLabel}</span>
<span className="download-cell-content download-status download-status-completed" title={downloadStatusLabel}>
{downloadStatusLabel}
</span>
) : (
<>
<div className="download-cell-content download-status-content">
<div className="download-progress-track">
<div
className={`download-progress-fill ${
download.status === 'paused' ? 'paused' :
download.status === 'paused' ? 'paused' :
download.status === 'processing' ? 'processing' :
download.status === 'queued' || download.status === 'staged' ? 'queued' :
download.status === 'retrying' ? 'retrying' : ''
@@ -143,22 +157,22 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
/>
</div>
<span
title={
download.lastError && (download.status === 'failed' || download.status === 'retrying')
? download.lastError
: (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1
? `${downloadStatusLabel} #${queueIndex + 1}`
: download.status === 'downloading'
? displayPercent
: download.status === 'processing'
? downloadStatusLabel
: downloadStatusLabel
}
className={`download-status flex items-center gap-1.5 ${
download.status === 'paused' ? 'download-status-paused' :
download.status === 'failed' ? 'download-status-failed' :
title={
download.lastError && (download.status === 'failed' || download.status === 'retrying')
? download.lastError
: (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1
? `${downloadStatusLabel} #${queueIndex + 1}`
: download.status === 'downloading'
? displayPercent
: download.status === 'processing'
? downloadStatusLabel
: downloadStatusLabel
}
className={`download-status flex items-center gap-1.5 ${
download.status === 'paused' ? 'download-status-paused' :
download.status === 'failed' ? 'download-status-failed' :
download.status === 'processing' ? 'download-status-processing' :
download.status === 'downloading' ? 'download-status-downloading' :
download.status === 'downloading' ? 'download-status-downloading' :
download.status === 'queued' || download.status === 'staged' ? 'download-status-queued' :
download.status === 'retrying' ? 'download-status-retrying' : ''
}`}
@@ -178,83 +192,99 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
downloadStatusLabel
)}
</span>
</>
</div>
)}
</div>
<div className="download-cell-truncate">
<span
className="tabular-nums"
title={displaySpeed}
>
),
Speed: (
<div className="download-column-cell download-cell-truncate" style={columnStyle('Speed')}>
<span className="download-cell-content tabular-nums" title={displaySpeed}>
{displaySpeed}
</span>
</div>
<div className="download-cell-truncate">
<span
className="tabular-nums"
title={displayEta}
>
),
ETA: (
<div className="download-column-cell download-cell-truncate" style={columnStyle('ETA')}>
<span className="download-cell-content tabular-nums" title={displayEta}>
{displayEta}
</span>
</div>
<div className="download-cell-right">
),
'Date Added': (
<div className="download-column-cell download-cell-right download-column-date-added" style={columnStyle('Date Added')}>
<span
className="truncate group-hover:hidden tabular-nums"
className="download-cell-content download-date-value group-hover:hidden tabular-nums"
title={download.dateAdded ? new Date(download.dateAdded).toLocaleDateString() : '-'}
>
{download.dateAdded ? new Date(download.dateAdded).toLocaleDateString() : '-'}
</span>
<div
className="hidden group-hover:flex items-center justify-end gap-0.5 w-full ml-auto"
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
{(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 && (
<>
<button
onClick={() => onMoveInQueue(download.id, 'up')}
disabled={queueIndex === 0}
className="app-icon-button h-7 w-7 disabled:opacity-40"
title={t($ => $.downloads.actions.moveUp)}
>
<ArrowUp size={14} />
</button>
<button
onClick={() => onMoveInQueue(download.id, 'down')}
disabled={queueIndex === queueLength - 1}
className="app-icon-button h-7 w-7 disabled:opacity-40"
title={t($ => $.downloads.actions.moveDown)}
>
<ArrowDown size={14} />
</button>
</>
)}
{canPauseDownload(download.status) && (
<button onClick={() => handlePause(download.id)} className="app-icon-button h-7 w-7" title={t($ => $.downloads.actions.pause)}>
<Pause size={14} fill="currentColor" />
</button>
)}
{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)}>
<Play size={14} fill="currentColor" />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
}}
className="app-icon-button h-7 w-7"
title={t($ => $.downloads.actions.options)}
>
<MoreVertical size={14} />
</button>
</div>
</div>
),
};
const rowActions = (
<div
className="download-row-actions hidden group-hover:flex items-center gap-0.5"
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
{(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 && (
<>
<button
onClick={() => onMoveInQueue(download.id, 'up')}
disabled={queueIndex === 0}
className="app-icon-button h-7 w-7 disabled:opacity-40"
title={t($ => $.downloads.actions.moveUp)}
>
<ArrowUp size={14} />
</button>
<button
onClick={() => onMoveInQueue(download.id, 'down')}
disabled={queueIndex === queueLength - 1}
className="app-icon-button h-7 w-7 disabled:opacity-40"
title={t($ => $.downloads.actions.moveDown)}
>
<ArrowDown size={14} />
</button>
</>
)}
{canPauseDownload(download.status) && (
<button onClick={() => handlePause(download.id)} className="app-icon-button h-7 w-7" title={t($ => $.downloads.actions.pause)}>
<Pause size={14} fill="currentColor" />
</button>
)}
{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)}>
<Play size={14} fill="currentColor" />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
}}
className="app-icon-button h-7 w-7"
title={t($ => $.downloads.actions.options)}
>
<MoreVertical size={14} />
</button>
</div>
);
return (
<div
className={`download-row group cursor-default relative ${index % 2 !== 0 ? 'striped' : ''} ${isSelected ? 'is-selected' : ''}`}
style={{ gridTemplateColumns: tableGridTemplate, minWidth: tableMinWidth }}
onClick={(e) => onClick(e, download)}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
}}
>
{columnOrder.map(columnKey => (
<React.Fragment key={columnKey}>{cells[columnKey]}</React.Fragment>
))}
{rowActions}
</div>
);
});
+437 -59
View File
@@ -4,7 +4,11 @@ import { useToast } from '../contexts/ToastContext';
import { useSettingsStore } from '../store/useSettingsStore';
import { SidebarFilter } from './Sidebar';
import { useAutoAnimate } from '@formkit/auto-animate/react';
import { Play, Pause, Plus, FileText, Image as ImageIcon, Music, Film, Box, Archive, FileQuestion, ArrowDownCircle, Command, ChevronRight, ChevronUp, ChevronDown } from 'lucide-react';
import {
Play, Pause, Plus, FileText, Image as ImageIcon, Music, Film, Box, Archive, FileQuestion,
ArrowDownCircle, Command, ChevronRight, ChevronUp, ChevronDown, MoreHorizontal,
AlignLeft, AlignCenter, AlignRight, GripVertical
} from 'lucide-react';
import { DownloadItem as DownloadItemComponent } from './DownloadItem';
import { invokeCommand as invoke } from '../ipc';
import {
@@ -24,26 +28,27 @@ import {
type DownloadSortColumn,
type DownloadSortConfig
} from '../utils/downloadTableSorting';
import {
COLUMN_ALIGNMENTS_STORAGE_KEY,
COLUMN_ALIGNMENT_JUSTIFY,
COLUMN_MINIMUMS,
COLUMN_ORDER_STORAGE_KEY,
COLUMN_WIDTHS_STORAGE_KEY,
DEFAULT_COLUMN_ALIGNMENTS,
DEFAULT_COLUMN_ORDER,
DEFAULT_COLUMN_WIDTHS,
columnIndex,
normalizeColumnAlignments,
normalizeColumnOrder,
normalizeColumnWidths,
type DownloadColumnAlignment,
type DownloadTableColumnKey
} from '../utils/downloadTableColumns';
interface DownloadTableProps {
filter: SidebarFilter;
}
const DEFAULT_COLUMN_WIDTHS = [340, 100, 220, 100, 80, 170];
const COLUMN_MINIMUMS = [0, 58, 92, 58, 48, 112];
const COLUMN_WIDTHS_STORAGE_KEY = 'firelink-download-column-widths';
const normalizeColumnWidths = (value: unknown): number[] => {
if (!Array.isArray(value) || value.length !== DEFAULT_COLUMN_WIDTHS.length) {
return DEFAULT_COLUMN_WIDTHS;
}
return value.map((width, index) =>
typeof width === 'number' && Number.isFinite(width)
? Math.max(COLUMN_MINIMUMS[index], width)
: DEFAULT_COLUMN_WIDTHS[index]
);
};
const persistColumnWidths = (widths: number[]): void => {
try {
window.localStorage.setItem(COLUMN_WIDTHS_STORAGE_KEY, JSON.stringify(widths));
@@ -52,6 +57,39 @@ const persistColumnWidths = (widths: number[]): void => {
}
};
const persistColumnOrder = (order: DownloadTableColumnKey[]): void => {
try {
window.localStorage.setItem(COLUMN_ORDER_STORAGE_KEY, JSON.stringify(order));
} catch {
// Local storage can be unavailable in restricted WebView contexts.
}
};
const persistColumnAlignments = (alignments: Record<DownloadTableColumnKey, DownloadColumnAlignment>): void => {
try {
window.localStorage.setItem(COLUMN_ALIGNMENTS_STORAGE_KEY, JSON.stringify(alignments));
} catch {
// Local storage can be unavailable in restricted WebView contexts.
}
};
interface ColumnDragState {
key: DownloadTableColumnKey;
startX: number;
pointerX: number;
offsetX: number;
top: number;
width: number;
dropIndex: number;
markerX: number;
}
interface ColumnMenuState {
key: DownloadTableColumnKey;
x: number;
y: number;
}
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
const { t } = useTranslation();
const { downloads, queues, assignToQueue, openDeleteModal, redownload, moveInQueue } = useDownloadStore();
@@ -75,6 +113,17 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
const [sortConfig, setSortConfig] = useState<DownloadSortConfig>({ column: 'Date Added', direction: 'desc' });
const [queueSortConfig, setQueueSortConfig] = useState<DownloadSortConfig | null>(null);
const resizeCleanupRef = useRef<(() => void) | null>(null);
const dragGestureRef = useRef<{
key: DownloadTableColumnKey;
pointerId: number;
startX: number;
startY: number;
active: boolean;
} | null>(null);
const headerElementsRef = useRef(new Map<DownloadTableColumnKey, HTMLDivElement>());
const columnDragStateRef = useRef<ColumnDragState | null>(null);
const columnDropFlashTimerRef = useRef<number | null>(null);
const suppressHeaderClickRef = useRef(false);
const selectedIdsRef = useRef(selectedIds);
const lastSelectedIdRef = useRef(lastSelectedId);
const sortedDownloadsRef = useRef<DownloadItem[]>([]);
@@ -85,60 +134,237 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
const stored = JSON.parse(window.localStorage.getItem(COLUMN_WIDTHS_STORAGE_KEY) || 'null');
return normalizeColumnWidths(stored);
} catch {
return DEFAULT_COLUMN_WIDTHS;
return [...DEFAULT_COLUMN_WIDTHS];
}
});
const [columnOrder, setColumnOrder] = useState<DownloadTableColumnKey[]>(() => {
try {
return normalizeColumnOrder(JSON.parse(window.localStorage.getItem(COLUMN_ORDER_STORAGE_KEY) || 'null'));
} catch {
return [...DEFAULT_COLUMN_ORDER];
}
});
const [columnAlignments, setColumnAlignments] = useState(() => {
try {
return normalizeColumnAlignments(JSON.parse(window.localStorage.getItem(COLUMN_ALIGNMENTS_STORAGE_KEY) || 'null'));
} catch {
return { ...DEFAULT_COLUMN_ALIGNMENTS };
}
});
const [columnDragState, setColumnDragState] = useState<ColumnDragState | null>(null);
const [columnMenu, setColumnMenu] = useState<ColumnMenuState | null>(null);
const [columnDropFlashKey, setColumnDropFlashKey] = useState<DownloadTableColumnKey | null>(null);
const columnWidthsRef = useRef(columnWidths);
columnWidthsRef.current = columnWidths;
const normalizedColumnWidths = columnWidths.map((width, index) =>
Math.max(COLUMN_MINIMUMS[index], width)
);
const orderedColumns = useMemo(() => columnOrder, [columnOrder]);
const orderedColumnWidths = orderedColumns.map(key => normalizedColumnWidths[columnIndex(key)]);
const tableGridTemplate = [
...normalizedColumnWidths.slice(0, -1).map(width => `${width}px`),
...orderedColumnWidths.slice(0, -1).map(width => `${width}px`),
'minmax(0, 1fr)',
`${normalizedColumnWidths[normalizedColumnWidths.length - 1]}px`
`${orderedColumnWidths[orderedColumnWidths.length - 1]}px`
].join(' ');
const tableMinWidth = normalizedColumnWidths.reduce(
(total, width) => total + width,
0
);
const startColumnResize = (index: number, event: React.PointerEvent<HTMLDivElement>) => {
const updateColumnDragState = (next: ColumnDragState | null) => {
columnDragStateRef.current = next;
setColumnDragState(next);
};
const getColumnDropPosition = (key: DownloadTableColumnKey, pointerX: number) => {
const remainingColumns = orderedColumns.filter(columnKey => columnKey !== key);
const targetIndex = remainingColumns.findIndex(columnKey => {
const element = headerElementsRef.current.get(columnKey);
if (!element) return false;
const rect = element.getBoundingClientRect();
return pointerX < rect.left + rect.width / 2;
});
const dropIndex = targetIndex === -1 ? remainingColumns.length : targetIndex;
const markerElement = dropIndex < remainingColumns.length
? headerElementsRef.current.get(remainingColumns[dropIndex])
: headerElementsRef.current.get(remainingColumns[remainingColumns.length - 1]);
const markerRect = markerElement?.getBoundingClientRect();
const markerX = markerRect
? dropIndex < remainingColumns.length ? markerRect.left : markerRect.right
: pointerX;
return { dropIndex, markerX };
};
const handleColumnPointerDown = (key: DownloadTableColumnKey, event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
if (event.target instanceof Element && event.target.closest('.column-resize-handle, .download-column-options')) return;
event.currentTarget.setPointerCapture(event.pointerId);
dragGestureRef.current = {
key,
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
active: false,
};
};
const handleColumnPointerMove = (key: DownloadTableColumnKey, event: React.PointerEvent<HTMLDivElement>) => {
const gesture = dragGestureRef.current;
if (!gesture || gesture.key !== key || gesture.pointerId !== event.pointerId) return;
const distance = Math.hypot(event.clientX - gesture.startX, event.clientY - gesture.startY);
if (!gesture.active) {
if (distance < 5) return;
gesture.active = true;
suppressHeaderClickRef.current = true;
const rect = event.currentTarget.getBoundingClientRect();
const { dropIndex, markerX } = getColumnDropPosition(key, event.clientX);
updateColumnDragState({
key,
startX: gesture.startX,
pointerX: event.clientX,
offsetX: event.clientX - rect.left,
top: rect.top,
width: rect.width,
dropIndex,
markerX,
});
} else {
const current = columnDragStateRef.current;
if (!current) return;
const { dropIndex, markerX } = getColumnDropPosition(key, event.clientX);
updateColumnDragState({
...current,
pointerX: event.clientX,
dropIndex,
markerX,
});
}
event.preventDefault();
};
const finishColumnDrag = (key: DownloadTableColumnKey, event: React.PointerEvent<HTMLDivElement>, cancelled = false) => {
const gesture = dragGestureRef.current;
if (!gesture || gesture.key !== key || gesture.pointerId !== event.pointerId) return;
const current = columnDragStateRef.current;
dragGestureRef.current = null;
updateColumnDragState(null);
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
if (cancelled || !gesture.active || !current) {
suppressHeaderClickRef.current = false;
return;
}
window.setTimeout(() => {
suppressHeaderClickRef.current = false;
}, 0);
const remainingColumns = orderedColumns.filter(columnKey => columnKey !== key);
const nextOrder = [...remainingColumns];
nextOrder.splice(current.dropIndex, 0, key);
if (nextOrder.join('|') === orderedColumns.join('|')) return;
setColumnOrder(nextOrder);
persistColumnOrder(nextOrder);
if (columnDropFlashTimerRef.current !== null) {
window.clearTimeout(columnDropFlashTimerRef.current);
}
setColumnDropFlashKey(key);
columnDropFlashTimerRef.current = window.setTimeout(() => {
setColumnDropFlashKey(null);
columnDropFlashTimerRef.current = null;
}, 260);
};
const startColumnResize = (key: DownloadTableColumnKey, event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
resizeCleanupRef.current?.();
const index = columnIndex(key);
const startX = event.clientX;
const startWidth = columnWidths[index];
const startWidth = columnWidthsRef.current[index];
const handlePointerMove = (moveEvent: PointerEvent) => {
const nextWidth = Math.max(COLUMN_MINIMUMS[index], startWidth + moveEvent.clientX - startX);
setColumnWidths(widths => {
const nextWidths = widths.map((width, columnIndex) => columnIndex === index ? nextWidth : width);
columnWidthsRef.current = nextWidths;
return nextWidths;
});
const nextWidths = columnWidthsRef.current.map((width, columnIndex) =>
columnIndex === index ? nextWidth : width
);
columnWidthsRef.current = nextWidths;
setColumnWidths(nextWidths);
};
const handlePointerUp = () => {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointercancel', handlePointerUp);
window.removeEventListener('blur', handlePointerUp);
document.removeEventListener('visibilitychange', handlePointerUp);
persistColumnWidths(columnWidthsRef.current);
document.body.classList.remove('is-resizing');
document.body.classList.remove('is-column-resizing');
resizeCleanupRef.current = null;
};
resizeCleanupRef.current = handlePointerUp;
document.body.classList.add('is-resizing');
document.body.classList.add('is-column-resizing');
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
window.addEventListener('pointercancel', handlePointerUp);
window.addEventListener('blur', handlePointerUp);
document.addEventListener('visibilitychange', handlePointerUp);
};
const openColumnMenu = (key: DownloadTableColumnKey, x: number, y: number) => {
const menuWidth = 188;
const menuHeight = 220;
const maxX = Math.max(8, window.innerWidth - menuWidth - 8);
const maxY = Math.max(8, window.innerHeight - menuHeight - 8);
setContextMenu(null);
setColumnMenu({
key,
x: Math.min(Math.max(8, x), maxX),
y: Math.min(Math.max(8, y), maxY),
});
};
const setColumnAlignment = (alignment: DownloadColumnAlignment) => {
if (!columnMenu) return;
const key = columnMenu.key;
setColumnAlignments(current => {
const next = { ...current, [key]: alignment };
persistColumnAlignments(next);
return next;
});
setColumnMenu(null);
};
const resetColumnLayout = () => {
const widths = [...DEFAULT_COLUMN_WIDTHS];
const order = [...DEFAULT_COLUMN_ORDER];
const alignments = { ...DEFAULT_COLUMN_ALIGNMENTS };
setColumnWidths(widths);
columnWidthsRef.current = widths;
setColumnOrder(order);
setColumnAlignments(alignments);
persistColumnWidths(widths);
persistColumnOrder(order);
persistColumnAlignments(alignments);
setColumnMenu(null);
};
useEffect(() => {
const handleCloseMenu = () => setContextMenu(null);
const handleCloseMenu = () => {
setContextMenu(null);
setColumnMenu(null);
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setContextMenu(null);
if (event.key === 'Escape') {
setContextMenu(null);
setColumnMenu(null);
}
};
window.addEventListener('click', handleCloseMenu);
window.addEventListener('keydown', handleEscape);
@@ -150,6 +376,26 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
useEffect(() => () => {
resizeCleanupRef.current?.();
if (columnDropFlashTimerRef.current !== null) {
window.clearTimeout(columnDropFlashTimerRef.current);
}
}, []);
useEffect(() => {
const cancelColumnDrag = () => {
if (!dragGestureRef.current) return;
dragGestureRef.current = null;
columnDragStateRef.current = null;
setColumnDragState(null);
suppressHeaderClickRef.current = false;
};
window.addEventListener('blur', cancelColumnDrag);
document.addEventListener('visibilitychange', cancelColumnDrag);
return () => {
window.removeEventListener('blur', cancelColumnDrag);
document.removeEventListener('visibilitychange', cancelColumnDrag);
};
}, []);
useEffect(() => {
@@ -363,6 +609,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
setSelectedIds(new Set([menu.id]));
setLastSelectedId(menu.id);
}
setColumnMenu(null);
setContextMenu(menu);
}, []);
@@ -513,6 +760,19 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
}
}, []);
const columnDefinitions: Array<{ key: DownloadTableColumnKey; label: string }> = [
{ key: 'File Name', label: t($ => $.downloadTable.headers.fileName) },
{ key: 'Size', label: t($ => $.downloadTable.headers.size) },
{ key: 'Status', label: t($ => $.downloadTable.headers.status) },
{ key: 'Speed', label: t($ => $.downloadTable.headers.speed) },
{ key: 'ETA', label: t($ => $.downloadTable.headers.eta) },
{ key: 'Date Added', label: t($ => $.downloadTable.headers.dateAdded) },
];
const columnLabels = new Map(columnDefinitions.map(column => [column.key, column.label]));
const columnAlignmentStyle = (key: DownloadTableColumnKey): React.CSSProperties => ({
'--column-justify': COLUMN_ALIGNMENT_JUSTIFY[columnAlignments[key]],
} as React.CSSProperties);
return (
<div className="downloads-view flex-1 flex flex-col h-full min-w-0">
<div
@@ -578,36 +838,110 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
<div className="downloads-table flex-1 flex flex-col">
<div className="download-table-scroll">
<div className="download-table-header" style={{ gridTemplateColumns: tableGridTemplate, minWidth: tableMinWidth }}>
{[
{ key: 'File Name' as const, label: t($ => $.downloadTable.headers.fileName) },
{ key: 'Size' as const, label: t($ => $.downloadTable.headers.size) },
{ key: 'Status' as const, label: t($ => $.downloadTable.headers.status) },
{ key: 'Speed' as const, label: t($ => $.downloadTable.headers.speed) },
{ key: 'ETA' as const, label: t($ => $.downloadTable.headers.eta) },
{ key: 'Date Added' as const, label: t($ => $.downloadTable.headers.dateAdded) },
].map(({ key, label }, index) => (
<div
key={key}
className={`${index === 5 ? 'download-cell-right' : ''} cursor-pointer hover:text-text-primary transition-colors flex items-center justify-between`}
onClick={() => handleSort(key)}
>
<div className="flex items-center gap-1 w-full h-full select-none">
<span>{label}</span>
{(isQueueFilter ? queueSortConfig : sortConfig)?.column === key && (
(isQueueFilter ? queueSortConfig : sortConfig)?.direction === 'asc'
? <ChevronUp size={14} />
: <ChevronDown size={14} />
)}
</div>
<div
className={`download-table-header ${columnDragState ? 'is-column-dragging' : ''}`}
style={{ gridTemplateColumns: tableGridTemplate, minWidth: tableMinWidth }}
>
{orderedColumns.map(key => {
const label = columnLabels.get(key) ?? key;
const activeSort = isQueueFilter ? queueSortConfig : sortConfig;
const isDragging = columnDragState?.key === key;
const isDropFlashing = columnDropFlashKey === key;
return (
<div
className="column-resize-handle"
onPointerDown={(event) => startColumnResize(index, event)}
/>
</div>
))}
key={key}
ref={element => {
if (element) headerElementsRef.current.set(key, element);
else headerElementsRef.current.delete(key);
}}
data-column-key={key}
role="columnheader"
aria-sort={activeSort?.column === key
? activeSort.direction === 'asc' ? 'ascending' : 'descending'
: 'none'}
className={`download-column-header group ${isDragging ? 'is-dragging' : ''} ${isDropFlashing ? 'is-drop-flashing' : ''}`}
style={columnAlignmentStyle(key)}
onContextMenu={event => {
event.preventDefault();
event.stopPropagation();
openColumnMenu(key, event.clientX, event.clientY);
}}
onPointerDown={event => handleColumnPointerDown(key, event)}
onPointerMove={event => handleColumnPointerMove(key, event)}
onPointerUp={event => finishColumnDrag(key, event)}
onPointerCancel={event => finishColumnDrag(key, event, true)}
onLostPointerCapture={event => finishColumnDrag(key, event, true)}
>
<button
type="button"
className="download-column-header-content"
onClick={event => {
event.stopPropagation();
if (suppressHeaderClickRef.current) {
suppressHeaderClickRef.current = false;
return;
}
handleSort(key);
}}
title={t($ => $.downloadTable.columnOptions.dragToReorder)}
>
<span>{label}</span>
{activeSort?.column === key && (
activeSort.direction === 'asc'
? <ChevronUp size={14} aria-hidden="true" />
: <ChevronDown size={14} aria-hidden="true" />
)}
</button>
<button
type="button"
className="download-column-options"
aria-label={t($ => $.downloadTable.columnOptions.open, { column: label })}
title={t($ => $.downloadTable.columnOptions.open, { column: label })}
onPointerDown={event => event.stopPropagation()}
onClick={event => {
event.preventDefault();
event.stopPropagation();
const rect = event.currentTarget.getBoundingClientRect();
openColumnMenu(key, rect.left, rect.bottom + 6);
}}
>
<MoreHorizontal size={14} aria-hidden="true" />
</button>
<div
className="column-resize-handle"
aria-hidden="true"
onPointerDown={event => {
event.stopPropagation();
startColumnResize(key, event);
}}
/>
</div>
);
})}
</div>
{columnDragState && (
<>
<div
className="download-column-drop-marker"
style={{ top: columnDragState.top, left: columnDragState.markerX }}
aria-hidden="true"
/>
<div
className="download-column-drag-preview"
style={{
top: columnDragState.top,
left: columnDragState.pointerX - columnDragState.offsetX,
width: columnDragState.width,
}}
aria-hidden="true"
>
<GripVertical size={14} />
<span>{columnLabels.get(columnDragState.key) ?? columnDragState.key}</span>
</div>
</>
)}
<div className="download-table-body" style={{ minWidth: tableMinWidth }}>
<div className="download-table-list" ref={animationParent}>
{sortedDownloads.length === 0 ? (
@@ -647,6 +981,8 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
index={index}
queueIndex={queuePositionsByDownloadId.get(d.id)?.index ?? -1}
queueLength={queuePositionsByDownloadId.get(d.id)?.length ?? 0}
columnOrder={orderedColumns}
columnAlignments={columnAlignments}
tableGridTemplate={tableGridTemplate}
tableMinWidth={tableMinWidth}
setContextMenu={handleContextMenu}
@@ -666,14 +1002,56 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
</div>
</div>
{columnMenu && (
<div
role="menu"
className="download-column-menu app-modal fixed z-50 min-w-[188px] overflow-hidden py-1.5 text-[12px] font-medium text-text-primary"
style={{ top: columnMenu.y, left: columnMenu.x }}
onClick={event => event.stopPropagation()}
>
<div className="download-column-menu-title px-3 py-1.5 text-text-muted">
{columnLabels.get(columnMenu.key) ?? columnMenu.key}
</div>
<div className="download-column-menu-label px-3 pb-1 text-[10px] uppercase tracking-[0.12em] text-text-muted">
{t($ => $.downloadTable.columnOptions.alignContent)}
</div>
{([
['left', AlignLeft],
['center', AlignCenter],
['right', AlignRight],
] as const).map(([alignment, Icon]) => (
<button
key={alignment}
type="button"
role="menuitemradio"
aria-checked={columnAlignments[columnMenu.key] === alignment}
className="download-column-menu-item flex w-full items-center gap-2 px-3 py-1.5 text-start hover:bg-item-hover"
onClick={() => setColumnAlignment(alignment)}
>
<Icon size={14} aria-hidden="true" />
<span>{t($ => $.downloadTable.columnOptions[alignment])}</span>
</button>
))}
<div className="my-1 h-px bg-border-color" />
<button
type="button"
className="download-column-menu-item flex w-full items-center gap-2 px-3 py-1.5 text-start hover:bg-item-hover"
onClick={resetColumnLayout}
>
<GripVertical size={14} aria-hidden="true" />
<span>{t($ => $.downloadTable.columnOptions.resetLayout)}</span>
</button>
</div>
)}
{/* Floating Context Menu */}
{contextMenu && contextItem && (
<div
role="menu"
className="app-modal fixed z-50 min-w-[180px] overflow-visible py-1.5 text-[12px] font-medium text-text-primary"
style={{
top: Math.min(contextMenu.y, window.innerHeight - 300),
left: Math.min(contextMenu.x, window.innerWidth - 200)
top: Math.min(Math.max(8, contextMenu.y), Math.max(8, window.innerHeight - 300)),
left: Math.min(Math.max(8, contextMenu.x), Math.max(8, window.innerWidth - 200))
}}
onClick={(e) => e.stopPropagation()}
>
+3 -3
View File
@@ -241,7 +241,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
if (isRenaming) {
return (
<div className="flex items-center px-2.5 py-1 rounded-lg mb-0.5 bg-item-hover">
<div className="sidebar-queue-editor flex items-center px-2.5 py-1 rounded-lg mb-0.5 bg-item-hover">
<List className="w-4 h-4 me-2 text-text-secondary" strokeWidth={2} />
<input
ref={renameInputRef}
@@ -369,7 +369,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
<QueueItem key={queue.id} queue={queue} />
))}
{isAddingQueue ? (
<div className="flex items-center px-3.5 py-1.5 rounded-lg bg-item-hover mb-1">
<div className="sidebar-queue-editor flex items-center px-3.5 py-1.5 rounded-lg bg-item-hover mb-1">
<Plus className="w-4 h-4 me-2 text-text-secondary shrink-0" strokeWidth={2} />
<input
ref={addInputRef}
@@ -403,7 +403,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
setIsAddingQueue(true);
setNewQueueName('');
}}
className="flex w-full items-center px-3.5 py-1.5 rounded-lg text-[13px] text-text-muted hover:bg-item-hover hover:text-text-secondary cursor-default transition-colors mb-1"
className="sidebar-add-queue-button flex w-full items-center px-3.5 py-1.5 rounded-lg text-[13px] text-text-muted hover:bg-item-hover hover:text-text-secondary cursor-default transition-colors mb-1"
>
<Plus className="w-4 h-4 me-2 shrink-0" strokeWidth={2} />
<span className="truncate">{t($ => $.actions.addNewQueue)}</span>