client: allow multi-select of filters with Ctrl and Shift keys

This commit is contained in:
FinalDoom
2021-09-15 06:36:02 -06:00
committed by Jesse Chan
parent af8de75e05
commit 9361b2fa3d
8 changed files with 119 additions and 63 deletions

View File

@@ -1,5 +1,5 @@
import classnames from 'classnames'; import classnames from 'classnames';
import {FC, ReactNode} from 'react'; import {FC, ReactNode, KeyboardEvent, MouseEvent, TouchEvent} from 'react';
import {useLingui} from '@lingui/react'; import {useLingui} from '@lingui/react';
import Badge from '../general/Badge'; import Badge from '../general/Badge';
@@ -12,7 +12,7 @@ interface SidebarFilterProps {
slug: string; slug: string;
count: number; count: number;
size?: number; size?: number;
handleClick: (slug: string) => void; handleClick: (slug: string, event: KeyboardEvent | MouseEvent | TouchEvent) => void;
} }
const SidebarFilter: FC<SidebarFilterProps> = ({ const SidebarFilter: FC<SidebarFilterProps> = ({
@@ -57,7 +57,7 @@ const SidebarFilter: FC<SidebarFilterProps> = ({
}, },
}} }}
type="button" type="button"
onClick={() => handleClick(slug)} onClick={(event) => handleClick(slug, event)}
role="menuitem" role="menuitem"
> >
{icon} {icon}

View File

@@ -66,11 +66,14 @@ const StatusFilters: FC = observer(() => {
const filterElements = filters.map((filter) => ( const filterElements = filters.map((filter) => (
<SidebarFilter <SidebarFilter
handleClick={(selection) => TorrentFilterStore.setStatusFilter(selection as TorrentStatus)} handleClick={(selection, event) => TorrentFilterStore.setStatusFilters(selection as TorrentStatus, event)}
count={TorrentFilterStore.taxonomy.statusCounts[filter.slug] || 0} count={TorrentFilterStore.taxonomy.statusCounts[filter.slug] || 0}
key={filter.slug} key={filter.slug}
icon={filter.icon} 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} name={filter.label}
slug={filter.slug} slug={filter.slug}
/> />

View File

@@ -28,10 +28,13 @@ const TagFilters: FC = observer(() => {
const filterElements = filterItems.map((filter) => ( const filterElements = filterItems.map((filter) => (
<SidebarFilter <SidebarFilter
handleClick={(tag) => TorrentFilterStore.setTagFilter(tag)} handleClick={(tag, event) => TorrentFilterStore.setTagFilters(tag, event)}
count={TorrentFilterStore.taxonomy.tagCounts[filter] || 0} count={TorrentFilterStore.taxonomy.tagCounts[filter] || 0}
key={filter} key={filter}
isActive={filter === TorrentFilterStore.filters.tagFilter} isActive={
(filter === '' && !TorrentFilterStore.filters.tagFilter.length) ||
TorrentFilterStore.filters.tagFilter.includes(filter)
}
name={filter} name={filter}
slug={filter} slug={filter}
size={TorrentFilterStore.taxonomy.tagSizes[filter]} size={TorrentFilterStore.taxonomy.tagSizes[filter]}

View File

@@ -27,10 +27,13 @@ const TrackerFilters: FC = observer(() => {
const filterElements = filterItems.map((filter) => ( const filterElements = filterItems.map((filter) => (
<SidebarFilter <SidebarFilter
handleClick={(tracker) => TorrentFilterStore.setTrackerFilter(tracker)} handleClick={(tracker, event) => TorrentFilterStore.setTrackerFilters(tracker, event)}
count={TorrentFilterStore.taxonomy.trackerCounts[filter] || 0} count={TorrentFilterStore.taxonomy.trackerCounts[filter] || 0}
key={filter} key={filter}
isActive={filter === TorrentFilterStore.filters.trackerFilter} isActive={
(filter === '' && !TorrentFilterStore.filters.trackerFilter.length) ||
TorrentFilterStore.filters.trackerFilter.includes(filter)
}
name={filter} name={filter}
slug={filter} slug={filter}
size={TorrentFilterStore.taxonomy.trackerSizes[filter]} size={TorrentFilterStore.taxonomy.trackerSizes[filter]}

View File

@@ -1,20 +1,21 @@
import {computed, makeAutoObservable} from 'mobx'; import {computed, makeAutoObservable} from 'mobx';
import jsonpatch, {Operation} from 'fast-json-patch'; import jsonpatch, {Operation} from 'fast-json-patch';
import {KeyboardEvent, MouseEvent, TouchEvent} from 'react';
import type {Taxonomy} from '@shared/types/Taxonomy'; import type {Taxonomy} from '@shared/types/Taxonomy';
import type {TorrentStatus} from '@shared/constants/torrentStatusMap'; import torrentStatusMap, {TorrentStatus} from '@shared/constants/torrentStatusMap';
class TorrentFilterStore { class TorrentFilterStore {
filters: { filters: {
searchFilter: string; searchFilter: string;
statusFilter: TorrentStatus | ''; statusFilter: Array<TorrentStatus>;
tagFilter: string; tagFilter: Array<string>;
trackerFilter: string; trackerFilter: Array<string>;
} = { } = {
searchFilter: '', searchFilter: '',
statusFilter: '', statusFilter: [],
tagFilter: '', tagFilter: [],
trackerFilter: '', trackerFilter: [],
}; };
taxonomy: Taxonomy = { taxonomy: Taxonomy = {
@@ -28,9 +29,9 @@ class TorrentFilterStore {
@computed get isFilterActive() { @computed get isFilterActive() {
return ( return (
this.filters.searchFilter !== '' || this.filters.searchFilter !== '' ||
this.filters.statusFilter !== '' || this.filters.statusFilter.length ||
this.filters.tagFilter !== '' || this.filters.tagFilter.length ||
this.filters.trackerFilter !== '' this.filters.trackerFilter.length
); );
} }
@@ -41,9 +42,9 @@ class TorrentFilterStore {
clearAllFilters() { clearAllFilters() {
this.filters = { this.filters = {
searchFilter: '', searchFilter: '',
statusFilter: '', statusFilter: [],
tagFilter: '', tagFilter: [],
trackerFilter: '', trackerFilter: [],
}; };
} }
@@ -62,25 +63,73 @@ class TorrentFilterStore {
}; };
} }
setStatusFilter(filter: TorrentStatus) { setStatusFilters(filter: TorrentStatus | '', event: KeyboardEvent | MouseEvent | TouchEvent) {
this.filters = { this.computeFilters(torrentStatusMap, this.filters.statusFilter, filter, event);
...this.filters,
statusFilter: filter,
};
} }
setTagFilter(filter: string) { setTagFilters(filter: string, event: KeyboardEvent | MouseEvent | TouchEvent) {
this.filters = { const tags = Object.keys(this.taxonomy.tagCounts).sort((a, b) => {
...this.filters, if (a === 'untagged') return -1;
tagFilter: filter, 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) { setTrackerFilters(filter: string, event: KeyboardEvent | MouseEvent | TouchEvent) {
this.filters = { const trackers = Object.keys(this.taxonomy.trackerCounts).sort((a, b) => a.localeCompare(b));
...this.filters,
trackerFilter: filter, this.computeFilters(trackers, this.filters.trackerFilter, filter, event);
}; }
private computeFilters<T extends TorrentStatus | string>(
keys: readonly T[],
currentFilters: Array<T>,
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);
}
} }
} }

View File

@@ -32,21 +32,21 @@ class TorrentStore {
filteredTorrents = termMatch(filteredTorrents, (properties) => properties.name, searchFilter); filteredTorrents = termMatch(filteredTorrents, (properties) => properties.name, searchFilter);
} }
if (statusFilter !== '') { if (statusFilter.length) {
filteredTorrents = filterTorrents(filteredTorrents, { filteredTorrents = filterTorrents(filteredTorrents, {
type: 'status', type: 'status',
filter: statusFilter, filter: statusFilter,
}); });
} }
if (tagFilter !== '') { if (tagFilter.length) {
filteredTorrents = filterTorrents(filteredTorrents, { filteredTorrents = filterTorrents(filteredTorrents, {
type: 'tag', type: 'tag',
filter: tagFilter, filter: tagFilter,
}); });
} }
if (trackerFilter !== '') { if (trackerFilter.length) {
filteredTorrents = filterTorrents(filteredTorrents, { filteredTorrents = filterTorrents(filteredTorrents, {
type: 'tracker', type: 'tracker',
filter: trackerFilter, filter: trackerFilter,

View File

@@ -3,40 +3,38 @@ import type {TorrentStatus} from '@shared/constants/torrentStatusMap';
interface StatusFilter { interface StatusFilter {
type: 'status'; type: 'status';
filter: TorrentStatus; filter: TorrentStatus[];
} }
interface TrackerFilter { interface TrackerFilter {
type: 'tracker'; type: 'tracker';
filter: string; filter: string[];
} }
interface TagFilter { interface TagFilter {
type: 'tag'; type: 'tag';
filter: string; filter: string[];
} }
function filterTorrents( function filterTorrents(
torrentList: TorrentProperties[], torrentList: TorrentProperties[],
opts: StatusFilter | TrackerFilter | TagFilter, opts: StatusFilter | TrackerFilter | TagFilter,
): TorrentProperties[] { ): TorrentProperties[] {
const {type, filter} = opts; if (opts.filter.length) {
if (opts.type === 'status') {
if (filter !== '') { return torrentList.filter((torrent) => torrent.status.some((status) => opts.filter.includes(status)));
if (type === 'status') {
return torrentList.filter((torrent) => torrent.status.includes(filter as TorrentStatus));
} }
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)),
);
} }
} }

View File

@@ -1,12 +1,12 @@
const torrentStatusMap = [ const torrentStatusMap = [
'checking',
'seeding',
'complete',
'downloading', 'downloading',
'seeding',
'checking',
'complete',
'stopped', 'stopped',
'error',
'inactive',
'active', 'active',
'inactive',
'error',
] as const; ] as const;
export type TorrentStatus = typeof torrentStatusMap[number]; export type TorrentStatus = typeof torrentStatusMap[number];