From 7524f9a6fa7385962dfc48e8c7c4d706f3019c1c Mon Sep 17 00:00:00 2001 From: himsin Date: Mon, 11 May 2026 10:35:02 +0530 Subject: [PATCH] Added ability to multi-select torrents. --- src/components/TorrentDetailsPanel.tsx | 6 +- src/components/TorrentList.tsx | 13 ++ src/components/TorrentRow.tsx | 9 +- src/mobile/MobileTorrentList.tsx | 267 ++++++++++++++++++++++++- 4 files changed, 283 insertions(+), 12 deletions(-) diff --git a/src/components/TorrentDetailsPanel.tsx b/src/components/TorrentDetailsPanel.tsx index bda0d72..3f3fc8e 100644 --- a/src/components/TorrentDetailsPanel.tsx +++ b/src/components/TorrentDetailsPanel.tsx @@ -37,6 +37,7 @@ interface Props { onToggle: () => void height: number onHeightChange: (h: number) => void + selectedCount?: number } type Tab = 'general' | 'trackers' | 'peers' | 'http' | 'content' @@ -767,7 +768,7 @@ function ContentTab({ hash }: { hash: string }) { return } -export function TorrentDetailsPanel({ hash, name, category, tags, expanded, onToggle, height, onHeightChange }: Props) { +export function TorrentDetailsPanel({ hash, name, category, tags, expanded, onToggle, height, onHeightChange, selectedCount = 0 }: Props) { const [tab, setTab] = useState('general') const [dragging, setDragging] = useState(false) const dragStartY = useRef(0) @@ -894,7 +895,7 @@ export function TorrentDetailsPanel({ hash, name, category, tags, expanded, onTo

- Select a torrent + {selectedCount > 1 ? `${selectedCount} torrents selected` : 'Select a torrent'}

@@ -906,3 +907,4 @@ export function TorrentDetailsPanel({ hash, name, category, tags, expanded, onTo } + diff --git a/src/components/TorrentList.tsx b/src/components/TorrentList.tsx index c340f04..5ac6228 100644 --- a/src/components/TorrentList.tsx +++ b/src/components/TorrentList.tsx @@ -418,6 +418,16 @@ export function TorrentList() { } } + function handleCheckboxToggle(hash: string) { + setSelected((prev) => { + const next = new Set(prev) + if (next.has(hash)) next.delete(hash) + else next.add(hash) + return next + }) + setLastSelected(hash) + } + useEffect(() => { function handleKeyDown(e: KeyboardEvent) { if (e.key === 'Escape') { @@ -741,6 +751,7 @@ export function TorrentList() { torrent={t} selected={selected.has(t.hash)} onSelect={handleSelect} + onCheckboxToggle={handleCheckboxToggle} onContextMenu={(e) => handleContextMenu(e, t)} ratioThreshold={ratioThreshold} hideAddedTime={hideAddedTime} @@ -839,6 +850,7 @@ export function TorrentList() { onToggle={() => setPanelExpanded(!panelExpanded)} height={panelHeight} onHeightChange={setPanelHeight} + selectedCount={selected.size} /> @@ -878,3 +890,4 @@ export function TorrentList() { ) } + diff --git a/src/components/TorrentRow.tsx b/src/components/TorrentRow.tsx index 3b52a36..72eee67 100644 --- a/src/components/TorrentRow.tsx +++ b/src/components/TorrentRow.tsx @@ -274,6 +274,7 @@ interface Props { torrent: Torrent selected: boolean onSelect: (hash: string, multi: boolean, range: boolean) => void + onCheckboxToggle: (hash: string) => void onContextMenu: (e: React.MouseEvent) => void ratioThreshold: number hideAddedTime: boolean @@ -287,6 +288,7 @@ export function TorrentRow({ torrent, selected, onSelect, + onCheckboxToggle, onContextMenu, ratioThreshold, hideAddedTime, @@ -321,7 +323,11 @@ export function TorrentRow({ >
{ + e.stopPropagation() + onCheckboxToggle(torrent.hash) + }} + className="shrink-0 w-4 h-4 rounded border transition-colors duration-150 flex items-center justify-center cursor-pointer" style={{ borderColor: selected ? 'var(--text-muted)' : 'var(--border)', backgroundColor: selected ? 'color-mix(in srgb, white 3%, transparent)' : 'transparent', @@ -353,3 +359,4 @@ export function TorrentRow({ ) } + diff --git a/src/mobile/MobileTorrentList.tsx b/src/mobile/MobileTorrentList.tsx index d2dcd8a..74dcdcb 100644 --- a/src/mobile/MobileTorrentList.tsx +++ b/src/mobile/MobileTorrentList.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useRef, useEffect } from 'react' +import { useState, useMemo, useRef, useEffect, useCallback } from 'react' import { ChevronDown, ArrowDown, @@ -10,6 +10,10 @@ import { LayoutGrid, List, Archive, + Square, + Trash2, + X, + Check, } from 'lucide-react' import { useQueries, useMutation, useQueryClient } from '@tanstack/react-query' import * as api from '../api/qbittorrent' @@ -172,6 +176,11 @@ export function MobileTorrentList({ instances, search, compact, onToggleCompact, const [status, setStatus] = useState('all') const [sortBy, setSortBy] = useState('added_on') const [swipedHash, setSwipedHash] = useState(null) + const [multiSelectMode, setMultiSelectMode] = useState(false) + const [selectedHashes, setSelectedHashes] = useState>(new Set()) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const longPressTimerRef = useRef | null>(null) + const longPressTriggeredRef = useRef(false) const queryClient = useQueryClient() const torrentQueries = useQueries({ @@ -206,6 +215,87 @@ export function MobileTorrentList({ instances, search, compact, onToggleCompact, onSuccess: (_, { instanceId }) => queryClient.invalidateQueries({ queryKey: ['torrents', instanceId] }), }) + const deleteMutation = useMutation({ + mutationFn: ({ instanceId, hashes, deleteFiles }: { instanceId: number; hashes: string[]; deleteFiles: boolean }) => + api.deleteTorrents(instanceId, hashes, deleteFiles), + onSuccess: (_, { instanceId }) => queryClient.invalidateQueries({ queryKey: ['torrents', instanceId] }), + }) + + const getSelectedTorrents = useCallback(() => { + return torrents.filter((t) => selectedHashes.has(t.hash)) + }, [torrents, selectedHashes]) + + const groupByInstance = useCallback((selected: TorrentWithInstance[]) => { + const groups = new Map() + for (const t of selected) { + const arr = groups.get(t.instanceId) || [] + arr.push(t.hash) + groups.set(t.instanceId, arr) + } + return groups + }, []) + + function handleBulkStart() { + const groups = groupByInstance(getSelectedTorrents()) + groups.forEach((hashes, instanceId) => startMutation.mutate({ instanceId, hashes })) + } + + function handleBulkStop() { + const groups = groupByInstance(getSelectedTorrents()) + groups.forEach((hashes, instanceId) => stopMutation.mutate({ instanceId, hashes })) + } + + function handleBulkDelete(deleteFiles: boolean) { + const groups = groupByInstance(getSelectedTorrents()) + groups.forEach((hashes, instanceId) => deleteMutation.mutate({ instanceId, hashes, deleteFiles })) + setSelectedHashes(new Set()) + setMultiSelectMode(false) + setShowDeleteConfirm(false) + } + + function exitMultiSelect() { + setMultiSelectMode(false) + setSelectedHashes(new Set()) + } + + function handleTorrentTap(torrent: TorrentWithInstance) { + if (multiSelectMode) { + setSelectedHashes((prev) => { + const next = new Set(prev) + if (next.has(torrent.hash)) next.delete(torrent.hash) + else next.add(torrent.hash) + if (next.size === 0) setMultiSelectMode(false) + return next + }) + } else { + onSelectTorrent(torrent.hash, torrent.instanceId) + } + } + + function handleLongPressStart(torrent: TorrentWithInstance) { + longPressTriggeredRef.current = false + longPressTimerRef.current = setTimeout(() => { + longPressTriggeredRef.current = true + setMultiSelectMode(true) + setSelectedHashes((prev) => new Set(prev).add(torrent.hash)) + if (navigator.vibrate) navigator.vibrate(50) + }, 500) + } + + function handleLongPressEnd() { + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current) + longPressTimerRef.current = null + } + } + + function handleLongPressMove() { + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current) + longPressTimerRef.current = null + } + } + const filteredTorrents = useMemo(() => { let result = torrents if (search) { @@ -294,14 +384,32 @@ export function MobileTorrentList({ instances, search, compact, onToggleCompact, const progress = Math.round(torrent.progress * 100) if (compact) { + const isSelected = selectedHashes.has(torrent.hash) return ( + + + {selectedHashes.size} selected + + +
+ + + + + + +
+ )} + + {showDeleteConfirm && ( +
setShowDeleteConfirm(false)} + > +
e.stopPropagation()} + > +

+ Delete {selectedHashes.size} torrent{selectedHashes.size > 1 ? 's' : ''}? +

+

+ This action cannot be undone. +

+
+ + + +
+
+
+ )}
) } +