mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-07 09:53:17 +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>
|
||||
|
||||
Reference in New Issue
Block a user