Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add filter by filesystem location + bonus tooltip on overflowed filter names #518

Merged
merged 11 commits into from
Jan 14, 2025
68 changes: 68 additions & 0 deletions client/src/javascript/components/sidebar/LocationFilters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import {FC, KeyboardEvent, MouseEvent, ReactNode, TouchEvent} from 'react';
import {observer} from 'mobx-react';
import {useLingui} from '@lingui/react';
import {LocationTreeNode} from '@shared/types/Taxonomy';

import SidebarFilter from './SidebarFilter';
import TorrentFilterStore from '../../stores/TorrentFilterStore';

const LocationFilters: FC = observer(() => {
const {i18n} = useLingui();

const locations = Object.keys(TorrentFilterStore.taxonomy.locationCounts);

if (locations.length === 1 && locations[0] === '') {
return null;
}

const buildLocationFilterTree = (location: LocationTreeNode): ReactNode => {
if (
location.children.length === 1 &&
TorrentFilterStore.taxonomy.locationCounts[location.fullPath] ===
TorrentFilterStore.taxonomy.locationCounts[location.children[0].fullPath]
) {
const onlyChild = location.children[0];
const separator = onlyChild.fullPath.includes('/') ? '/' : '\\';
return buildLocationFilterTree({
...onlyChild,
directoryName: location.directoryName + separator + onlyChild.directoryName,
});
}

const children = location.children.map(buildLocationFilterTree);

return (
<SidebarFilter
handleClick={(filter: string | '', event: KeyboardEvent | MouseEvent | TouchEvent) =>
TorrentFilterStore.setLocationFilters(filter, event)
}
count={TorrentFilterStore.taxonomy.locationCounts[location.fullPath] || 0}
key={location.fullPath}
isActive={
(location.fullPath === '' && !TorrentFilterStore.locationFilter.length) ||
TorrentFilterStore.locationFilter.includes(location.fullPath)
}
name={location.directoryName}
slug={location.fullPath}
size={TorrentFilterStore.taxonomy.locationSizes[location.fullPath]}
>
{(children.length && children) || undefined}
</SidebarFilter>
);
};

const filterElements = TorrentFilterStore.taxonomy.locationTree.map(buildLocationFilterTree);

const title = i18n._('filter.location.title');

return (
<ul aria-label={title} className="sidebar-filter sidebar__item" role="menu">
<li className="sidebar-filter__item sidebar-filter__item--heading" role="none">
{title}
</li>
{filterElements}
</ul>
);
});

export default LocationFilters;
2 changes: 2 additions & 0 deletions client/src/javascript/components/sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {OverlayScrollbarsComponent} from 'overlayscrollbars-react';
import DiskUsage from './DiskUsage';
import FeedsButton from './FeedsButton';
import LogoutButton from './LogoutButton';
import LocationFilters from './LocationFilters';
import NotificationsButton from './NotificationsButton';
import SearchBox from './SearchBox';
import SettingsButton from './SettingsButton';
Expand Down Expand Up @@ -44,6 +45,7 @@ const Sidebar: FC = () => (
<StatusFilters />
<TagFilters />
<TrackerFilters />
<LocationFilters />
<DiskUsage />
<div style={{flexGrow: 1}} />
<SidebarActions>
Expand Down
85 changes: 67 additions & 18 deletions client/src/javascript/components/sidebar/SidebarFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,26 @@
import classnames from 'classnames';
import {FC, ReactNode, KeyboardEvent, MouseEvent, TouchEvent} from 'react';
import {createRef, FC, ReactNode, KeyboardEvent, MouseEvent, RefObject, TouchEvent, useEffect, useState} from 'react';
import {useLingui} from '@lingui/react';
import {Start} from '@client/ui/icons';

import Badge from '../general/Badge';
import Size from '../general/Size';

const useRefTextOverflowed = (ref: RefObject<HTMLElement>) => {
const [overflowed, setOverflowed] = useState(false);

useEffect(() => {
if (ref.current) {
const {current} = ref;
setOverflowed(current.scrollWidth > current.clientWidth);
}
}, [ref, ref?.current?.scrollWidth, ref?.current?.clientWidth]);

return overflowed;
};

interface SidebarFilterProps {
children?: ReactNode[];
name: string;
icon?: ReactNode;
isActive: boolean;
Expand All @@ -16,6 +31,7 @@ interface SidebarFilterProps {
}

const SidebarFilter: FC<SidebarFilterProps> = ({
children,
name: _name,
icon,
isActive,
Expand All @@ -24,11 +40,32 @@ const SidebarFilter: FC<SidebarFilterProps> = ({
size,
handleClick,
}: SidebarFilterProps) => {
const nameSpanRef = createRef<HTMLSpanElement>();
const overflowed = useRefTextOverflowed(nameSpanRef);

const {i18n} = useLingui();

const [expanded, setExpanded] = useState(false);

const classNames = classnames('sidebar-filter__item', {
'is-active': isActive,
});
const expanderClassNames = classnames('sidebar-filter__expander', {
'is-active': isActive,
expanded: expanded,
});

const flexCss = children
? {
display: 'flex',
}
: {};
const focusCss = {
':focus': {
outline: 'none',
WebkitTapHighlightColor: 'transparent',
},
};

let name = _name;
if (name === '') {
Expand All @@ -48,23 +85,35 @@ const SidebarFilter: FC<SidebarFilterProps> = ({

return (
<li>
<button
className={classNames}
css={{
':focus': {
outline: 'none',
WebkitTapHighlightColor: 'transparent',
},
}}
type="button"
onClick={(event) => handleClick(slug, event)}
role="menuitem"
>
{icon}
<span className="name">{name}</span>
<Badge>{count}</Badge>
{size != null && <Size value={size} className="size" />}
</button>
<div css={flexCss}>
{children && (
<button
className={expanderClassNames}
css={focusCss}
type="button"
onClick={() => setExpanded(!expanded)}
role="switch"
aria-checked={expanded}
>
<Start />
</button>
)}
<button
className={classNames}
css={focusCss}
type="button"
onClick={(event) => handleClick(slug, event)}
role="menuitem"
>
{icon}
<span className="name" ref={nameSpanRef} title={overflowed ? name || '' : undefined}>
{name}
</span>
<Badge>{count}</Badge>
{size != null && <Size value={size} className="size" />}
</button>
</div>
{children && expanded && <ul className="sidebar-filter__nested">{children}</ul>}
</li>
);
};
Expand Down
1 change: 1 addition & 0 deletions client/src/javascript/i18n/strings/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@
"filter.status.seeding": "Seeding",
"filter.status.stopped": "Stopped",
"filter.status.title": "Filter by Status",
"filter.location.title": "Filter by Location",
"filter.tag.title": "Filter by Tag",
"filter.tracker.title": "Filter by Tracker",
"filter.untagged": "Untagged",
Expand Down
20 changes: 19 additions & 1 deletion client/src/javascript/stores/TorrentFilterStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {Taxonomy} from '@shared/types/Taxonomy';
import torrentStatusMap, {TorrentStatus} from '@shared/constants/torrentStatusMap';

class TorrentFilterStore {
locationFilter: Array<string> = [];
searchFilter = '';
statusFilter: Array<TorrentStatus> = [];
tagFilter: Array<string> = [];
Expand All @@ -14,6 +15,9 @@ class TorrentFilterStore {
filterTrigger = false;

taxonomy: Taxonomy = {
locationCounts: {},
locationSizes: {},
locationTree: [],
statusCounts: {},
tagCounts: {},
tagSizes: {},
Expand All @@ -22,14 +26,21 @@ class TorrentFilterStore {
};

@computed get isFilterActive() {
return this.searchFilter !== '' || this.statusFilter.length || this.tagFilter.length || this.trackerFilter.length;
return (
this.locationFilter.length ||
this.searchFilter !== '' ||
this.statusFilter.length ||
this.tagFilter.length ||
this.trackerFilter.length
);
}

constructor() {
makeAutoObservable(this);
}

clearAllFilters() {
this.locationFilter = [];
this.searchFilter = '';
this.statusFilter = [];
this.tagFilter = [];
Expand All @@ -50,6 +61,13 @@ class TorrentFilterStore {
this.filterTrigger = !this.filterTrigger;
}

setLocationFilters(filter: string | '', event: KeyboardEvent | MouseEvent | TouchEvent) {
const locations = Object.keys(this.taxonomy.locationCounts).sort((a, b) => a.localeCompare(b));

this.computeFilters(locations, this.locationFilter, filter, event);
this.filterTrigger = !this.filterTrigger;
}

setStatusFilters(filter: TorrentStatus | '', event: KeyboardEvent | MouseEvent | TouchEvent) {
this.computeFilters(torrentStatusMap, this.statusFilter, filter, event);
this.filterTrigger = !this.filterTrigger;
Expand Down
9 changes: 8 additions & 1 deletion client/src/javascript/stores/TorrentStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,17 @@ class TorrentStore {
}

@computed get filteredTorrents(): Array<TorrentProperties> {
const {searchFilter, statusFilter, tagFilter, trackerFilter} = TorrentFilterStore;
const {locationFilter, searchFilter, statusFilter, tagFilter, trackerFilter} = TorrentFilterStore;

let filteredTorrents = Object.assign([], this.sortedTorrents) as Array<TorrentProperties>;

if (locationFilter.length) {
filteredTorrents = filterTorrents(filteredTorrents, {
type: 'location',
filter: locationFilter,
});
}

if (searchFilter !== '') {
filteredTorrents = termMatch(filteredTorrents, (properties) => properties.name, searchFilter);
}
Expand Down
11 changes: 10 additions & 1 deletion client/src/javascript/util/filterTorrents.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import type {TorrentProperties} from '@shared/types/Torrent';
import type {TorrentStatus} from '@shared/constants/torrentStatusMap';

interface LocationFilter {
type: 'location';
filter: string[];
}

interface StatusFilter {
type: 'status';
filter: TorrentStatus[];
Expand All @@ -18,9 +23,13 @@ interface TagFilter {

function filterTorrents(
torrentList: TorrentProperties[],
opts: StatusFilter | TrackerFilter | TagFilter,
opts: LocationFilter | StatusFilter | TrackerFilter | TagFilter,
): TorrentProperties[] {
if (opts.filter.length) {
if (opts.type === 'location') {
return torrentList.filter((torrent) => opts.filter.some((directory) => torrent.directory.startsWith(directory)));
}

if (opts.type === 'status') {
return torrentList.filter((torrent) => torrent.status.some((status) => opts.filter.includes(status)));
}
Expand Down
16 changes: 16 additions & 0 deletions client/src/sass/components/_sidebar-filter.scss
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
padding-top: 0;
}

&__expander,
&__item {
@include themes.theme('color', 'sidebar-filter--foreground');
cursor: pointer;
Expand Down Expand Up @@ -75,9 +76,24 @@

.size {
margin-left: auto;
white-space: nowrap;
}
}

&__expander {
display: block;
width: 14px;
padding: 0 0 0 20px;

&.expanded svg {
transform: rotate(90deg);
}
}

&__nested {
margin-left: 8px;
}

.badge {
@include themes.theme('background', 'sidebar-filter--count--background');
@include themes.theme('color', 'sidebar-filter--count--foreground');
Expand Down
Loading
Loading