diff --git a/package.json b/package.json index 792588b868..19f2485eb6 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@mui/system": "^5.0.6", "axios": "^0.21.1", "file-selector": "^0.2.4", + "query-string": "^7.0.1", "react": "^17.0.2", "react-beautiful-dnd": "^13.0.0", "react-dom": "^17.0.2", @@ -20,6 +21,7 @@ "react-router-dom": "^5.2.0", "react-scripts": "4.0.3", "react-virtuoso": "^1.8.6", + "use-query-params": "^1.2.3", "web-vitals": "^2.1.0" }, "scripts": { diff --git a/src/App.tsx b/src/App.tsx index b6beb34111..e51f75d95b 100644 --- a/src/App.tsx +++ b/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('Tachidesk'); const [action, setAction] = useState(
); - const [override, setOverride] = useState({ status: false, value:
}); + const [override, setOverride] = useState({ + status: false, + value:
, + }); const [darkTheme, setDarkTheme] = useLocalStorage('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() { - - - - + + + + + + + {/* General Routes */} + ( + + )} + /> + + + + + + + + + + + + + + + + {/* Manga Routes */} + + + + + + + + + + + + + + + + + + <> + + + + + + + + + + + + + + + - {/* General Routes */} ( - - )} + path="/manga/:mangaId/chapter/:chapterIndex" + // passing a key re-mounts the reader when changing chapters + render={ + (props: any) => ( + + ) + } /> - - - - - - - - - - - - - - - - - {/* Manga Routes */} - - - - - - - - - - - - - - - - - - <> - - - - - - - - - - - - - - - - - } - /> - - + + diff --git a/src/components/ThreeStateCheckbox.tsx b/src/components/ThreeStateCheckbox.tsx new file mode 100644 index 0000000000..997440791a --- /dev/null +++ b/src/components/ThreeStateCheckbox.tsx @@ -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( + <> + + , + 'CancelBox', + ); + + return ( + } + onChange={handleChange} + className={`${localChecked}`} + /> + ); +}; +export default ThreeStateCheckbox; diff --git a/src/components/library/LibraryMangaGrid.tsx b/src/components/library/LibraryMangaGrid.tsx new file mode 100644 index 0000000000..0f2f161b45 --- /dev/null +++ b/src/components/library/LibraryMangaGrid.tsx @@ -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, { 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 ( + + ); +} diff --git a/src/components/library/LibraryOptions.tsx b/src/components/library/LibraryOptions.tsx new file mode 100644 index 0000000000..53859ccb20 --- /dev/null +++ b/src/components/library/LibraryOptions.tsx @@ -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 ( +
+ } label="Unread" /> +
+ ); +} + +export default function LibraryOptions() { + const [filtersOpen, setFiltersOpen] = React.useState(false); + const { active } = useLibraryOptions(); + return ( + <> + setFiltersOpen(!filtersOpen)} + color={active ? 'warning' : 'default'} + > + + + + setFiltersOpen(false)} + PaperProps={{ + style: { + maxWidth: 600, padding: '1em', marginLeft: 'auto', marginRight: 'auto', + }, + }} + > + + + + + ); +} diff --git a/src/components/manga/MangaGrid.tsx b/src/components/manga/MangaGrid.tsx index ed01bd92e0..e6e7382fed 100644 --- a/src/components/manga/MangaGrid.tsx +++ b/src/components/manga/MangaGrid.tsx @@ -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; diff --git a/src/screens/manga/Library.tsx b/src/screens/manga/Library.tsx index 3e451d092a..42a9c79a89 100644 --- a/src/screens/manga/Library.tsx +++ b/src/screens/manga/Library.tsx @@ -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( + <> + + , + ); + }, []); const [tabs, setTabs] = useState(); const [tabNum, setTabNum] = useState(0); @@ -88,7 +95,7 @@ export default function Library() { const tabBodies = tabs.map((tab) => ( - = T | null | undefined; + +interface IUseLibraryOptions { + unread: NullAndUndefined + setUnread: (unread: NullAndUndefined) => void + active: boolean +} + +export default function useLibraryOptions(): IUseLibraryOptions { + const [query, setQuery] = useQueryParams({ + unread: BooleanParam, + }); + const { unread } = query; + const setUnread = (newUnread: NullAndUndefined) => { + setQuery(Object.assign(query, { unread: newUnread }), 'replace'); + }; + // eslint-disable-next-line eqeqeq + const active = !(unread == undefined); + return { + unread, setUnread, active, + }; +} diff --git a/yarn.lock b/yarn.lock index 9dcd88ef9d..7c67bab2d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5435,6 +5435,11 @@ fill-range@^7.0.1: dependencies: 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: version "1.1.2" 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" 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: version "0.2.1" resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" @@ -10361,6 +10376,11 @@ serialize-javascript@^5.0.1: dependencies: 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: version "1.9.1" 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" 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: version "3.1.0" 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" 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: version "4.0.2" 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" 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: version "3.1.1" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"