mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
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:
+140
-110
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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()}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -338,6 +338,15 @@ const fa = {
|
||||
eta: 'زمان تخمینی',
|
||||
dateAdded: 'تاریخ افزودن',
|
||||
},
|
||||
columnOptions: {
|
||||
open: 'گزینههای ستون {{column}}',
|
||||
dragToReorder: 'برای جابهجایی ستونها بکشید',
|
||||
alignContent: 'جایگاه محتوا',
|
||||
left: 'چپ',
|
||||
center: 'وسط',
|
||||
right: 'راست',
|
||||
resetLayout: 'بازنشانی چیدمان ستونها',
|
||||
},
|
||||
addDownload: 'افزودن دانلود',
|
||||
resumeAll: 'ادامه همه',
|
||||
pauseAll: 'توقف همه',
|
||||
|
||||
@@ -338,6 +338,15 @@ const he = {
|
||||
eta: 'זמן נותר',
|
||||
dateAdded: 'תאריך הוספה',
|
||||
},
|
||||
columnOptions: {
|
||||
open: 'אפשרויות עמודה: {{column}}',
|
||||
dragToReorder: 'גררו כדי לשנות את סדר העמודות',
|
||||
alignContent: 'מיקום התוכן',
|
||||
left: 'שמאל',
|
||||
center: 'מרכז',
|
||||
right: 'ימין',
|
||||
resetLayout: 'איפוס פריסת העמודות',
|
||||
},
|
||||
addDownload: 'הוספת הורדה',
|
||||
resumeAll: 'המשך הכל',
|
||||
pauseAll: 'השהיית הכל',
|
||||
|
||||
+337
-328
File diff suppressed because it is too large
Load Diff
@@ -338,6 +338,15 @@ const uk = {
|
||||
eta: 'Залишилось',
|
||||
dateAdded: 'Дата додавання',
|
||||
},
|
||||
columnOptions: {
|
||||
open: 'Параметри стовпця: {{column}}',
|
||||
dragToReorder: 'Перетягніть, щоб змінити порядок стовпців',
|
||||
alignContent: 'Позиція вмісту',
|
||||
left: 'Ліворуч',
|
||||
center: 'По центру',
|
||||
right: 'Праворуч',
|
||||
resetLayout: 'Скинути макет стовпців',
|
||||
},
|
||||
addDownload: 'Додати завантаження',
|
||||
resumeAll: 'Відновити всі',
|
||||
pauseAll: 'Призупинити всі',
|
||||
|
||||
@@ -338,6 +338,15 @@ const zhCN = {
|
||||
eta: '剩余时间',
|
||||
dateAdded: '添加日期',
|
||||
},
|
||||
columnOptions: {
|
||||
open: '{{column}}列选项',
|
||||
dragToReorder: '拖动以重新排列列',
|
||||
alignContent: '内容位置',
|
||||
left: '左',
|
||||
center: '居中',
|
||||
right: '右',
|
||||
resetLayout: '重置列布局',
|
||||
},
|
||||
addDownload: '添加下载',
|
||||
resumeAll: '全部继续',
|
||||
pauseAll: '全部暂停',
|
||||
|
||||
+230
-57
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user