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
+}