Implement Unread Filter for Library (#54)
This commit is contained in:
186
src/App.tsx
186
src/App.tsx
@@ -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>
|
||||
|
||||
90
src/components/ThreeStateCheckbox.tsx
Normal file
90
src/components/ThreeStateCheckbox.tsx
Normal 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;
|
||||
51
src/components/library/LibraryMangaGrid.tsx
Normal file
51
src/components/library/LibraryMangaGrid.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
51
src/components/library/LibraryOptions.tsx
Normal file
51
src/components/library/LibraryOptions.tsx
Normal 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>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
32
src/util/useLibraryOptions.ts
Normal file
32
src/util/useLibraryOptions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user