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

@@ -13,6 +13,7 @@
"@mui/system": "^5.0.6", "@mui/system": "^5.0.6",
"axios": "^0.21.1", "axios": "^0.21.1",
"file-selector": "^0.2.4", "file-selector": "^0.2.4",
"query-string": "^7.0.1",
"react": "^17.0.2", "react": "^17.0.2",
"react-beautiful-dnd": "^13.0.0", "react-beautiful-dnd": "^13.0.0",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
@@ -20,6 +21,7 @@
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"react-scripts": "4.0.3", "react-scripts": "4.0.3",
"react-virtuoso": "^1.8.6", "react-virtuoso": "^1.8.6",
"use-query-params": "^1.2.3",
"web-vitals": "^2.1.0" "web-vitals": "^2.1.0"
}, },
"scripts": { "scripts": {

View File

@@ -11,6 +11,7 @@ import {
Route, Route,
Redirect, Redirect,
} from 'react-router-dom'; } from 'react-router-dom';
import { QueryParamProvider } from 'use-query-params';
import { Container, useMediaQuery } from '@mui/material'; import { Container, useMediaQuery } from '@mui/material';
import CssBaseline from '@mui/material/CssBaseline'; import CssBaseline from '@mui/material/CssBaseline';
import { import {
@@ -36,20 +37,32 @@ import Browse from 'screens/manga/Browse';
declare module '@mui/styles/defaultTheme' { declare module '@mui/styles/defaultTheme' {
// eslint-disable-next-line @typescript-eslint/no-empty-interface // eslint-disable-next-line @typescript-eslint/no-empty-interface
interface DefaultTheme extends Theme {} interface DefaultTheme extends Theme {
}
} }
export default function App() { export default function App() {
const [title, setTitle] = useState<string>('Tachidesk'); const [title, setTitle] = useState<string>('Tachidesk');
const [action, setAction] = useState<any>(<div />); 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 [darkTheme, setDarkTheme] = useLocalStorage<boolean>('darkTheme', true);
const navBarContext = { 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( const theme = React.useMemo(
() => createTheme({ () => createTheme({
@@ -82,6 +95,7 @@ export default function App() {
<Router> <Router>
<StyledEngineProvider injectFirst> <StyledEngineProvider injectFirst>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<QueryParamProvider ReactRouterRoute={Route}>
<NavbarContext.Provider value={navBarContext}> <NavbarContext.Provider value={navBarContext}>
<CssBaseline /> <CssBaseline />
<NavBar /> <NavBar />
@@ -106,7 +120,6 @@ export default function App() {
<Redirect to="/library" /> <Redirect to="/library" />
)} )}
/> />
<Route path="/settings/about"> <Route path="/settings/about">
<About /> <About />
</Route> </Route>
@@ -161,11 +174,16 @@ export default function App() {
path="/manga/:mangaId/chapter/:chapterIndex" path="/manga/:mangaId/chapter/:chapterIndex"
// passing a key re-mounts the reader when changing chapters // passing a key re-mounts the reader when changing chapters
render={ render={
(props:any) => <Reader key={props.match.params.chapterIndex} /> (props: any) => (
<Reader
key={props.match.params.chapterIndex}
/>
)
} }
/> />
</Switch> </Switch>
</NavbarContext.Provider> </NavbarContext.Provider>
</QueryParamProvider>
</ThemeProvider> </ThemeProvider>
</StyledEngineProvider> </StyledEngineProvider>
</Router> </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 LoadingPlaceholder from 'components/LoadingPlaceholder';
import MangaCard from './MangaCard'; import MangaCard from './MangaCard';
interface IProps{ export interface IMangaGridProps{
mangas: IMangaCard[] mangas: IMangaCard[]
isLoading: boolean isLoading: boolean
message?: string message?: string
@@ -21,7 +21,7 @@ interface IProps{
setLastPageNum: (lastPageNum: number) => void setLastPageNum: (lastPageNum: number) => void
} }
export default function MangaGrid(props: IProps) { export default function MangaGrid(props: IMangaGridProps) {
const { const {
mangas, isLoading, message, messageExtra, hasNextPage, lastPageNum, setLastPageNum, mangas, isLoading, message, messageExtra, hasNextPage, lastPageNum, setLastPageNum,
} = props; } = props;

View File

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

View File

@@ -5435,6 +5435,11 @@ fill-range@^7.0.1:
dependencies: dependencies:
to-regex-range "^5.0.1" to-regex-range "^5.0.1"
filter-obj@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/filter-obj/-/filter-obj-1.1.0.tgz#9b311112bc6c6127a16e016c6c5d7f19e0805c5b"
integrity sha1-mzERErxsYSehbgFsbF1/GeCAXFs=
finalhandler@~1.1.2: finalhandler@~1.1.2:
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d" resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d"
@@ -9478,6 +9483,16 @@ query-string@^4.1.0:
object-assign "^4.1.0" object-assign "^4.1.0"
strict-uri-encode "^1.0.0" strict-uri-encode "^1.0.0"
query-string@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/query-string/-/query-string-7.0.1.tgz#45bd149cf586aaa582dffc7ec7a8ad97dd02f75d"
integrity sha512-uIw3iRvHnk9to1blJCG3BTc+Ro56CBowJXKmNNAm3RulvPBzWLRqKSiiDk+IplJhsydwtuNMHi8UGQFcCLVfkA==
dependencies:
decode-uri-component "^0.2.0"
filter-obj "^1.1.0"
split-on-first "^1.0.0"
strict-uri-encode "^2.0.0"
querystring-es3@^0.2.0: querystring-es3@^0.2.0:
version "0.2.1" version "0.2.1"
resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73"
@@ -10361,6 +10376,11 @@ serialize-javascript@^5.0.1:
dependencies: dependencies:
randombytes "^2.1.0" randombytes "^2.1.0"
serialize-query-params@^1.3.5:
version "1.3.5"
resolved "https://registry.yarnpkg.com/serialize-query-params/-/serialize-query-params-1.3.5.tgz#ca6a6419219ead24cf3b55d7b4d6a4b716b70359"
integrity sha512-BrLH1RqgzVxm6dco+KP9S6BodeFiUVvKwtY3GSWQlupIdblT19KCGTRkHZ2yIU6Bjy0Prjts0tYe11VpTMbAeQ==
serve-index@^1.9.1: serve-index@^1.9.1:
version "1.9.1" version "1.9.1"
resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239" resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239"
@@ -10657,6 +10677,11 @@ spdy@^4.0.2:
select-hose "^2.0.0" select-hose "^2.0.0"
spdy-transport "^3.0.0" spdy-transport "^3.0.0"
split-on-first@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/split-on-first/-/split-on-first-1.1.0.tgz#f610afeee3b12bce1d0c30425e76398b78249a5f"
integrity sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==
split-string@^3.0.1, split-string@^3.0.2: split-string@^3.0.1, split-string@^3.0.2:
version "3.1.0" version "3.1.0"
resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2"
@@ -10750,6 +10775,11 @@ strict-uri-encode@^1.0.0:
resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713"
integrity sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM= integrity sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=
strict-uri-encode@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546"
integrity sha1-ucczDHBChi9rFC3CdLvMWGbONUY=
string-length@^4.0.1: string-length@^4.0.1:
version "4.0.2" version "4.0.2"
resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a"
@@ -11459,6 +11489,13 @@ use-memo-one@^1.1.1:
resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.2.tgz#0c8203a329f76e040047a35a1197defe342fab20" resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.2.tgz#0c8203a329f76e040047a35a1197defe342fab20"
integrity sha512-u2qFKtxLsia/r8qG0ZKkbytbztzRb317XCkT7yP8wxL0tZ/CzK2G+WWie5vWvpyeP7+YoPIwbJoIHJ4Ba4k0oQ== integrity sha512-u2qFKtxLsia/r8qG0ZKkbytbztzRb317XCkT7yP8wxL0tZ/CzK2G+WWie5vWvpyeP7+YoPIwbJoIHJ4Ba4k0oQ==
use-query-params@^1.2.3:
version "1.2.3"
resolved "https://registry.yarnpkg.com/use-query-params/-/use-query-params-1.2.3.tgz#306c31a0cbc714e8a3b4bd7e91a6a9aaccaa5e22"
integrity sha512-cdG0tgbzK+FzsV6DAt2CN8Saa3WpRnze7uC4Rdh7l15epSFq7egmcB/zuREvPNwO5Yk80nUpDZpiyHsoq50d8w==
dependencies:
serialize-query-params "^1.3.5"
use@^3.1.0: use@^3.1.0:
version "3.1.1" version "3.1.1"
resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"