diff --git a/src/components/ChapterCard.tsx b/src/components/ChapterCard.tsx index 3da91058c1..b58f8c83ca 100644 --- a/src/components/ChapterCard.tsx +++ b/src/components/ChapterCard.tsx @@ -24,12 +24,15 @@ interface IProps{ chapter: IChapter triggerChaptersUpdate: () => void downloadStatusString: string + showChapterNumber: boolean } export default function ChapterCard(props: IProps) { const theme = useTheme(); - const { chapter, triggerChaptersUpdate, downloadStatusString } = props; + const { + chapter, triggerChaptersUpdate, downloadStatusString, showChapterNumber, + } = props; const dateStr = chapter.uploadDate && new Date(chapter.uploadDate).toISOString().slice(0, 10); @@ -109,7 +112,7 @@ export default function ChapterCard(props: IProps) { {chapter.bookmarked && } - {chapter.name} + { showChapterNumber ? `Chapter ${chapter.chapterNumber}` : chapter.name} {chapter.scanlator} diff --git a/src/components/ChapterList.tsx b/src/components/ChapterList.tsx new file mode 100644 index 0000000000..d59ab9b6b8 --- /dev/null +++ b/src/components/ChapterList.tsx @@ -0,0 +1,246 @@ +/* + * 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, { useState, useEffect } from 'react'; +import { Box, styled } from '@mui/system'; +import { Virtuoso } from 'react-virtuoso'; +import Typography from '@mui/material/Typography'; +import ChapterCard from 'components/ChapterCard'; +import { CircularProgress, Fab } from '@mui/material'; +import { Link } from 'react-router-dom'; +import makeToast from 'components/util/Toast'; +import client from 'util/client'; +import PlayArrow from '@mui/icons-material/PlayArrow'; +import ChapterOptions from 'components/ChapterOptions'; +import useLocalStorage from '../util/useLocalStorage'; + +const List = styled(Virtuoso)(({ theme }) => ({ + listStyle: 'none', + padding: 0, + minHeight: '200px', + [theme.breakpoints.up('md')]: { + width: '50vw', + // 64px for the Appbar, 40px for the ChapterCount Header + height: 'calc(100vh - 64px - 40px)', + margin: 0, + }, +})); + +const baseWebsocketUrl = JSON.parse(window.localStorage.getItem('serverBaseURL')!).replace('http', 'ws'); +const initialQueue = { + status: 'Stopped', + queue: [], +} as IQueue; + +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, setChapters] = useState([]); + const [noChaptersFound, setNoChaptersFound] = useState(false); + const [chapterUpdateTriggerer, setChapterUpdateTriggerer] = useState(0); + const [fetchedOnline, setFetchedOnline] = useState(false); + const [fetchedOffline, setFetchedOffline] = useState(false); + 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, + }, + ); + + const [, setWsClient] = useState(); + const [{ queue }, setQueueState] = useState(initialQueue); + + function triggerChaptersUpdate() { + setChapterUpdateTriggerer(chapterUpdateTriggerer + 1); + } + + useEffect(() => { + const wsc = new WebSocket(`${baseWebsocketUrl}/api/v1/downloads`); + wsc.onmessage = (e) => { + const data = JSON.parse(e.data) as IQueue; + setQueueState(data); + }; + + setWsClient(wsc); + + return () => wsc.close(); + }, []); + + useEffect(() => { + triggerChaptersUpdate(); + }, [queue.length]); + + const downloadStatusStringFor = (chapter: IChapter) => { + let rtn = ''; + if (chapter.downloaded) { + rtn = ' • Downloaded'; + } + queue.forEach((q) => { + if (chapter.index === q.chapterIndex && chapter.mangaId === q.mangaId) { + rtn = ` • Downloading (${(q.progress * 100).toFixed(2)}%)`; + } + }); + return rtn; + }; + + useEffect(() => { + const shouldFetchOnline = fetchedOffline && !fetchedOnline; + + client.get(`/api/v1/manga/${id}/chapters?onlineFetch=${shouldFetchOnline}`) + .then((response) => response.data) + .then((data) => { + if (data.length === 0 && fetchedOffline) { + makeToast('No chapters found', 'warning'); + setNoChaptersFound(true); + } + setChapters(data); + }) + .then(() => { + if (shouldFetchOnline) { + setFetchedOnline(true); + } else setFetchedOffline(true); + }); + }, [fetchedOnline, fetchedOffline, chapterUpdateTriggerer]); + + 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); + + setFirstUnreadChapter(findFirstUnreadChapter(filtered)); + }, [options, chapters]); + + const ResumeFab = () => (firstUnreadChapter === undefined ? null + : ( + + + {firstUnreadChapter.index === 1 ? 'Start' : 'Resume' } + + )); + + if (chapters.length === 0 || noChaptersFound) { + return ( +
+ +
+ ); + } + + return ( + <> + + + + {`${filteredChapters.length} Chapters`} + + + + + ( + + )} + useWindowScroll={window.innerWidth < 900} + overscan={window.innerHeight * 0.5} + /> + + + + ); +} diff --git a/src/components/ChapterOptions.tsx b/src/components/ChapterOptions.tsx new file mode 100644 index 0000000000..241fe1ae79 --- /dev/null +++ b/src/components/ChapterOptions.tsx @@ -0,0 +1,155 @@ +/* + * 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, { useState } from 'react'; +import FilterListIcon from '@mui/icons-material/FilterList'; +import { + Drawer, FormControlLabel, IconButton, Typography, Tab, Tabs, Radio, RadioGroup, +} from '@mui/material'; +import ThreeStateCheckbox from 'components/util/ThreeStateCheckbox'; +import { Box } from '@mui/system'; +import { ArrowDownward, ArrowUpward } from '@mui/icons-material'; +import TabPanel from 'components/util/TabPanel'; + +interface IProps{ + options: ChapterListOptions + setOptions: React.Dispatch> +} + +const SortTab: [SortMode, string][] = [['source', 'By Source'], ['fetchedAt', 'By Fetch date']]; + +export default function ChapterOptions(props: IProps) { + const { options, setOptions } = 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: SortMode) => { + 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 }); + } + }; + + return ( + <> + setFiltersOpen(!filtersOpen)} + color={options.active ? 'warning' : 'default'} + > + + + + setFiltersOpen(false)} + PaperProps={{ + style: { + maxWidth: 600, + padding: '1em', + marginLeft: 'auto', + marginRight: 'auto', + minHeight: '150px', + }, + }} + > + + setTabNum(newTab)} + indicatorColor="primary" + textColor="primary" + > + + + + + + + } label="Unread" /> + } label="Downloaded" /> + } label="Bookmarked" /> + + + + + { + SortTab.map((item) => ( + setSort(item[0])} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 1, + height: 42, + py: 1, + }} + > + + {options.sortBy === item[0] + && (options.reverse ? ( + + ) : ( + + ))} + + {item[1]} + + + )) + } + + + + + + } /> + } /> + + + + + + + + ); +} diff --git a/src/screens/Manga.tsx b/src/screens/Manga.tsx index dd02df3147..99ab8b7594 100644 --- a/src/screens/Manga.tsx +++ b/src/screens/Manga.tsx @@ -6,34 +6,13 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import React, { useEffect, useState, useContext } from 'react'; -import { Box, styled } from '@mui/system'; -import { Link, useParams } from 'react-router-dom'; -import { Virtuoso } from 'react-virtuoso'; -import ChapterCard from 'components/ChapterCard'; +import { Box } from '@mui/system'; import MangaDetails from 'components/MangaDetails'; import NavbarContext from 'components/context/NavbarContext'; import client from 'util/client'; import LoadingPlaceholder from 'components/util/LoadingPlaceholder'; -import makeToast from 'components/util/Toast'; -import { Fab } from '@mui/material'; -import PlayArrow from '@mui/icons-material/PlayArrow'; - -const StyledVirtuoso = styled(Virtuoso)((({ theme }) => ({ - listStyle: 'none', - padding: 0, - minHeight: '200px', - [theme.breakpoints.up('md')]: { - width: '50vw', - height: 'calc(100vh - 64px)', - margin: 0, - }, -}))); - -const baseWebsocketUrl = JSON.parse(window.localStorage.getItem('serverBaseURL')!).replace('http', 'ws'); -const initialQueue = { - status: 'Stopped', - queue: [], -} as IQueue; +import ChapterList from 'components/ChapterList'; +import { useParams } from 'react-router-dom'; export default function Manga() { const { setTitle } = useContext(NavbarContext); @@ -42,48 +21,6 @@ export default function Manga() { const { id } = useParams<{ id: string }>(); const [manga, setManga] = useState(); - const [chapters, setChapters] = useState([]); - const [noChaptersFound, setNoChaptersFound] = useState(false); - const [chapterUpdateTriggerer, setChapterUpdateTriggerer] = useState(0); - const [fetchedOnline, setFetchedOnline] = useState(false); - const [fetchedOffline, setFetchedOffline] = useState(false); - const [firstUnreadChapter, setFirstUnreadChapter] = useState(); - - const [, setWsClient] = useState(); - const [{ queue }, setQueueState] = useState(initialQueue); - - function triggerChaptersUpdate() { - setChapterUpdateTriggerer(chapterUpdateTriggerer + 1); - } - - useEffect(() => { - const wsc = new WebSocket(`${baseWebsocketUrl}/api/v1/downloads`); - wsc.onmessage = (e) => { - const data = JSON.parse(e.data) as IQueue; - setQueueState(data); - }; - - setWsClient(wsc); - - return () => wsc.close(); - }, []); - - useEffect(() => { - triggerChaptersUpdate(); - }, [queue.length]); - - const downloadStatusStringFor = (chapter: IChapter) => { - let rtn = ''; - if (chapter.downloaded) { - rtn = ' • Downloaded'; - } - queue.forEach((q) => { - if (chapter.index === q.chapterIndex && chapter.mangaId === q.mangaId) { - rtn = ` • Downloading (${(q.progress * 100).toFixed(2)}%)`; - } - }); - return rtn; - }; useEffect(() => { if (manga === undefined || !manga.freshData) { @@ -96,44 +33,6 @@ export default function Manga() { } }, [manga]); - useEffect(() => { - const shouldFetchOnline = fetchedOffline && !fetchedOnline && (chapterUpdateTriggerer < 2); - - client.get(`/api/v1/manga/${id}/chapters?onlineFetch=${shouldFetchOnline}`) - .then((response) => response.data) - .then((data) => { - if (data.length === 0 && fetchedOffline) { - makeToast('No chapters found', 'warning'); - setNoChaptersFound(true); - } - setChapters(data); - }) - .then(() => { - if (shouldFetchOnline) { - setFetchedOnline(true); - } else setFetchedOffline(true); - }); - }, [fetchedOnline, fetchedOffline, chapterUpdateTriggerer]); - - useEffect(() => { - const a = [...chapters].reverse().find((chp) => !chp.read); - setFirstUnreadChapter(a); - }, [chapters]); - - const ResumeFab = () => (firstUnreadChapter === undefined ? null - : ( - - - {firstUnreadChapter.index === 1 ? 'Start' : 'Resume' } - - )); - return ( - 0 || noChaptersFound} - > - ( - - )} - useWindowScroll={window.innerWidth < 900} - overscan={window.innerHeight * 0.5} - /> - - + ); } diff --git a/src/typings.d.ts b/src/typings.d.ts index 6e0fc51d26..c6c0463009 100644 --- a/src/typings.d.ts +++ b/src/typings.d.ts @@ -216,3 +216,17 @@ interface PaginatedList { page: T[], hasNextPage: boolean } + +type NullAndUndefined = T | null | undefined; + +type SortMode = 'fetchedAt' | 'source'; + +interface ChapterListOptions { + active: boolean + unread: NullAndUndefined + downloaded: NullAndUndefined + bookmarked: NullAndUndefined + reverse: boolean + sortBy: SortMode + showChapterNumber: boolean +}