diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index 8ead9e4..9e385ca 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -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; tableGridTemplate: string; tableMinWidth: number; setContextMenu: (menu: { x: number; y: number; id: string }) => void; @@ -31,6 +38,8 @@ export const DownloadItem = React.memo(({ index, queueIndex, queueLength, + columnOrder, + columnAlignments, tableGridTemplate, tableMinWidth, setContextMenu, @@ -83,35 +92,34 @@ export const DownloadItem = React.memo(({ unit: sizeDisplay.unit ?? '', }); - return ( -
onClick(e, download)} - onContextMenu={(e) => { - e.preventDefault(); - setContextMenu({ x: e.clientX, y: e.clientY, id: download.id }); - }} - > -
- - {getCategoryIcon(download.category)} - - - {download.fileName} - -
- + const columnStyle = (key: DownloadTableColumnKey): React.CSSProperties => ({ + '--column-justify': COLUMN_ALIGNMENT_JUSTIFY[columnAlignments[key]], + } as React.CSSProperties); + + const cells: Record = { + 'File Name': (
-
+
+ + {getCategoryIcon(download.category)} + + + {download.fileName} + +
+
+ ), + Size: ( +
+
{hasDownloadedAmount ? ( {sizeDisplay.downloaded} @@ -125,16 +133,22 @@ export const DownloadItem = React.memo(({
- -
+ ), + Status: ( +
{download.status === 'completed' ? ( - {downloadStatusLabel} + + {downloadStatusLabel} + ) : ( - <> +
(({ />
(({ downloadStatusLabel )} - +
)}
- -
- + ), + Speed: ( +
+ {displaySpeed}
- -
- + ), + ETA: ( +
+ {displayEta}
- -
+ ), + 'Date Added': ( +
{download.dateAdded ? new Date(download.dateAdded).toLocaleDateString() : '-'} - -
e.stopPropagation()} - onDoubleClick={(e) => e.stopPropagation()} - > - {(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 && ( - <> - - - - )} - {canPauseDownload(download.status) && ( - - )} - {canStartDownload(download.status) && ( - - )} - -
+ ), + }; + + const rowActions = ( +
e.stopPropagation()} + onDoubleClick={(e) => e.stopPropagation()} + > + {(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 && ( + <> + + + + )} + {canPauseDownload(download.status) && ( + + )} + {canStartDownload(download.status) && ( + + )} + +
+ ); + + return ( +
onClick(e, download)} + onContextMenu={(e) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY, id: download.id }); + }} + > + {columnOrder.map(columnKey => ( + {cells[columnKey]} + ))} + {rowActions}
); }); diff --git a/src/components/DownloadTable.tsx b/src/components/DownloadTable.tsx index a172cfb..f7dbeb7 100644 --- a/src/components/DownloadTable.tsx +++ b/src/components/DownloadTable.tsx @@ -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): 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 = ({ filter }) => { const { t } = useTranslation(); const { downloads, queues, assignToQueue, openDeleteModal, redownload, moveInQueue } = useDownloadStore(); @@ -75,6 +113,17 @@ export const DownloadTable: React.FC = ({ filter }) => { const [sortConfig, setSortConfig] = useState({ column: 'Date Added', direction: 'desc' }); const [queueSortConfig, setQueueSortConfig] = useState(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()); + const columnDragStateRef = useRef(null); + const columnDropFlashTimerRef = useRef(null); + const suppressHeaderClickRef = useRef(false); const selectedIdsRef = useRef(selectedIds); const lastSelectedIdRef = useRef(lastSelectedId); const sortedDownloadsRef = useRef([]); @@ -85,60 +134,237 @@ export const DownloadTable: React.FC = ({ 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(() => { + 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(null); + const [columnMenu, setColumnMenu] = useState(null); + const [columnDropFlashKey, setColumnDropFlashKey] = useState(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) => { + 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) => { + 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) => { + 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, 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) => { 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 = ({ 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 = ({ filter }) => { setSelectedIds(new Set([menu.id])); setLastSelectedId(menu.id); } + setColumnMenu(null); setContextMenu(menu); }, []); @@ -513,6 +760,19 @@ export const DownloadTable: React.FC = ({ 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 (
= ({ filter }) => {
-
- {[ - { 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) => ( -
handleSort(key)} - > -
- {label} - {(isQueueFilter ? queueSortConfig : sortConfig)?.column === key && ( - (isQueueFilter ? queueSortConfig : sortConfig)?.direction === 'asc' - ? - : - )} -
+
+ {orderedColumns.map(key => { + const label = columnLabels.get(key) ?? key; + const activeSort = isQueueFilter ? queueSortConfig : sortConfig; + const isDragging = columnDragState?.key === key; + const isDropFlashing = columnDropFlashKey === key; + return (
startColumnResize(index, event)} - /> -
- ))} + 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)} + > + + + + ); + })}
+ {columnDragState && ( + <> +