diff --git a/client/src/javascript/components/sidebar/SidebarFilter.tsx b/client/src/javascript/components/sidebar/SidebarFilter.tsx index bf6c7ccb..35cb29a0 100644 --- a/client/src/javascript/components/sidebar/SidebarFilter.tsx +++ b/client/src/javascript/components/sidebar/SidebarFilter.tsx @@ -1,5 +1,5 @@ import classnames from 'classnames'; -import {FC, ReactNode} from 'react'; +import {FC, ReactNode, KeyboardEvent, MouseEvent, TouchEvent} from 'react'; import {useLingui} from '@lingui/react'; import Badge from '../general/Badge'; @@ -12,7 +12,7 @@ interface SidebarFilterProps { slug: string; count: number; size?: number; - handleClick: (slug: string) => void; + handleClick: (slug: string, event: KeyboardEvent | MouseEvent | TouchEvent) => void; } const SidebarFilter: FC = ({ @@ -57,7 +57,7 @@ const SidebarFilter: FC = ({ }, }} type="button" - onClick={() => handleClick(slug)} + onClick={(event) => handleClick(slug, event)} role="menuitem" > {icon} diff --git a/client/src/javascript/components/sidebar/StatusFilters.tsx b/client/src/javascript/components/sidebar/StatusFilters.tsx index cf97bca2..1d1f4ad1 100644 --- a/client/src/javascript/components/sidebar/StatusFilters.tsx +++ b/client/src/javascript/components/sidebar/StatusFilters.tsx @@ -66,11 +66,14 @@ const StatusFilters: FC = observer(() => { const filterElements = filters.map((filter) => ( TorrentFilterStore.setStatusFilter(selection as TorrentStatus)} + handleClick={(selection, event) => TorrentFilterStore.setStatusFilters(selection as TorrentStatus, event)} count={TorrentFilterStore.taxonomy.statusCounts[filter.slug] || 0} key={filter.slug} icon={filter.icon} - isActive={filter.slug === TorrentFilterStore.filters.statusFilter} + isActive={ + (filter.slug === '' && !TorrentFilterStore.filters.statusFilter.length) || + TorrentFilterStore.filters.statusFilter.includes(filter.slug as TorrentStatus) + } name={filter.label} slug={filter.slug} /> diff --git a/client/src/javascript/components/sidebar/TagFilters.tsx b/client/src/javascript/components/sidebar/TagFilters.tsx index 9a359991..754eeb80 100644 --- a/client/src/javascript/components/sidebar/TagFilters.tsx +++ b/client/src/javascript/components/sidebar/TagFilters.tsx @@ -28,10 +28,13 @@ const TagFilters: FC = observer(() => { const filterElements = filterItems.map((filter) => ( TorrentFilterStore.setTagFilter(tag)} + handleClick={(tag, event) => TorrentFilterStore.setTagFilters(tag, event)} count={TorrentFilterStore.taxonomy.tagCounts[filter] || 0} key={filter} - isActive={filter === TorrentFilterStore.filters.tagFilter} + isActive={ + (filter === '' && !TorrentFilterStore.filters.tagFilter.length) || + TorrentFilterStore.filters.tagFilter.includes(filter) + } name={filter} slug={filter} size={TorrentFilterStore.taxonomy.tagSizes[filter]} diff --git a/client/src/javascript/components/sidebar/TrackerFilters.tsx b/client/src/javascript/components/sidebar/TrackerFilters.tsx index 5c5b46e5..7f8e895e 100644 --- a/client/src/javascript/components/sidebar/TrackerFilters.tsx +++ b/client/src/javascript/components/sidebar/TrackerFilters.tsx @@ -27,10 +27,13 @@ const TrackerFilters: FC = observer(() => { const filterElements = filterItems.map((filter) => ( TorrentFilterStore.setTrackerFilter(tracker)} + handleClick={(tracker, event) => TorrentFilterStore.setTrackerFilters(tracker, event)} count={TorrentFilterStore.taxonomy.trackerCounts[filter] || 0} key={filter} - isActive={filter === TorrentFilterStore.filters.trackerFilter} + isActive={ + (filter === '' && !TorrentFilterStore.filters.trackerFilter.length) || + TorrentFilterStore.filters.trackerFilter.includes(filter) + } name={filter} slug={filter} size={TorrentFilterStore.taxonomy.trackerSizes[filter]} diff --git a/client/src/javascript/stores/TorrentFilterStore.ts b/client/src/javascript/stores/TorrentFilterStore.ts index 6951cbbe..c7b654aa 100644 --- a/client/src/javascript/stores/TorrentFilterStore.ts +++ b/client/src/javascript/stores/TorrentFilterStore.ts @@ -1,20 +1,21 @@ import {computed, makeAutoObservable} from 'mobx'; import jsonpatch, {Operation} from 'fast-json-patch'; +import {KeyboardEvent, MouseEvent, TouchEvent} from 'react'; import type {Taxonomy} from '@shared/types/Taxonomy'; -import type {TorrentStatus} from '@shared/constants/torrentStatusMap'; +import torrentStatusMap, {TorrentStatus} from '@shared/constants/torrentStatusMap'; class TorrentFilterStore { filters: { searchFilter: string; - statusFilter: TorrentStatus | ''; - tagFilter: string; - trackerFilter: string; + statusFilter: Array; + tagFilter: Array; + trackerFilter: Array; } = { searchFilter: '', - statusFilter: '', - tagFilter: '', - trackerFilter: '', + statusFilter: [], + tagFilter: [], + trackerFilter: [], }; taxonomy: Taxonomy = { @@ -28,9 +29,9 @@ class TorrentFilterStore { @computed get isFilterActive() { return ( this.filters.searchFilter !== '' || - this.filters.statusFilter !== '' || - this.filters.tagFilter !== '' || - this.filters.trackerFilter !== '' + this.filters.statusFilter.length || + this.filters.tagFilter.length || + this.filters.trackerFilter.length ); } @@ -41,9 +42,9 @@ class TorrentFilterStore { clearAllFilters() { this.filters = { searchFilter: '', - statusFilter: '', - tagFilter: '', - trackerFilter: '', + statusFilter: [], + tagFilter: [], + trackerFilter: [], }; } @@ -62,25 +63,73 @@ class TorrentFilterStore { }; } - setStatusFilter(filter: TorrentStatus) { - this.filters = { - ...this.filters, - statusFilter: filter, - }; + setStatusFilters(filter: TorrentStatus | '', event: KeyboardEvent | MouseEvent | TouchEvent) { + this.computeFilters(torrentStatusMap, this.filters.statusFilter, filter, event); } - setTagFilter(filter: string) { - this.filters = { - ...this.filters, - tagFilter: filter, - }; + setTagFilters(filter: string, event: KeyboardEvent | MouseEvent | TouchEvent) { + const tags = Object.keys(this.taxonomy.tagCounts).sort((a, b) => { + if (a === 'untagged') return -1; + else if (b === 'untagged') return 1; + else return a.localeCompare(b); + }); + + // Put 'untagged' in the correct second position for shift click ordering + tags.splice(tags.indexOf('untagged'), 1); + tags.splice(1, 0, 'untagged'); + + this.computeFilters(tags, this.filters.tagFilter, filter, event); } - setTrackerFilter(filter: string) { - this.filters = { - ...this.filters, - trackerFilter: filter, - }; + setTrackerFilters(filter: string, event: KeyboardEvent | MouseEvent | TouchEvent) { + const trackers = Object.keys(this.taxonomy.trackerCounts).sort((a, b) => a.localeCompare(b)); + + this.computeFilters(trackers, this.filters.trackerFilter, filter, event); + } + + private computeFilters( + keys: readonly T[], + currentFilters: Array, + newFilter: T, + event: KeyboardEvent | MouseEvent | TouchEvent, + ) { + if (newFilter === ('' as T)) { + currentFilters.splice(0); + } else if (event.shiftKey) { + if (currentFilters.length) { + const lastKey = currentFilters[currentFilters.length - 1]; + const lastKeyIndex = keys.indexOf(lastKey); + let currentKeyIndex = keys.indexOf(newFilter); + + if (!~currentKeyIndex || !~lastKeyIndex) { + return; + } + + // from the previously selected index to the currently selected index, + // add all filters to the selected array. + // if the newly selcted index is larger than the previous, start from + // the newly selected index and work backwards. otherwise go forwards. + const increment = currentKeyIndex > lastKeyIndex ? -1 : 1; + + for (; currentKeyIndex !== lastKeyIndex; currentKeyIndex += increment) { + const foundKey = keys[currentKeyIndex] as T; + // if the filter isn't already selected, add the filter to the array. + if (!currentFilters.includes(foundKey)) { + currentFilters.push(foundKey); + } + } + } else { + currentFilters.splice(0, currentFilters.length, newFilter); + } + } else if (event.metaKey || event.ctrlKey) { + if (currentFilters.includes(newFilter)) { + currentFilters.splice(currentFilters.indexOf(newFilter), 1); + } else { + currentFilters.push(newFilter); + } + } else { + currentFilters.splice(0, currentFilters.length, newFilter); + } } } diff --git a/client/src/javascript/stores/TorrentStore.ts b/client/src/javascript/stores/TorrentStore.ts index 92491004..9b1a79b1 100644 --- a/client/src/javascript/stores/TorrentStore.ts +++ b/client/src/javascript/stores/TorrentStore.ts @@ -32,21 +32,21 @@ class TorrentStore { filteredTorrents = termMatch(filteredTorrents, (properties) => properties.name, searchFilter); } - if (statusFilter !== '') { + if (statusFilter.length) { filteredTorrents = filterTorrents(filteredTorrents, { type: 'status', filter: statusFilter, }); } - if (tagFilter !== '') { + if (tagFilter.length) { filteredTorrents = filterTorrents(filteredTorrents, { type: 'tag', filter: tagFilter, }); } - if (trackerFilter !== '') { + if (trackerFilter.length) { filteredTorrents = filterTorrents(filteredTorrents, { type: 'tracker', filter: trackerFilter, diff --git a/client/src/javascript/util/filterTorrents.ts b/client/src/javascript/util/filterTorrents.ts index af990cce..b3d95f5d 100644 --- a/client/src/javascript/util/filterTorrents.ts +++ b/client/src/javascript/util/filterTorrents.ts @@ -3,40 +3,38 @@ import type {TorrentStatus} from '@shared/constants/torrentStatusMap'; interface StatusFilter { type: 'status'; - filter: TorrentStatus; + filter: TorrentStatus[]; } interface TrackerFilter { type: 'tracker'; - filter: string; + filter: string[]; } interface TagFilter { type: 'tag'; - filter: string; + filter: string[]; } function filterTorrents( torrentList: TorrentProperties[], opts: StatusFilter | TrackerFilter | TagFilter, ): TorrentProperties[] { - const {type, filter} = opts; - - if (filter !== '') { - if (type === 'status') { - return torrentList.filter((torrent) => torrent.status.includes(filter as TorrentStatus)); + if (opts.filter.length) { + if (opts.type === 'status') { + return torrentList.filter((torrent) => torrent.status.some((status) => opts.filter.includes(status))); } - if (type === 'tracker') { - return torrentList.filter((torrent) => torrent.trackerURIs.includes(filter)); - } - if (type === 'tag') { - return torrentList.filter((torrent) => { - if (filter === 'untagged') { - return torrent.tags.length === 0; - } - return torrent.tags.includes(filter); - }); + if (opts.type === 'tracker') { + return torrentList.filter((torrent) => torrent.trackerURIs.some((uri) => opts.filter.includes(uri))); + } + + if (opts.type === 'tag') { + const includeUntagged = opts.filter.includes('untagged'); + return torrentList.filter( + (torrent) => + (includeUntagged && torrent.tags.length === 0) || torrent.tags.some((tag) => opts.filter.includes(tag)), + ); } } diff --git a/shared/constants/torrentStatusMap.ts b/shared/constants/torrentStatusMap.ts index df1e6b05..fb4c9479 100644 --- a/shared/constants/torrentStatusMap.ts +++ b/shared/constants/torrentStatusMap.ts @@ -1,12 +1,12 @@ const torrentStatusMap = [ - 'checking', - 'seeding', - 'complete', 'downloading', + 'seeding', + 'checking', + 'complete', 'stopped', - 'error', - 'inactive', 'active', + 'inactive', + 'error', ] as const; export type TorrentStatus = typeof torrentStatusMap[number];