diff --git a/src/components/chapter/ChapterList.tsx b/src/components/chapter/ChapterList.tsx index 2474cb135b..23cc768b48 100644 --- a/src/components/chapter/ChapterList.tsx +++ b/src/components/chapter/ChapterList.tsx @@ -5,17 +5,20 @@ * 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, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { Box, styled } from '@mui/system'; import { Virtuoso } from 'react-virtuoso'; import Typography from '@mui/material/Typography'; -import { CircularProgress, Fab } from '@mui/material'; -import { Link } from 'react-router-dom'; +import { CircularProgress, Stack } from '@mui/material'; import makeToast from 'components/util/Toast'; -import PlayArrow from '@mui/icons-material/PlayArrow'; import ChapterOptions from 'components/chapter/ChapterOptions'; import ChapterCard from 'components/chapter/ChapterCard'; -import useLocalStorage from 'util/useLocalStorage'; +import { useReducerLocalStorage } from 'util/useLocalStorage'; +import { + chapterOptionsReducer, defaultChapterOptions, findFirstUnreadChapter, + filterAndSortChapters, +} from 'components/chapter/util'; +import ResumeFab from 'components/chapter/ResumeFAB'; import useFetchChapters from './useFetchChapters'; const CustomVirtuoso = styled(Virtuoso)(({ theme }) => ({ @@ -40,65 +43,15 @@ interface IProps { id: string } -function unreadFilter(unread: NullAndUndefined, { read: isChapterRead }: IChapter) { - switch (unread) { - case true: - return !isChapterRead; - case false: - return isChapterRead; - default: - return true; - } -} - -function downloadFilter(downloaded: NullAndUndefined, - { downloaded: chapterDownload }: IChapter) { - switch (downloaded) { - case true: - return chapterDownload; - case false: - return !chapterDownload; - default: - return true; - } -} - -function bookmarkdFilter(bookmarked: NullAndUndefined, - { bookmarked: chapterBookmarked }: IChapter) { - switch (bookmarked) { - case true: - return chapterBookmarked; - case false: - return !chapterBookmarked; - default: - return true; - } -} - -function findFirstUnreadChapter(chapters: IChapter[]): IChapter | undefined { - for (let index = chapters.length - 1; index >= 0; index--) { - if (!chapters[index].read) return chapters[index]; - } - return undefined; -} - export default function ChapterList(props: IProps) { const { id } = props; const [chapters, triggerChaptersUpdate, noChaptersFound] = useFetchChapters(id); const [firstUnreadChapter, setFirstUnreadChapter] = useState(); const [filteredChapters, setFilteredChapters] = useState([]); - const [options, setOptions] = useLocalStorage( - `${id}filterOptions`, - { - active: false, - unread: undefined, - downloaded: undefined, - bookmarked: undefined, - reverse: false, - sortBy: 'source', - showChapterNumber: false, - }, + // eslint-disable-next-line max-len + const [options, optionsDispatch] = useReducerLocalStorage( + chapterOptionsReducer, `${id}filterOptions`, defaultChapterOptions, ); const [, setWsClient] = useState(); @@ -120,7 +73,7 @@ export default function ChapterList(props: IProps) { triggerChaptersUpdate(); }, [queue.length]); - const downloadStatusStringFor = (chapter: IChapter) => { + const downloadStatusStringFor = useCallback((chapter: IChapter) => { let rtn = ''; if (chapter.downloaded) { rtn = ' • Downloaded'; @@ -131,39 +84,14 @@ export default function ChapterList(props: IProps) { } }); return rtn; - }; + }, [queue]); useEffect(() => { - const filtered = options.active - ? chapters.filter((chp) => unreadFilter(options.unread, chp) - && downloadFilter(options.downloaded, chp) - && bookmarkdFilter(options.bookmarked, chp)) - : [...chapters]; - const Sorted = options.sortBy === 'fetchedAt' - ? filtered.sort((a, b) => a.fetchedAt - b.fetchedAt) - : filtered; - if (options.reverse) { - Sorted.reverse(); - } - setFilteredChapters(Sorted); - + const filtered = filterAndSortChapters(chapters, options); + setFilteredChapters(filtered); setFirstUnreadChapter(findFirstUnreadChapter(filtered)); }, [options, chapters]); - const ResumeFab = () => (firstUnreadChapter === undefined ? null - : ( - - - {firstUnreadChapter.index === 1 ? 'Start' : 'Resume' } - - )); - useEffect(() => { if (noChaptersFound) { makeToast('No chapters found', 'warning'); @@ -185,11 +113,7 @@ export default function ChapterList(props: IProps) { return ( <> - + {`${filteredChapters.length} Chapters`} - + - - + + {firstUnreadChapter && } ); } diff --git a/src/components/chapter/ChapterOptions.tsx b/src/components/chapter/ChapterOptions.tsx index 533610067c..3e86abb7d1 100644 --- a/src/components/chapter/ChapterOptions.tsx +++ b/src/components/chapter/ChapterOptions.tsx @@ -5,10 +5,10 @@ * 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, { useState } from 'react'; +import React, { useState, useCallback } from 'react'; import FilterListIcon from '@mui/icons-material/FilterList'; import { - Drawer, FormControlLabel, IconButton, Typography, Tab, Tabs, Radio, RadioGroup, + Drawer, FormControlLabel, IconButton, Typography, Tab, Tabs, Radio, RadioGroup, Stack, } from '@mui/material'; import ThreeStateCheckbox from 'components/util/ThreeStateCheckbox'; import { Box } from '@mui/system'; @@ -17,51 +17,21 @@ import TabPanel from 'components/util/TabPanel'; interface IProps{ options: ChapterListOptions - setOptions: React.Dispatch> + optionsDispatch: React.Dispatch } const SortTab: [ChapterSortMode, string][] = [['source', 'By Source'], ['fetchedAt', 'By Fetch date']]; export default function ChapterOptions(props: IProps) { - const { options, setOptions } = props; + const { options, optionsDispatch } = props; const [filtersOpen, setFiltersOpen] = useState(false); const [tabNum, setTabNum] = useState(0); - const setUnread = (newUnread: NullAndUndefined) => { - const active = options.unread !== false - && options.downloaded !== false - && options.bookmarked !== false; - setOptions({ ...options, active, unread: newUnread }); - }; - - const setDownloaded = (newDownloaded: NullAndUndefined) => { - const active = options.unread !== false - && options.downloaded !== false - && options.bookmarked !== false; - setOptions({ ...options, active, downloaded: newDownloaded }); - }; - - const setBookmarked = (newBookmarked: NullAndUndefined) => { - const active = options.unread !== false - && options.downloaded !== false - && options.bookmarked !== false; - setOptions({ ...options, active, bookmarked: newBookmarked }); - }; - - const setSort = (newSort: ChapterSortMode) => { - if (newSort !== options.sortBy) { - setOptions({ ...options, sortBy: newSort }); - } else { - setOptions({ ...options, reverse: !options.reverse }); - } - }; - - const handleDisplay = (e: React.ChangeEvent) => { - const showChapterNumber = e.target.value === 'chapterNumber'; - if (showChapterNumber !== options.showChapterNumber) { - setOptions({ ...options, showChapterNumber }); - } - }; + const filterOptions = useCallback( + (value: NullAndUndefined, name: string) => { + optionsDispatch({ type: 'filter', filterType: name.toLowerCase(), filterValue: value }); + }, [], + ); return ( <> @@ -101,51 +71,45 @@ export default function ChapterOptions(props: IProps) { - } label="Unread" /> - } label="Downloaded" /> - } label="Bookmarked" /> + } label="Unread" /> + } label="Downloaded" /> + } label="Bookmarked" /> { SortTab.map((item) => ( - setSort(item[0])} - sx={{ - display: 'flex', - alignItems: 'center', - gap: 1, - height: 42, - py: 1, - }} + (item[0] !== options.sortBy + ? optionsDispatch({ type: 'sortBy', sortBy: item[0] }) + : optionsDispatch({ type: 'sortReverse' }))} > - - {options.sortBy === item[0] - && (options.reverse ? ( - - ) : ( - - ))} + + { + options.sortBy === item[0] + && (options.reverse + ? () : ()) + } {item[1]} - + )) } - - + + optionsDispatch({ type: 'showChapterNumber' })} value={options.showChapterNumber}> } /> } /> - + diff --git a/src/components/chapter/ResumeFAB.tsx b/src/components/chapter/ResumeFAB.tsx new file mode 100644 index 0000000000..9eee4e25e9 --- /dev/null +++ b/src/components/chapter/ResumeFAB.tsx @@ -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 React from 'react'; +import { Fab } from '@mui/material'; +import { Link } from 'react-router-dom'; +import { PlayArrow } from '@mui/icons-material'; + +interface ResumeFABProps{ + chapter: IChapter + mangaId: string +} + +export default function ResumeFab(props: ResumeFABProps) { + const { chapter: { index, lastPageRead }, mangaId } = props; + return ( + + + {index === 1 ? 'Start' : 'Resume' } + + ); +} diff --git a/src/components/chapter/util.tsx b/src/components/chapter/util.tsx new file mode 100644 index 0000000000..ae0eb38fa4 --- /dev/null +++ b/src/components/chapter/util.tsx @@ -0,0 +1,99 @@ +/* + * 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/. */ + +export const defaultChapterOptions: ChapterListOptions = { + active: false, + unread: undefined, + downloaded: undefined, + bookmarked: undefined, + reverse: false, + sortBy: 'source', + showChapterNumber: false, +}; + +export function chapterOptionsReducer(state: ChapterListOptions, + actions: ChapterOptionsReducerAction) + : ChapterListOptions { + switch (actions.type) { + case 'filter': + // eslint-disable-next-line no-case-declarations + const active = state.unread !== false + && state.downloaded !== false + && state.bookmarked !== false; + return { + ...state, + active, + [actions.filterType!]: actions.filterValue, + }; + case 'sortBy': + return { ...state, sortBy: actions.sortBy }; + case 'sortReverse': + return { ...state, reverse: !state.reverse }; + case 'showChapterNumber': + return { ...state, showChapterNumber: !state.showChapterNumber }; + default: + throw Error('This is not a valid Action'); + } +} + +export function unreadFilter(unread: NullAndUndefined, { read: isChapterRead }: IChapter) { + switch (unread) { + case true: + return !isChapterRead; + case false: + return isChapterRead; + default: + return true; + } +} + +export function downloadFilter(downloaded: NullAndUndefined, + { downloaded: chapterDownload }: IChapter) { + switch (downloaded) { + case true: + return chapterDownload; + case false: + return !chapterDownload; + default: + return true; + } +} + +export function bookmarkdFilter(bookmarked: NullAndUndefined, + { bookmarked: chapterBookmarked }: IChapter) { + switch (bookmarked) { + case true: + return chapterBookmarked; + case false: + return !chapterBookmarked; + default: + return true; + } +} + +export function filterAndSortChapters(chapters: IChapter[], options: ChapterListOptions) + : IChapter[] { + const filtered = options.active + ? chapters.filter((chp) => unreadFilter(options.unread, chp) + && downloadFilter(options.downloaded, chp) + && bookmarkdFilter(options.bookmarked, chp)) + : [...chapters]; + const Sorted = options.sortBy === 'fetchedAt' + ? filtered.sort((a, b) => a.fetchedAt - b.fetchedAt) + : filtered; + if (options.reverse) { + Sorted.reverse(); + } + return Sorted; +} + +export function findFirstUnreadChapter(chapters: IChapter[]): IChapter | undefined { + for (let index = chapters.length - 1; index >= 0; index--) { + if (!chapters[index].read) return chapters[index]; + } + return undefined; +} diff --git a/src/components/util/ThreeStateCheckbox.tsx b/src/components/util/ThreeStateCheckbox.tsx index 997440791a..3fc6795e60 100644 --- a/src/components/util/ThreeStateCheckbox.tsx +++ b/src/components/util/ThreeStateCheckbox.tsx @@ -14,7 +14,7 @@ import React, { export interface IThreeStateCheckboxProps { name: string checked: boolean | undefined | null - onChange: (change: boolean | undefined | null) => void + onChange: (change: boolean | undefined | null, name: string) => void } enum CheckState { @@ -61,10 +61,10 @@ const ThreeStateCheckbox = (props: IThreeStateCheckboxProps) => { } = props; const [localChecked, setLocalChecked] = useState(checkedToState(checked)); useEffect(() => setLocalChecked(checkedToState(checked)), [checked]); - const handleChange = () => { + const handleChange = (e: React.ChangeEvent) => { setLocalChecked(stateTransition(localChecked)); if (onChange) { - onChange(stateToChecked(stateTransition(localChecked))); + onChange(stateToChecked(stateTransition(localChecked)), e.currentTarget.name); } }; const CancelBox = createSvgIcon( diff --git a/src/typings.d.ts b/src/typings.d.ts index b6feb46d0b..18d779c52b 100644 --- a/src/typings.d.ts +++ b/src/typings.d.ts @@ -230,3 +230,9 @@ interface ChapterListOptions { sortBy: ChapterSortMode showChapterNumber: boolean } + +type ChapterOptionsReducerAction = +{ type: 'filter', filterType:string, filterValue: NullAndUndefined } +| { type: 'sortBy', sortBy: ChapterSortMode } +| { type: 'sortReverse' } +| { type: 'showChapterNumber' }; diff --git a/src/util/useLocalStorage.tsx b/src/util/useLocalStorage.tsx index 83392830b0..2b0d2dbec2 100644 --- a/src/util/useLocalStorage.tsx +++ b/src/util/useLocalStorage.tsx @@ -5,13 +5,24 @@ * 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, { useState, Dispatch, SetStateAction } from 'react'; +import React, { + useState, + Dispatch, + SetStateAction, + useReducer, + Reducer, +} from 'react'; import storage from './localStorage'; // eslint-disable-next-line max-len -export default function useLocalStorage(key: string, defaultValue: T | (() => T)) : [T, Dispatch>] { +export default function useLocalStorage( + key: string, + defaultValue: T | (() => T), +): [T, Dispatch>] { const initialState = defaultValue instanceof Function ? defaultValue() : defaultValue; - const [storedValue, setStoredValue] = useState(storage.getItem(key, initialState)); + const [storedValue, setStoredValue] = useState( + storage.getItem(key, initialState), + ); const setValue = ((value: T | ((prevState: T) => T)) => { // Allow value to be a function so we have same API as useState @@ -22,3 +33,16 @@ export default function useLocalStorage(key: string, defaultValue: T | (() => return [storedValue, setValue]; } + +export function useReducerLocalStorage( + reducer: Reducer, + key: string, + defaultState: S | (() => S), +) { + const [storedValue, setValue] = useLocalStorage(key, defaultState); + return useReducer((state: S, action: A): S => { + const newState = reducer(state, action); + setValue(newState); + return newState; + }, storedValue); +}