Feature/batch chapter download (#191)

* Add NavbarToolbar component for rendering items in toolbar through portal

* Refactor Manga screen, move chapters filtering to toolbar menu, cleanup ChapterOptions

* Fix warning

* Update layout of ChapterList and add selection for chapters, add batch download, move stuff around

* Add space to bookmark icon

* Clear actions when leaving source mangas page

* Move bookmark icon back inline and align it correctly with text

* Fixup MangaDetails buttons to update cache properly so buttons in manga menu are updated

* Move chapters filter to ChapterList

* Fixup error display overlapping with loaded content when revalidation fails

* Fixup spacing around ChapterList title

* Only show small progress if manga is loaded so there is not to many spinners

* Prevent title wrapping

* Add icon buttons to desktop sizes of manga toolbar menu
This commit is contained in:
Valter Martinek
2022-11-09 18:09:15 +01:00
committed by GitHub
parent e828582fd4
commit 4a4e8b2cde
19 changed files with 988 additions and 552 deletions

View File

@@ -1,156 +0,0 @@
/*
* 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 { useTheme } from '@mui/material/styles';
import { Link } from 'react-router-dom';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import IconButton from '@mui/material/IconButton';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import Typography from '@mui/material/Typography';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import BookmarkIcon from '@mui/icons-material/Bookmark';
import client from 'util/client';
import { Box } from '@mui/system';
interface IProps{
chapter: IChapter
triggerChaptersUpdate: () => void
downloadStatusString: string
showChapterNumber: boolean
}
export default function ChapterCard(props: IProps) {
const theme = useTheme();
const {
chapter, triggerChaptersUpdate, downloadStatusString, showChapterNumber,
} = props;
const dateStr = chapter.uploadDate && new Date(chapter.uploadDate).toLocaleDateString();
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
// prevent parent tags from getting the event
event.stopPropagation();
event.preventDefault();
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const sendChange = (key: string, value: any) => {
handleClose();
const formData = new FormData();
formData.append(key, value);
if (key === 'read') { formData.append('lastPageRead', '1'); }
client.patch(`/api/v1/manga/${chapter.mangaId}/chapter/${chapter.index}`, formData)
.then(() => triggerChaptersUpdate());
};
const downloadChapter = () => {
client.get(`/api/v1/download/${chapter.mangaId}/chapter/${chapter.index}`);
handleClose();
};
const deleteChapter = () => {
client.delete(`/api/v1/manga/${chapter.mangaId}/chapter/${chapter.index}`)
.then(() => triggerChaptersUpdate());
handleClose();
};
const readChapterColor = theme.palette.mode === 'dark' ? '#acacac' : '#b0b0b0';
return (
<>
<li>
<Card
sx={{
margin: '10px',
':hover': {
backgroundColor: 'action.hover',
transition: 'background-color 100ms cubic-bezier(0.4, 0, 0.2, 1) 0ms',
cursor: 'pointer',
},
':active': {
backgroundColor: 'action.selected',
transition: 'background-color 100ms cubic-bezier(0.4, 0, 0.2, 1) 0ms',
},
}}
>
<Link
to={`/manga/${chapter.mangaId}/chapter/${chapter.index}`}
style={{
textDecoration: 'none',
color: chapter.read ? readChapterColor : theme.palette.text.primary,
}}
>
<CardContent
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: 2,
}}
>
<Box sx={{ display: 'flex' }}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="h5" component="h2">
<span style={{ color: theme.palette.primary.dark }}>
{chapter.bookmarked && <BookmarkIcon />}
</span>
{ showChapterNumber ? `Chapter ${chapter.chapterNumber}` : chapter.name}
</Typography>
<Typography variant="caption" display="block" gutterBottom>
{chapter.scanlator}
{chapter.scanlator && ' '}
{dateStr}
{downloadStatusString}
</Typography>
</div>
</Box>
<IconButton aria-label="more" onClick={handleClick} size="large">
<MoreVertIcon />
</IconButton>
</CardContent>
</Link>
<Menu
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={handleClose}
>
{downloadStatusString.endsWith('Downloaded')
&& <MenuItem onClick={deleteChapter}>Delete</MenuItem>}
{downloadStatusString.length === 0
&& <MenuItem onClick={downloadChapter}>Download</MenuItem> }
<MenuItem onClick={() => sendChange('bookmarked', !chapter.bookmarked)}>
{chapter.bookmarked && 'Remove bookmark'}
{!chapter.bookmarked && 'Bookmark'}
</MenuItem>
<MenuItem onClick={() => sendChange('read', !chapter.read)}>
{`Mark as ${chapter.read ? 'unread' : 'read'}`}
</MenuItem>
<MenuItem onClick={() => sendChange('markPrevRead', true)}>
Mark previous as Read
</MenuItem>
</Menu>
</Card>
</li>
</>
);
}

View File

@@ -1,150 +0,0 @@
/*
* 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, useCallback, useMemo, useRef,
} from 'react';
import { Box, styled } from '@mui/system';
import { Virtuoso } from 'react-virtuoso';
import Typography from '@mui/material/Typography';
import { CircularProgress, Stack } from '@mui/material';
import makeToast from 'components/util/Toast';
import ChapterOptions from 'components/chapter/ChapterOptions';
import ChapterCard from 'components/chapter/ChapterCard';
import { useReducerLocalStorage } from 'util/useLocalStorage';
import {
chapterOptionsReducer, defaultChapterOptions, findFirstUnreadChapter,
filterAndSortChapters,
} from 'components/chapter/util';
import ResumeFab from 'components/chapter/ResumeFAB';
import useSubscription from 'components/library/useSubscription';
const CustomVirtuoso = styled(Virtuoso)(({ theme }) => ({
listStyle: 'none',
padding: 0,
minHeight: '200px',
[theme.breakpoints.up('md')]: {
width: '50vw',
// 64px for the Appbar, 48px for the ChapterCount Header
height: 'calc(100vh - 64px - 48px)',
margin: 0,
},
}));
interface IProps {
id: string
chaptersData: IChapter[] | undefined
onRefresh: () => void;
}
export default function ChapterList({ id, chaptersData, onRefresh }: IProps) {
const noChaptersFound = chaptersData?.length === 0;
const chapters = useMemo(() => chaptersData ?? [], [chaptersData]);
const [firstUnreadChapter, setFirstUnreadChapter] = useState<IChapter>();
const [filteredChapters, setFilteredChapters] = useState<IChapter[]>([]);
// eslint-disable-next-line max-len
const [options, optionsDispatch] = useReducerLocalStorage<ChapterListOptions, ChapterOptionsReducerAction>(
chapterOptionsReducer, `${id}filterOptions`, defaultChapterOptions,
);
const prevQueueRef = useRef<IDownloadChapter[]>();
const queue = useSubscription<IQueue>('/api/v1/downloads').data?.queue;
const downloadStatusStringFor = useCallback((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;
}, [queue]);
useEffect(() => {
if (prevQueueRef.current && queue) {
const prevQueue = prevQueueRef.current;
const changedDownloads = queue.filter((cd) => {
const prevChapterDownload = prevQueue
.find((pcd) => cd.chapterIndex === pcd.chapterIndex
&& cd.mangaId === pcd.mangaId);
if (!prevChapterDownload) return true;
return cd.state !== prevChapterDownload.state;
});
if (changedDownloads.length > 0) {
onRefresh();
}
}
prevQueueRef.current = queue;
}, [queue]);
useEffect(() => {
const filtered = filterAndSortChapters(chapters, options);
setFilteredChapters(filtered);
setFirstUnreadChapter(findFirstUnreadChapter(filtered));
}, [options, chapters]);
useEffect(() => {
if (noChaptersFound) {
makeToast('No chapters found', 'warning');
}
}, [noChaptersFound]);
if (chapters.length === 0 || noChaptersFound) {
return (
<div style={{
margin: '10px auto',
display: 'flex',
justifyContent: 'center',
}}
>
<CircularProgress thickness={5} />
</div>
);
}
return (
<>
<Stack direction="column">
<Box sx={{
display: 'flex', justifyContent: 'space-between', px: 1.5, mt: 1,
}}
>
<Typography variant="h5">
{`${filteredChapters.length} Chapters`}
</Typography>
<ChapterOptions options={options} optionsDispatch={optionsDispatch} />
</Box>
<CustomVirtuoso
style={{ // override Virtuoso default values and set them with class
height: 'undefined',
// 900 is the md breakpoint in MUI
overflowY: window.innerWidth < 900 ? 'visible' : 'auto',
}}
totalCount={filteredChapters.length}
itemContent={(index:number) => (
<ChapterCard
showChapterNumber={options.showChapterNumber}
chapter={filteredChapters[index]}
downloadStatusString={downloadStatusStringFor(filteredChapters[index])}
triggerChaptersUpdate={onRefresh}
/>
)}
useWindowScroll={window.innerWidth < 900}
overscan={window.innerHeight * 0.5}
/>
</Stack>
{firstUnreadChapter && <ResumeFab chapter={firstUnreadChapter} mangaId={id} />}
</>
);
}

View File

@@ -1,119 +0,0 @@
/*
* 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, useCallback } from 'react';
import FilterListIcon from '@mui/icons-material/FilterList';
import {
Drawer, FormControlLabel, IconButton, Typography, Tab, Tabs, Radio, RadioGroup, Stack,
} 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
optionsDispatch: React.Dispatch<ChapterOptionsReducerAction>
}
const SortTab: [ChapterSortMode, string][] = [['source', 'By Source'], ['fetchedAt', 'By Fetch date']];
export default function ChapterOptions(props: IProps) {
const { options, optionsDispatch } = props;
const [filtersOpen, setFiltersOpen] = useState(false);
const [tabNum, setTabNum] = useState(0);
const filterOptions = useCallback(
(value: NullAndUndefined<boolean>, name: string) => {
optionsDispatch({ type: 'filter', filterType: name.toLowerCase(), filterValue: value });
}, [],
);
return (
<>
<IconButton
onClick={() => setFiltersOpen(!filtersOpen)}
color={options.active ? 'warning' : 'default'}
>
<FilterListIcon />
</IconButton>
<Drawer
anchor="bottom"
open={filtersOpen}
onClose={() => setFiltersOpen(false)}
PaperProps={{
style: {
maxWidth: 600,
padding: '1em',
marginLeft: 'auto',
marginRight: 'auto',
minHeight: '150px',
},
}}
>
<Box>
<Tabs
key={tabNum}
value={tabNum}
variant="fullWidth"
onChange={(e, newTab) => setTabNum(newTab)}
indicatorColor="primary"
textColor="primary"
>
<Tab value={0} label="Filter" />
<Tab value={1} label="Sort" />
<Tab value={2} label="Display" />
</Tabs>
<TabPanel index={0} currentIndex={tabNum}>
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '150px' }}>
<FormControlLabel control={<ThreeStateCheckbox name="Unread" checked={options.unread} onChange={filterOptions} />} label="Unread" />
<FormControlLabel control={<ThreeStateCheckbox name="Downloaded" checked={options.downloaded} onChange={filterOptions} />} label="Downloaded" />
<FormControlLabel control={<ThreeStateCheckbox name="Bookmarked" checked={options.bookmarked} onChange={filterOptions} />} label="Bookmarked" />
</Box>
</TabPanel>
<TabPanel index={1} currentIndex={tabNum}>
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '150px' }}>
{
SortTab.map((item) => (
<Stack
direction="row"
alignItems="center"
spacing="2"
sx={{ py: 1, height: 42 }}
onClick={() => (item[0] !== options.sortBy
? optionsDispatch({ type: 'sortBy', sortBy: item[0] })
: optionsDispatch({ type: 'sortReverse' }))}
>
<Box sx={{ height: 24, width: 24 }}>
{
options.sortBy === item[0]
&& (options.reverse
? (<ArrowUpward color="primary" />) : (<ArrowDownward color="primary" />))
}
</Box>
<Typography>{item[1]}</Typography>
</Stack>
))
}
</Box>
</TabPanel>
<TabPanel index={2} currentIndex={tabNum}>
<Stack flexDirection="column" sx={{ minHeight: '150px' }}>
<RadioGroup name="chapter-title-display" onChange={() => optionsDispatch({ type: 'showChapterNumber' })} value={options.showChapterNumber}>
<FormControlLabel label="By Source Title" value="title" control={<Radio checked={!options.showChapterNumber} />} />
<FormControlLabel label="By Chapter Number" value="chapterNumber" control={<Radio checked={options.showChapterNumber} />} />
</RadioGroup>
</Stack>
</TabPanel>
</Box>
</Drawer>
</>
);
}

View File

@@ -1,32 +0,0 @@
/*
* 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 (
<Fab
sx={{ position: 'fixed', bottom: '2em', right: '3em' }}
component={Link}
variant="extended"
color="primary"
to={`/manga/${mangaId}/chapter/${index}/page/${lastPageRead}`}
>
<PlayArrow />
{index === 1 ? 'Start' : 'Resume' }
</Fab>
);
}

View File

@@ -1,99 +0,0 @@
/*
* 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<boolean>, { read: isChapterRead }: IChapter) {
switch (unread) {
case true:
return !isChapterRead;
case false:
return isChapterRead;
default:
return true;
}
}
export function downloadFilter(downloaded: NullAndUndefined<boolean>,
{ downloaded: chapterDownload }: IChapter) {
switch (downloaded) {
case true:
return chapterDownload;
case false:
return !chapterDownload;
default:
return true;
}
}
export function bookmarkdFilter(bookmarked: NullAndUndefined<boolean>,
{ 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;
}