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 {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<SidebarFilterProps> = ({
@@ -57,7 +57,7 @@ const SidebarFilter: FC<SidebarFilterProps> = ({
},
}}
type="button"
onClick={() => handleClick(slug)}
onClick={(event) => handleClick(slug, event)}
role="menuitem"
>
{icon}

View File

@@ -66,11 +66,14 @@ const StatusFilters: FC = observer(() => {
const filterElements = filters.map((filter) => (
<SidebarFilter
handleClick={(selection) => 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}
/>

View File

@@ -28,10 +28,13 @@ const TagFilters: FC = observer(() => {
const filterElements = filterItems.map((filter) => (
<SidebarFilter
handleClick={(tag) => 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]}

View File

@@ -27,10 +27,13 @@ const TrackerFilters: FC = observer(() => {
const filterElements = filterItems.map((filter) => (
<SidebarFilter
handleClick={(tracker) => 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]}

View File

@@ -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<TorrentStatus>;
tagFilter: Array<string>;
trackerFilter: Array<string>;
} = {
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<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);
}
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,

View File

@@ -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 (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;
if (opts.filter.length) {
if (opts.type === 'status') {
return torrentList.filter((torrent) => torrent.status.some((status) => opts.filter.includes(status)));
}
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 = [
'checking',
'seeding',
'complete',
'downloading',
'seeding',
'checking',
'complete',
'stopped',
'error',
'inactive',
'active',
'inactive',
'error',
] as const;
export type TorrentStatus = typeof torrentStatusMap[number];