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
+137 -107
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,12 +133,18 @@ 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 ${
@@ -143,20 +157,20 @@ 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 === 'queued' || download.status === 'staged' ? 'download-status-queued' :
@@ -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>
+9
View File
@@ -338,6 +338,15 @@ const common = {
eta: 'ETA',
dateAdded: 'Date Added',
},
columnOptions: {
open: 'Column options for {{column}}',
dragToReorder: 'Drag to reorder columns',
alignContent: 'Align content position',
left: 'Left',
center: 'Center',
right: 'Right',
resetLayout: 'Reset column layout',
},
addDownload: 'Add Download',
resumeAll: 'Resume All',
pauseAll: 'Pause All',
+9
View File
@@ -338,6 +338,15 @@ const fa = {
eta: 'زمان تخمینی',
dateAdded: 'تاریخ افزودن',
},
columnOptions: {
open: 'گزینه‌های ستون {{column}}',
dragToReorder: 'برای جابه‌جایی ستون‌ها بکشید',
alignContent: 'جایگاه محتوا',
left: 'چپ',
center: 'وسط',
right: 'راست',
resetLayout: 'بازنشانی چیدمان ستون‌ها',
},
addDownload: 'افزودن دانلود',
resumeAll: 'ادامه همه',
pauseAll: 'توقف همه',
+9
View File
@@ -338,6 +338,15 @@ const he = {
eta: 'זמן נותר',
dateAdded: 'תאריך הוספה',
},
columnOptions: {
open: 'אפשרויות עמודה: {{column}}',
dragToReorder: 'גררו כדי לשנות את סדר העמודות',
alignContent: 'מיקום התוכן',
left: 'שמאל',
center: 'מרכז',
right: 'ימין',
resetLayout: 'איפוס פריסת העמודות',
},
addDownload: 'הוספת הורדה',
resumeAll: 'המשך הכל',
pauseAll: 'השהיית הכל',
+337 -328
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -338,6 +338,15 @@ const uk = {
eta: 'Залишилось',
dateAdded: 'Дата додавання',
},
columnOptions: {
open: 'Параметри стовпця: {{column}}',
dragToReorder: 'Перетягніть, щоб змінити порядок стовпців',
alignContent: 'Позиція вмісту',
left: 'Ліворуч',
center: 'По центру',
right: 'Праворуч',
resetLayout: 'Скинути макет стовпців',
},
addDownload: 'Додати завантаження',
resumeAll: 'Відновити всі',
pauseAll: 'Призупинити всі',
+9
View File
@@ -338,6 +338,15 @@ const zhCN = {
eta: '剩余时间',
dateAdded: '添加日期',
},
columnOptions: {
open: '{{column}}列选项',
dragToReorder: '拖动以重新排列列',
alignContent: '内容位置',
left: '左',
center: '居中',
right: '右',
resetLayout: '重置列布局',
},
addDownload: '添加下载',
resumeAll: '全部继续',
pauseAll: '全部暂停',
+230 -57
View File
@@ -235,7 +235,9 @@
}
body.is-resizing,
body.is-resizing * {
body.is-resizing *,
body.is-column-resizing,
body.is-column-resizing * {
cursor: col-resize !important;
user-select: none !important;
}
@@ -1052,7 +1054,7 @@ html[data-list-density="relaxed"] {
transition: background-color 100ms ease;
}
.app-sidebar-shell--right .sidebar-resize-handle {
.app-sidebar-shell--right .sidebar-resize-handle {
inset-inline-start: -4px;
inset-inline-end: auto;
}
@@ -1915,40 +1917,109 @@ html[data-list-density="relaxed"] {
--download-column-padding-x: 14px;
}
.download-table-header > div {
.download-column-header {
position: relative;
height: 100%;
display: flex;
align-items: center;
justify-content: var(--column-justify, flex-start);
min-width: 0;
overflow: hidden;
padding-inline: var(--download-column-padding-x);
padding-inline-end: calc(var(--download-column-padding-x) + 30px);
cursor: grab;
transition: background-color 120ms ease, opacity 120ms ease, box-shadow 120ms ease;
}
.download-table-header > div:first-child {
padding-inline-start: 0;
direction: ltr;
text-align: left;
.download-column-header:active {
cursor: grabbing;
}
.download-table-header > div:last-child {
padding-inline-end: 0;
.download-column-header.is-dragging {
opacity: 0.22;
}
.download-table-header > div:last-child,
.download-row > div:last-child {
grid-column: 7;
.download-column-header.is-drop-flashing {
animation: column-drop-flash 260ms ease-out;
}
.download-table-header > div > span {
.download-column-header-content {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
max-width: 100%;
height: 100%;
overflow: hidden;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: inherit;
cursor: inherit;
}
.download-column-header-content > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.download-table-header > div:not(:first-child) {
padding-inline-start: var(--download-column-padding-x);
.download-column-header-content:focus-visible,
.download-column-options:focus-visible {
outline: 2px solid hsl(var(--accent-color));
outline-offset: -2px;
}
.download-column-options {
position: absolute;
inset-inline-end: 8px;
top: 50%;
z-index: 4;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: 0;
border-radius: 6px;
background: hsl(var(--surface-overlay) / 0.92);
color: hsl(var(--text-secondary));
opacity: 0;
pointer-events: none;
transform: translateY(-50%);
transition: opacity 120ms ease, color 120ms ease, background-color 120ms ease;
}
.download-column-header:hover .download-column-options,
.download-column-options:focus-visible {
opacity: 1;
pointer-events: auto;
}
.download-column-options:hover {
background: hsl(var(--item-hover));
color: hsl(var(--text-primary));
}
.download-column-header:first-child {
padding-inline-start: 0;
}
.download-column-header:last-child {
padding-inline-end: 30px;
}
.download-column-header:last-child,
.download-row > div:last-child {
grid-column: 7;
}
.download-column-header[data-column-key="File Name"] {
direction: ltr;
text-align: left;
}
.column-resize-handle {
@@ -1975,12 +2046,12 @@ html[data-list-density="relaxed"] {
}
.column-resize-handle:hover,
body.is-resizing .column-resize-handle:hover {
body.is-column-resizing .column-resize-handle:hover {
background: hsl(var(--accent-color) / 0.16);
}
.column-resize-handle:hover::after,
body.is-resizing .column-resize-handle:hover::after {
body.is-column-resizing .column-resize-handle:hover::after {
width: 2px;
transform: translateX(-1px);
background: hsl(var(--accent-color));
@@ -2059,21 +2130,41 @@ html[data-list-density="relaxed"] .download-ghost-row {
background: hsl(var(--item-hover));
}
.download-column-cell {
display: flex;
align-items: center;
justify-content: var(--column-justify, flex-start);
min-width: 0;
overflow: hidden;
}
.download-file-cell {
direction: ltr;
text-align: left;
display: flex;
}
.download-cell-content {
display: block;
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.download-file-cell > .download-cell-content {
display: inline-flex;
align-items: center;
gap: var(--download-cell-gap);
min-width: 0;
overflow: hidden;
width: max-content;
}
.download-file-name {
direction: ltr;
text-align: left;
display: block;
flex: 1 1 auto;
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -2083,24 +2174,13 @@ html[data-list-density="relaxed"] .download-ghost-row {
}
.download-cell-truncate {
display: flex;
align-items: center;
justify-content: flex-start;
min-width: 0;
overflow: hidden;
}
.download-size-cell {
justify-content: flex-start;
text-align: start;
}
.download-size-content {
display: flex;
align-items: center;
width: 100%;
min-width: 0;
overflow: hidden;
width: max-content;
direction: ltr;
text-align: start;
}
@@ -2124,36 +2204,80 @@ html[data-list-density="relaxed"] .download-ghost-row {
white-space: nowrap;
}
.download-row > .download-size-cell {
padding-inline-start: calc(var(--download-column-padding-x) + 8px);
}
.download-table-header > div:nth-child(2) {
padding-inline-start: calc(var(--download-column-padding-x) + 8px);
}
.download-cell-truncate > span,
.download-cell-right > span {
display: block;
text-align: start;
max-width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.download-cell-muted {
color: hsl(var(--text-muted));
}
.download-cell-right {
text-align: start;
display: flex;
justify-content: flex-start;
}
.download-row-actions {
position: absolute;
top: 50%;
inset-inline-end: var(--download-row-padding-x);
z-index: 2;
transform: translateY(-50%);
padding-inline-start: 4px;
border-radius: 6px;
background: hsl(var(--surface-overlay) / 0.94);
max-width: 100%;
}
.download-column-menu {
width: 188px;
}
.download-column-menu-title {
font-weight: 650;
}
.download-column-menu-label {
letter-spacing: 0.12em;
}
.download-column-menu-item[aria-checked="true"] {
color: hsl(var(--accent-color));
}
.download-column-drag-preview {
position: fixed;
z-index: 120;
display: inline-flex;
align-items: center;
gap: 5px;
height: var(--download-header-height);
padding: 0 12px;
border: 1px solid hsl(var(--accent-color) / 0.75);
border-radius: 8px;
background: hsl(var(--surface-overlay) / 0.96);
box-shadow: 0 12px 28px hsl(0 0% 0% / 0.28), 0 0 0 1px hsl(var(--accent-color) / 0.18);
color: hsl(var(--text-primary));
font-size: var(--download-row-font-size);
font-weight: 650;
pointer-events: none;
transition: left 70ms ease-out, top 120ms ease-out, width 120ms ease-out;
animation: column-drag-preview-in 120ms ease-out;
}
.download-column-drag-preview span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.download-column-drop-marker {
position: fixed;
z-index: 119;
width: 2px;
height: var(--download-header-height);
border-radius: 999px;
background: hsl(var(--accent-color));
box-shadow: 0 0 0 2px hsl(var(--accent-color) / 0.18), 0 0 14px hsl(var(--accent-color) / 0.8);
pointer-events: none;
transform: translateX(-1px);
animation: column-drop-marker-pulse 700ms ease-in-out infinite;
}
.add-download-nested-fields {
@@ -2202,12 +2326,20 @@ html[dir="rtl"] .download-context-menu-chevron {
.download-status-cell {
display: flex;
align-items: center;
justify-content: flex-start;
justify-content: var(--column-justify, flex-start);
gap: var(--download-status-gap);
min-width: 0;
overflow: hidden;
}
.download-status-content {
display: inline-flex;
align-items: center;
gap: var(--download-status-gap);
width: max-content;
max-width: 100%;
}
.download-status-cell > span {
min-width: 0;
max-width: 100%;
@@ -2222,8 +2354,9 @@ html[dir="rtl"] .download-context-menu-chevron {
}
.download-progress-track {
flex: 1 1 auto;
width: auto;
flex: 1 1 112px;
width: 112px;
max-width: 100%;
min-width: 0;
height: var(--download-progress-height);
border-radius: max(4px, calc(var(--download-progress-height) / 3));
@@ -2418,6 +2551,22 @@ html[dir="rtl"] .download-context-menu-chevron {
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes column-drag-preview-in {
from { opacity: 0; transform: translateY(4px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes column-drop-marker-pulse {
0%, 100% { opacity: 0.72; }
50% { opacity: 1; }
}
@keyframes column-drop-flash {
0% { box-shadow: inset 0 0 0 0 hsl(var(--accent-color) / 0); }
40% { box-shadow: inset 0 0 0 1px hsl(var(--accent-color) / 0.65); }
100% { box-shadow: inset 0 0 0 0 hsl(var(--accent-color) / 0); }
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
@@ -2495,6 +2644,30 @@ html[dir="rtl"] input[type="number"] {
margin-right: 0;
}
.app-sidebar-shell--right .sidebar-nav-label {
text-align: right;
}
.app-sidebar-shell--right .sidebar-section-label {
text-align: right;
}
.app-sidebar-shell--right .sidebar-section-label-toggle {
direction: rtl;
text-align: right;
}
.app-sidebar-shell--right .sidebar-add-queue-button,
.app-sidebar-shell--right .sidebar-queue-editor {
direction: rtl;
text-align: right;
}
html[dir="ltr"] .app-sidebar-shell--right .sidebar-queue-editor input {
direction: ltr;
text-align: right;
}
html[dir="ltr"] .app-sidebar-shell--right .sidebar-nav-label {
direction: ltr;
unicode-bidi: isolate;
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import {
DEFAULT_COLUMN_ALIGNMENTS,
DEFAULT_COLUMN_ORDER,
normalizeColumnAlignments,
normalizeColumnOrder,
normalizeColumnWidths,
} from './downloadTableColumns';
describe('download table column preferences', () => {
it('normalizes a persisted order without losing or duplicating columns', () => {
expect(normalizeColumnOrder(['Status', 'Status', 'not-a-column', 'File Name'])).toEqual([
'Status',
'File Name',
'Size',
'Speed',
'ETA',
'Date Added',
]);
});
it('clamps persisted widths to column minimums and restores malformed entries', () => {
expect(normalizeColumnWidths([10, 70, Number.POSITIVE_INFINITY, 80, '48', 140])).toEqual([
160,
70,
220,
80,
80,
140,
]);
expect(normalizeColumnWidths([0, 100, 220, 100, 80, 170])[0]).toBe(160);
});
it('keeps only supported positional alignment values', () => {
expect(normalizeColumnAlignments({
'File Name': 'center',
Size: 'right',
Status: 'invalid',
Speed: 'left',
})).toEqual({
...DEFAULT_COLUMN_ALIGNMENTS,
'File Name': 'center',
Size: 'right',
Speed: 'left',
});
});
it('restores the default order for malformed persisted data', () => {
expect(normalizeColumnOrder(null)).toEqual([...DEFAULT_COLUMN_ORDER]);
expect(normalizeColumnAlignments(null)).toEqual(DEFAULT_COLUMN_ALIGNMENTS);
});
});
+89
View File
@@ -0,0 +1,89 @@
import type { DownloadSortColumn } from './downloadTableSorting';
export type DownloadTableColumnKey = DownloadSortColumn;
export type DownloadColumnAlignment = 'left' | 'center' | 'right';
export const DEFAULT_COLUMN_ORDER = [
'File Name',
'Size',
'Status',
'Speed',
'ETA',
'Date Added',
] as const satisfies readonly DownloadTableColumnKey[];
export const DEFAULT_COLUMN_WIDTHS = [340, 100, 220, 100, 80, 170] as const;
export const COLUMN_MINIMUMS = [160, 58, 92, 58, 48, 112] as const;
export const COLUMN_WIDTHS_STORAGE_KEY = 'firelink-download-column-widths';
export const COLUMN_ORDER_STORAGE_KEY = 'firelink-download-column-order';
export const COLUMN_ALIGNMENTS_STORAGE_KEY = 'firelink-download-column-alignments';
export const DEFAULT_COLUMN_ALIGNMENTS: Record<DownloadTableColumnKey, DownloadColumnAlignment> = {
'File Name': 'left',
Size: 'left',
Status: 'left',
Speed: 'left',
ETA: 'left',
'Date Added': 'left',
};
export const COLUMN_ALIGNMENT_JUSTIFY: Record<DownloadColumnAlignment, 'flex-start' | 'center' | 'flex-end'> = {
left: 'flex-start',
center: 'center',
right: 'flex-end',
};
const isColumnKey = (value: unknown): value is DownloadTableColumnKey =>
typeof value === 'string' && (DEFAULT_COLUMN_ORDER as readonly string[]).includes(value);
const isColumnAlignment = (value: unknown): value is DownloadColumnAlignment =>
value === 'left' || value === 'center' || value === 'right';
export const normalizeColumnOrder = (value: unknown): DownloadTableColumnKey[] => {
const seen = new Set<DownloadTableColumnKey>();
const normalized: DownloadTableColumnKey[] = [];
if (Array.isArray(value)) {
for (const candidate of value) {
if (isColumnKey(candidate) && !seen.has(candidate)) {
seen.add(candidate);
normalized.push(candidate);
}
}
}
for (const key of DEFAULT_COLUMN_ORDER) {
if (!seen.has(key)) normalized.push(key);
}
return normalized;
};
export 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]
);
};
export const normalizeColumnAlignments = (value: unknown): Record<DownloadTableColumnKey, DownloadColumnAlignment> => {
const candidate = value && typeof value === 'object' && !Array.isArray(value)
? value as Partial<Record<DownloadTableColumnKey, unknown>>
: {};
return DEFAULT_COLUMN_ORDER.reduce((alignments, key) => {
alignments[key] = isColumnAlignment(candidate[key])
? candidate[key]
: DEFAULT_COLUMN_ALIGNMENTS[key];
return alignments;
}, {} as Record<DownloadTableColumnKey, DownloadColumnAlignment>);
};
export const columnIndex = (key: DownloadTableColumnKey): number =>
DEFAULT_COLUMN_ORDER.indexOf(key);