mirror of
https://github.com/zoriya/flood.git
synced 2025-12-06 07:16:18 +00:00
client: allow multi-select of filters with Ctrl and Shift keys
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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]}
|
||||||
|
|||||||
@@ -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]}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
Reference in New Issue
Block a user