Implement Unread Filter for Library (#54)

This commit is contained in:
Sascha Hahne
2021-10-29 20:11:23 +02:00
committed by GitHub
parent 88fb4b64b6
commit e9bdc95060
9 changed files with 378 additions and 90 deletions

View File

@@ -11,6 +11,7 @@ import {
Route,
Redirect,
} from 'react-router-dom';
import { QueryParamProvider } from 'use-query-params';
import { Container, useMediaQuery } from '@mui/material';
import CssBaseline from '@mui/material/CssBaseline';
import {
@@ -36,20 +37,32 @@ import Browse from 'screens/manga/Browse';
declare module '@mui/styles/defaultTheme' {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface DefaultTheme extends Theme {}
interface DefaultTheme extends Theme {
}
}
export default function App() {
const [title, setTitle] = useState<string>('Tachidesk');
const [action, setAction] = useState<any>(<div />);
const [override, setOverride] = useState<INavbarOverride>({ status: false, value: <div /> });
const [override, setOverride] = useState<INavbarOverride>({
status: false,
value: <div />,
});
const [darkTheme, setDarkTheme] = useLocalStorage<boolean>('darkTheme', true);
const navBarContext = {
title, setTitle, action, setAction, override, setOverride,
title,
setTitle,
action,
setAction,
override,
setOverride,
};
const darkThemeContext = {
darkTheme,
setDarkTheme,
};
const darkThemeContext = { darkTheme, setDarkTheme };
const theme = React.useMemo(
() => createTheme({
@@ -82,90 +95,95 @@ export default function App() {
<Router>
<StyledEngineProvider injectFirst>
<ThemeProvider theme={theme}>
<NavbarContext.Provider value={navBarContext}>
<CssBaseline />
<NavBar />
<Container
id="appMainContainer"
maxWidth={false}
disableGutters
style={{
marginTop: theme.spacing(8),
marginLeft: isMobileWidth ? '' : theme.spacing(8),
marginBottom: isMobileWidth ? theme.spacing(8) : '',
width: 'auto',
overflow: 'auto',
}}
>
<QueryParamProvider ReactRouterRoute={Route}>
<NavbarContext.Provider value={navBarContext}>
<CssBaseline />
<NavBar />
<Container
id="appMainContainer"
maxWidth={false}
disableGutters
style={{
marginTop: theme.spacing(8),
marginLeft: isMobileWidth ? '' : theme.spacing(8),
marginBottom: isMobileWidth ? theme.spacing(8) : '',
width: 'auto',
overflow: 'auto',
}}
>
<Switch>
{/* General Routes */}
<Route
exact
path="/"
render={() => (
<Redirect to="/library" />
)}
/>
<Route path="/settings/about">
<About />
</Route>
<Route path="/settings/categories">
<Categories />
</Route>
<Route path="/settings/backup">
<Backup />
</Route>
<Route path="/settings">
<DarkTheme.Provider value={darkThemeContext}>
<Settings />
</DarkTheme.Provider>
</Route>
{/* Manga Routes */}
<Route path="/sources/:sourceId/search/">
<SearchSingle />
</Route>
<Route path="/sources/:sourceId/popular/">
<SourceMangas popular />
</Route>
<Route path="/sources/:sourceId/latest/">
<SourceMangas popular={false} />
</Route>
<Route path="/sources/:sourceId/configure/">
<SourceConfigure />
</Route>
<Route path="/downloads">
<DownloadQueue />
</Route>
<Route path="/manga/:mangaId/chapter/:chapterNum">
<></>
</Route>
<Route path="/manga/:id">
<Manga />
</Route>
<Route path="/library">
<Library />
</Route>
<Route path="/updates">
<Updates />
</Route>
<Route path="/browse">
<Browse />
</Route>
</Switch>
</Container>
<Switch>
{/* General Routes */}
<Route
exact
path="/"
render={() => (
<Redirect to="/library" />
)}
path="/manga/:mangaId/chapter/:chapterIndex"
// passing a key re-mounts the reader when changing chapters
render={
(props: any) => (
<Reader
key={props.match.params.chapterIndex}
/>
)
}
/>
<Route path="/settings/about">
<About />
</Route>
<Route path="/settings/categories">
<Categories />
</Route>
<Route path="/settings/backup">
<Backup />
</Route>
<Route path="/settings">
<DarkTheme.Provider value={darkThemeContext}>
<Settings />
</DarkTheme.Provider>
</Route>
{/* Manga Routes */}
<Route path="/sources/:sourceId/search/">
<SearchSingle />
</Route>
<Route path="/sources/:sourceId/popular/">
<SourceMangas popular />
</Route>
<Route path="/sources/:sourceId/latest/">
<SourceMangas popular={false} />
</Route>
<Route path="/sources/:sourceId/configure/">
<SourceConfigure />
</Route>
<Route path="/downloads">
<DownloadQueue />
</Route>
<Route path="/manga/:mangaId/chapter/:chapterNum">
<></>
</Route>
<Route path="/manga/:id">
<Manga />
</Route>
<Route path="/library">
<Library />
</Route>
<Route path="/updates">
<Updates />
</Route>
<Route path="/browse">
<Browse />
</Route>
</Switch>
</Container>
<Switch>
<Route
path="/manga/:mangaId/chapter/:chapterIndex"
// passing a key re-mounts the reader when changing chapters
render={
(props:any) => <Reader key={props.match.params.chapterIndex} />
}
/>
</Switch>
</NavbarContext.Provider>
</NavbarContext.Provider>
</QueryParamProvider>
</ThemeProvider>
</StyledEngineProvider>
</Router>

View File

@@ -0,0 +1,90 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { Checkbox, createSvgIcon } from '@mui/material';
import React, {
useEffect, useState,
} from 'react';
export interface IThreeStateCheckboxProps {
name: string
checked: boolean | undefined | null
onChange: (change: boolean | undefined | null) => void
}
enum CheckState {
SELECTED, INTERMEDIATE, UNSELECTED,
}
function checkedToState(checked: boolean | undefined | null): CheckState {
switch (checked) {
case true:
return CheckState.SELECTED;
case false:
return CheckState.INTERMEDIATE;
default:
return CheckState.UNSELECTED;
}
}
function stateToChecked(state: CheckState): boolean | undefined {
switch (state) {
case CheckState.SELECTED:
return true;
case CheckState.INTERMEDIATE:
return false;
default:
case CheckState.UNSELECTED:
return undefined;
}
}
function stateTransition(state: CheckState): CheckState {
switch (state) {
case CheckState.SELECTED:
return CheckState.INTERMEDIATE;
case CheckState.INTERMEDIATE:
return CheckState.UNSELECTED;
case CheckState.UNSELECTED:
default:
return CheckState.SELECTED;
}
}
const ThreeStateCheckbox = (props: IThreeStateCheckboxProps) => {
const {
name, checked, onChange,
} = props;
const [localChecked, setLocalChecked] = useState(checkedToState(checked));
useEffect(() => setLocalChecked(checkedToState(checked)), [checked]);
const handleChange = () => {
setLocalChecked(stateTransition(localChecked));
if (onChange) {
onChange(stateToChecked(stateTransition(localChecked)));
}
};
const CancelBox = createSvgIcon(
<>
<path
d="M 19 6.41 L 13.41 12 L 19 17.59 L 17.59 19 L 12 13.41 L 6.41 19 V 19 H 6.41 L 5 17.59 L 11 12 L 5 6.41 L 6.41 5 L 12 10.59 L 17.59 5 L 19 6.41 M 5 5 m 0 -2 H 5 c -1.1 0 -2 0.9 -2 2 v 14 c 0 1.1 0.9 2 2 2 h 14 c 1.1 0 2 -0.9 2 -2 V 5 c 0 -1.1 -0.9 -2 -2 -2 z "
/>
</>,
'CancelBox',
);
return (
<Checkbox
name={name}
checked={localChecked === CheckState.SELECTED}
indeterminate={localChecked === CheckState.INTERMEDIATE}
indeterminateIcon={<CancelBox />}
onChange={handleChange}
className={`${localChecked}`}
/>
);
};
export default ThreeStateCheckbox;

View File

@@ -0,0 +1,51 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import React from 'react';
import MangaGrid, { IMangaGridProps } from '../manga/MangaGrid';
import useLibraryOptions, { NullAndUndefined } from '../../util/useLibraryOptions';
const FILTERED_OUT_MESSAGE = 'There are no Manga matching this filter';
function unreadFilter(unread: NullAndUndefined<boolean>, { unreadCount }: IMangaCard): boolean {
switch (unread) {
case true:
return !!unreadCount && unreadCount >= 1;
case false:
return unreadCount === 0;
default:
return true;
}
}
function filterManga(mangas: IMangaCard[]): IMangaCard[] {
const { unread } = useLibraryOptions();
return mangas
.filter((manga) => unreadFilter(unread, manga));
}
export default function LibraryMangaGrid(props: IMangaGridProps) {
const {
mangas, isLoading, hasNextPage, lastPageNum, setLastPageNum, message,
} = props;
const { active } = useLibraryOptions();
const filteredManga = filterManga(mangas);
const showFilteredOutMessage = active && filteredManga.length === 0 && mangas.length > 0;
return (
<MangaGrid
mangas={filteredManga}
isLoading={isLoading}
hasNextPage={hasNextPage}
lastPageNum={lastPageNum}
setLastPageNum={setLastPageNum}
message={showFilteredOutMessage ? FILTERED_OUT_MESSAGE : message}
/>
);
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import React from 'react';
import FilterListIcon from '@mui/icons-material/FilterList';
import { Drawer, FormControlLabel, IconButton } from '@mui/material';
import useLibraryOptions from '../../util/useLibraryOptions';
import ThreeStateCheckbox from '../ThreeStateCheckbox';
function Options() {
const { unread, setUnread } = useLibraryOptions();
return (
<div>
<FormControlLabel control={<ThreeStateCheckbox name="Unread" checked={unread} onChange={setUnread} />} label="Unread" />
</div>
);
}
export default function LibraryOptions() {
const [filtersOpen, setFiltersOpen] = React.useState(false);
const { active } = useLibraryOptions();
return (
<>
<IconButton
onClick={() => setFiltersOpen(!filtersOpen)}
color={active ? 'warning' : 'default'}
>
<FilterListIcon />
</IconButton>
<Drawer
anchor="bottom"
open={filtersOpen}
onClose={() => setFiltersOpen(false)}
PaperProps={{
style: {
maxWidth: 600, padding: '1em', marginLeft: 'auto', marginRight: 'auto',
},
}}
>
<Options />
</Drawer>
</>
);
}

View File

@@ -11,7 +11,7 @@ import EmptyView from 'components/EmptyView';
import LoadingPlaceholder from 'components/LoadingPlaceholder';
import MangaCard from './MangaCard';
interface IProps{
export interface IMangaGridProps{
mangas: IMangaCard[]
isLoading: boolean
message?: string
@@ -21,7 +21,7 @@ interface IProps{
setLastPageNum: (lastPageNum: number) => void
}
export default function MangaGrid(props: IProps) {
export default function MangaGrid(props: IMangaGridProps) {
const {
mangas, isLoading, message, messageExtra, hasNextPage, lastPageNum, setLastPageNum,
} = props;

View File

@@ -7,13 +7,14 @@
import { Tab, Tabs } from '@mui/material';
import React, { useContext, useEffect, useState } from 'react';
import MangaGrid from 'components/manga/MangaGrid';
import NavbarContext from 'context/NavbarContext';
import client from 'util/client';
import cloneObject from 'util/cloneObject';
import EmptyView from 'components/EmptyView';
import LoadingPlaceholder from 'components/LoadingPlaceholder';
import TabPanel from 'components/util/TabPanel';
import LibraryOptions from '../../components/library/LibraryOptions';
import LibraryMangaGrid from '../../components/library/LibraryMangaGrid';
interface IMangaCategory {
category: ICategory
@@ -23,7 +24,13 @@ interface IMangaCategory {
export default function Library() {
const { setTitle, setAction } = useContext(NavbarContext);
useEffect(() => { setTitle('Library'); setAction(<></>); }, []);
useEffect(() => {
setTitle('Library'); setAction(
<>
<LibraryOptions />
</>,
);
}, []);
const [tabs, setTabs] = useState<IMangaCategory[]>();
const [tabNum, setTabNum] = useState<number>(0);
@@ -88,7 +95,7 @@ export default function Library() {
const tabBodies = tabs.map((tab) => (
<TabPanel index={tab.category.order} currentIndex={tabNum}>
<MangaGrid
<LibraryMangaGrid
mangas={tab.mangas}
hasNextPage={false}
lastPageNum={lastPageNum}
@@ -122,7 +129,7 @@ export default function Library() {
} else {
const mangas = tabs.length === 1 ? tabs[0].mangas : [];
toRender = (
<MangaGrid
<LibraryMangaGrid
mangas={mangas}
hasNextPage={false}
lastPageNum={lastPageNum}

View File

@@ -0,0 +1,32 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { BooleanParam, useQueryParams } from 'use-query-params';
export type NullAndUndefined<T> = T | null | undefined;
interface IUseLibraryOptions {
unread: NullAndUndefined<boolean>
setUnread: (unread: NullAndUndefined<boolean>) => void
active: boolean
}
export default function useLibraryOptions(): IUseLibraryOptions {
const [query, setQuery] = useQueryParams({
unread: BooleanParam,
});
const { unread } = query;
const setUnread = (newUnread: NullAndUndefined<boolean>) => {
setQuery(Object.assign(query, { unread: newUnread }), 'replace');
};
// eslint-disable-next-line eqeqeq
const active = !(unread == undefined);
return {
unread, setUnread, active,
};
}