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

@@ -0,0 +1,241 @@
/*
* 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 BookmarkIcon from '@mui/icons-material/Bookmark';
import BookmarkAdd from '@mui/icons-material/BookmarkAdd';
import BookmarkRemove from '@mui/icons-material/BookmarkRemove';
import CheckBoxOutlineBlank from '@mui/icons-material/CheckBoxOutlineBlank';
import Delete from '@mui/icons-material/Delete';
import Done from '@mui/icons-material/Done';
import DoneAll from '@mui/icons-material/DoneAll';
import Download from '@mui/icons-material/Download';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import RemoveDone from '@mui/icons-material/RemoveDone';
import {
LinearProgress,
Checkbox, ListItemIcon, ListItemText, Stack,
} from '@mui/material';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import IconButton from '@mui/material/IconButton';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { useTheme } from '@mui/material/styles';
import Typography from '@mui/material/Typography';
import React from 'react';
import { Link } from 'react-router-dom';
import client from 'util/client';
interface IProps{
chapter: IChapter
triggerChaptersUpdate: () => void
downloadChapter: IDownloadChapter | undefined
showChapterNumber: boolean
onSelect: (selected: boolean) => void
selected: boolean | null
}
const ChapterCard: React.FC<IProps> = (props: IProps) => {
const theme = useTheme();
const {
chapter, triggerChaptersUpdate, downloadChapter: dc, showChapterNumber, onSelect, selected,
} = props;
const isSelecting = selected !== null;
const dateStr = chapter.uploadDate && new Date(chapter.uploadDate).toLocaleDateString();
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const handleMenuClick = (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 handleSelect = () => {
onSelect(true);
handleClose();
};
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (isSelecting) {
event.preventDefault();
event.stopPropagation();
onSelect(!selected);
}
};
const isDownloaded = chapter.downloaded;
const canBeDownloaded = !chapter.downloaded && dc === undefined;
return (
<li>
<Card
sx={{
position: 'relative',
margin: 1,
':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: theme.palette.text[chapter.read ? 'disabled' : 'primary'],
}}
onClick={handleClick}
>
<CardContent
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: 2,
'&:last-child': { pb: 2 },
}}
>
<Stack direction="column" flex={1}>
<Typography variant="h5" component="h2">
{chapter.bookmarked && (
<BookmarkIcon color="primary" sx={{ mr: 0.5, position: 'relative', top: '0.15em' }} />
)}
{ showChapterNumber ? `Chapter ${chapter.chapterNumber}` : chapter.name}
</Typography>
<Typography variant="caption">
{chapter.scanlator}
</Typography>
<Typography variant="caption">
{dateStr}
{isDownloaded && ' • Downloaded'}
{dc && ` • Downloading (${(dc.progress * 100).toFixed(2)}%)`}
</Typography>
</Stack>
{selected === null ? (
<IconButton aria-label="more" onClick={handleMenuClick} size="large">
<MoreVertIcon />
</IconButton>
) : (
<Checkbox checked={selected} />
)}
</CardContent>
</Link>
{dc != null && (
<LinearProgress
sx={{
position: 'absolute', bottom: 0, width: '100%', opacity: 0.5,
}}
variant="determinate"
value={dc.progress * 100}
color="inherit"
/>
)}
<Menu
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={handleClose}
>
<MenuItem onClick={handleSelect}>
<ListItemIcon>
<CheckBoxOutlineBlank fontSize="small" />
</ListItemIcon>
<ListItemText>
Select
</ListItemText>
</MenuItem>
{isDownloaded && (
<MenuItem onClick={deleteChapter}>
<ListItemIcon>
<Delete fontSize="small" />
</ListItemIcon>
<ListItemText>
Delete
</ListItemText>
</MenuItem>
)}
{canBeDownloaded && (
<MenuItem onClick={downloadChapter}>
<ListItemIcon>
<Download fontSize="small" />
</ListItemIcon>
<ListItemText>
Download
</ListItemText>
</MenuItem>
) }
<MenuItem onClick={() => sendChange('bookmarked', !chapter.bookmarked)}>
<ListItemIcon>
{chapter.bookmarked && <BookmarkRemove fontSize="small" />}
{!chapter.bookmarked && <BookmarkAdd fontSize="small" />}
</ListItemIcon>
<ListItemText>
{chapter.bookmarked && 'Remove bookmark'}
{!chapter.bookmarked && 'Add bookmark'}
</ListItemText>
</MenuItem>
<MenuItem onClick={() => sendChange('read', !chapter.read)}>
<ListItemIcon>
{chapter.read && <RemoveDone fontSize="small" />}
{!chapter.read && <Done fontSize="small" />}
</ListItemIcon>
<ListItemText>
{chapter.read && 'Mark as unread'}
{!chapter.read && 'Mark as read'}
</ListItemText>
</MenuItem>
<MenuItem onClick={() => sendChange('markPrevRead', true)}>
<ListItemIcon>
<DoneAll fontSize="small" />
</ListItemIcon>
<ListItemText>
Mark previous as Read
</ListItemText>
</MenuItem>
</Menu>
</Card>
</li>
);
};
export default ChapterCard;

View File

@@ -0,0 +1,218 @@
/*
* 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 {
Button, CircularProgress, Stack,
} from '@mui/material';
import Typography from '@mui/material/Typography';
import { styled } from '@mui/system';
import useSubscription from 'components/library/useSubscription';
import ChapterCard from 'components/manga/ChapterCard';
import ResumeFab from 'components/manga/ResumeFAB';
import { filterAndSortChapters, useChapterOptions } from 'components/manga/util';
import EmptyView from 'components/util/EmptyView';
import { pluralize } from 'components/util/helpers';
import makeToast from 'components/util/Toast';
import React, {
useEffect, useMemo, useRef, useState,
} from 'react';
import { Virtuoso } from 'react-virtuoso';
import client, { useQuery } from 'util/client';
import ChaptersToolbarMenu from './ChaptersToolbarMenu';
import SelectionFAB from './SelectionFAB';
const StyledVirtuoso = 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 {
mangaId: string
}
const ChapterList: React.FC<IProps> = ({ mangaId }) => {
const [selection, setSelection] = useState<number[] | null>(null);
const prevQueueRef = useRef<IDownloadChapter[]>();
const queue = useSubscription<IQueue>('/api/v1/downloads').data?.queue;
const [options, dispatch] = useChapterOptions(mangaId);
const {
data: chaptersData,
mutate,
loading,
} = useQuery<IChapter[]>(`/api/v1/manga/${mangaId}/chapters?onlineFetch=false`);
const chapters = useMemo(() => chaptersData ?? [], [chaptersData]);
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) {
mutate();
}
}
prevQueueRef.current = queue;
}, [queue]);
const visibleChapters = useMemo(() => filterAndSortChapters(chapters, options), //
[chapters, options]);
const firstUnreadChapter = useMemo(() => visibleChapters.slice()
.reverse()
.find((c) => c.read === false),
[visibleChapters]);
const selectedChapters = useMemo(() => {
if (selection === null) return null;
return visibleChapters.filter((chap) => selection.includes(chap.id));
}, [visibleChapters, selection]);
const handleSelection = (index: number) => {
const chapter = visibleChapters[index];
if (!chapter) return;
if (selection === null) {
setSelection([chapter.id]);
} else if (selection.includes(chapter.id)) {
const newSelection = selection.filter((cid) => cid !== chapter.id);
setSelection(newSelection.length > 0 ? newSelection : null);
} else {
setSelection([...selection, chapter.id]);
}
};
const handleSelectAll = () => {
if (selection === null) return;
setSelection(visibleChapters.map((c) => c.id));
};
const handleClear = () => {
if (selection === null) return;
setSelection(null);
};
const handleFabAction = (action: 'download') => {
if (!selectedChapters || selectedChapters.length === 0) return;
const chapterIds = selectedChapters.map((c) => c.id);
if (action === 'download') {
client.post('/api/v1/download/batch', { chapterIds })
.then(() => makeToast(`${chapterIds.length} ${pluralize(chapterIds.length, 'download')} added`, 'success'))
.then(() => mutate())
.catch(() => makeToast('Error adding downloads', 'error'));
}
};
if (loading) {
return (
<div style={{
margin: '10px auto',
display: 'flex',
justifyContent: 'center',
}}
>
<CircularProgress thickness={5} />
</div>
);
}
const noChaptersFound = chapters.length === 0;
const noChaptersMatchingFilter = !noChaptersFound && visibleChapters.length === 0;
const scrollCache = visibleChapters.map((chapter) => {
const downloadChapter = queue?.find(
(cd) => cd.chapterIndex === chapter.index
&& cd.mangaId === chapter.mangaId,
);
const selected = selection?.includes(chapter.id) ?? null;
return {
chapter,
downloadChapter,
selected,
};
});
return (
<>
<Stack direction="column" sx={{ position: 'relative' }}>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
sx={{
m: 1, mb: 0, mr: 2, minHeight: 40,
}}
>
<Typography variant="h5">
{`${visibleChapters.length} Chapter${visibleChapters.length === 1 ? '' : 's'}`}
</Typography>
{selection === null ? (
<ChaptersToolbarMenu options={options} optionsDispatch={dispatch} />
) : (
<Stack direction="row">
<Button size="small" onClick={handleSelectAll}>Select all</Button>
<Button size="small" onClick={handleClear}>Clear</Button>
</Stack>
)}
</Stack>
{noChaptersFound && (
<EmptyView message="No chapters found" />
)}
{noChaptersMatchingFilter && (
<EmptyView message="No chapters matching filter" />
)}
<StyledVirtuoso
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={visibleChapters.length}
itemContent={(index:number) => (
<ChapterCard
// eslint-disable-next-line react/jsx-props-no-spreading
{...scrollCache[index]}
showChapterNumber={options.showChapterNumber}
triggerChaptersUpdate={() => mutate()}
onSelect={() => handleSelection(index)}
/>
)}
useWindowScroll={window.innerWidth < 900}
overscan={window.innerHeight * 0.5}
/>
</Stack>
{selectedChapters !== null ? (
<SelectionFAB
selectedChapters={selectedChapters}
onAction={handleFabAction}
/>
) : (
firstUnreadChapter && <ResumeFab chapter={firstUnreadChapter} mangaId={mangaId} />
)}
</>
);
};
export default ChapterList;

View File

@@ -0,0 +1,114 @@
/*
* 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 { ArrowDownward, ArrowUpward } from '@mui/icons-material';
import {
Drawer, FormControlLabel, Radio, RadioGroup, Tab, Tabs,
} from '@mui/material';
import { Box } from '@mui/system';
import TabPanel from 'components/util/TabPanel';
import ThreeStateCheckbox from 'components/util/ThreeStateCheckbox';
import React, { useCallback, useState } from 'react';
import { SORT_OPTIONS } from './util';
interface IProps{
open: boolean
onClose: () => void;
options: ChapterListOptions
optionsDispatch: React.Dispatch<ChapterOptionsReducerAction>
}
const TabContent: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<Box sx={{
px: 3, py: 1, display: 'flex', flexDirection: 'column', minHeight: 150,
}}
>
{children}
</Box>
);
const ChapterOptions: React.FC<IProps> = ({
open, onClose, options, optionsDispatch,
}) => {
const [tabNum, setTabNum] = useState(0);
const handleFilterChange = useCallback(
(value: NullAndUndefined<boolean>, name: string) => {
optionsDispatch({ type: 'filter', filterType: name.toLowerCase(), filterValue: value });
}, [],
);
return (
<>
<Drawer
anchor="bottom"
open={open}
onClose={onClose}
PaperProps={{
style: {
maxWidth: 600,
marginLeft: 'auto',
marginRight: 'auto',
minHeight: '150px',
},
}}
>
<Box>
<Tabs
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}>
<TabContent>
<FormControlLabel control={<ThreeStateCheckbox name="Unread" checked={options.unread} onChange={handleFilterChange} />} label="Unread" />
<FormControlLabel control={<ThreeStateCheckbox name="Downloaded" checked={options.downloaded} onChange={handleFilterChange} />} label="Downloaded" />
<FormControlLabel control={<ThreeStateCheckbox name="Bookmarked" checked={options.bookmarked} onChange={handleFilterChange} />} label="Bookmarked" />
</TabContent>
</TabPanel>
<TabPanel index={1} currentIndex={tabNum}>
<TabContent>
{
SORT_OPTIONS.map(([mode, label]) => (
<FormControlLabel
key={mode}
control={(
<Radio
checked={options.sortBy === mode}
checkedIcon={options.reverse ? <ArrowUpward color="primary" /> : <ArrowDownward color="primary" />}
onClick={() => (mode !== options.sortBy
? optionsDispatch({ type: 'sortBy', sortBy: mode })
: optionsDispatch({ type: 'sortReverse' }))}
/>
)}
label={label}
/>
))
}
</TabContent>
</TabPanel>
<TabPanel index={2} currentIndex={tabNum}>
<TabContent>
<RadioGroup onChange={() => optionsDispatch({ type: 'showChapterNumber' })} value={options.showChapterNumber}>
<FormControlLabel label="Source Title" value="title" control={<Radio checked={!options.showChapterNumber} />} />
<FormControlLabel label="Chapter Number" value="chapterNumber" control={<Radio checked={options.showChapterNumber} />} />
</RadioGroup>
</TabContent>
</TabPanel>
</Box>
</Drawer>
</>
);
};
export default ChapterOptions;

View File

@@ -0,0 +1,40 @@
/*
* 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 FilterList from '@mui/icons-material/FilterList';
import { Badge, IconButton } from '@mui/material';
import * as React from 'react';
import ChapterOptions from './ChapterOptions';
import { isFilterActive } from './util';
interface IProps {
options: ChapterListOptions
optionsDispatch: React.Dispatch<ChapterOptionsReducerAction>
}
const ChaptersToolbarMenu = ({ options, optionsDispatch }: IProps) => {
const [open, setOpen] = React.useState(false);
const isFiltered = isFilterActive(options);
return (
<>
<IconButton onClick={() => setOpen(true)}>
<Badge color="primary" variant="dot" invisible={!isFiltered}>
<FilterList />
</Badge>
</IconButton>
<ChapterOptions
open={open}
onClose={() => setOpen(false)}
options={options}
optionsDispatch={optionsDispatch}
/>
</>
);
};
export default ChaptersToolbarMenu;

View File

@@ -0,0 +1,208 @@
/*
* 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 FavoriteIcon from '@mui/icons-material/Favorite';
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
import PublicIcon from '@mui/icons-material/Public';
import { Typography } from '@mui/material';
import IconButton from '@mui/material/IconButton';
import { Theme } from '@mui/material/styles';
import makeStyles from '@mui/styles/makeStyles';
import React from 'react';
import { mutate } from 'swr';
import client from 'util/client';
import useLocalStorage from 'util/useLocalStorage';
const useStyles = (inLibrary: boolean) => makeStyles((theme: Theme) => ({
root: {
width: '100%',
[theme.breakpoints.up('md')]: {
position: 'sticky',
top: '64px',
left: '0px',
width: '50vw',
height: 'calc(100vh - 64px)',
alignSelf: 'flex-start',
overflowY: 'auto',
},
},
top: {
padding: '10px',
// [theme.breakpoints.up('md')]: {
// minWidth: '50%',
// },
},
leftRight: {
display: 'flex',
},
leftSide: {
'& img': {
borderRadius: 4,
maxWidth: '100%',
minWidth: '100%',
height: 'auto',
},
maxWidth: '50%',
// [theme.breakpoints.up('md')]: {
// minWidth: '100px',
// },
},
rightSide: {
marginLeft: 15,
maxWidth: '100%',
'& span': {
fontWeight: '400',
},
[theme.breakpoints.up('lg')]: {
fontSize: '1.3em',
},
},
buttons: {
display: 'flex',
justifyContent: 'space-around',
'& button': {
color: inLibrary ? '#2196f3' : 'inherit',
},
'& a': {
textDecoration: 'none',
color: '#858585',
'& button': {
color: 'inherit',
},
},
},
bottom: {
paddingLeft: '10px',
paddingRight: '10px',
[theme.breakpoints.up('md')]: {
fontSize: '1.2em',
// maxWidth: '50%',
},
[theme.breakpoints.up('lg')]: {
fontSize: '1.3em',
},
},
description: {
'& h4': {
marginTop: '1em',
marginBottom: 0,
},
'& p': {
textAlign: 'justify',
textJustify: 'inter-word',
},
},
genre: {
display: 'flex',
flexWrap: 'wrap',
'& h5': {
border: '2px solid #2196f3',
borderRadius: '1.13em',
marginRight: '1em',
marginTop: 0,
marginBottom: '10px',
padding: '0.3em',
color: '#2196f3',
},
},
}));
interface IProps{
manga: IManga
}
function getSourceName(source: ISource) {
if (source.displayName !== null) {
return source.displayName;
}
return source.id;
}
function getValueOrUnknown(val: string) {
return val || 'UNKNOWN';
}
const MangaDetails: React.FC<IProps> = ({ manga }) => {
const [serverAddress] = useLocalStorage<String>('serverBaseURL', '');
const [useCache] = useLocalStorage<boolean>('useCache', true);
const classes = useStyles(manga.inLibrary)();
const addToLibrary = () => {
mutate(`/api/v1/manga/${manga.id}/?onlineFetch=false`, { ...manga, inLibrary: true }, { revalidate: false });
client.get(`/api/v1/manga/${manga.id}/library/`)
.then(() => mutate(`/api/v1/manga/${manga.id}/?onlineFetch=false`));
};
const removeFromLibrary = () => {
mutate(`/api/v1/manga/${manga.id}/?onlineFetch=false`, { ...manga, inLibrary: false }, { revalidate: false });
client.delete(`/api/v1/manga/${manga.id}/library/`)
.then(() => mutate(`/api/v1/manga/${manga.id}/?onlineFetch=false`));
};
return (
<div className={classes.root}>
<div className={classes.top}>
<div className={classes.leftRight}>
<div className={classes.leftSide}>
<img src={`${serverAddress}${manga.thumbnailUrl}?useCache=${useCache}`} alt="Manga Thumbnail" />
</div>
<div className={classes.rightSide}>
<h1>
{manga.title}
</h1>
<h3>
{'Author: '}
<span>{getValueOrUnknown(manga.author)}</span>
</h3>
<h3>
{'Artist: '}
<span>{getValueOrUnknown(manga.artist)}</span>
</h3>
<h3>
{`Status: ${manga.status}`}
</h3>
<h3>
{`Source: ${getSourceName(manga.source)}`}
</h3>
</div>
</div>
<div className={classes.buttons}>
<div>
<IconButton onClick={manga.inLibrary ? removeFromLibrary : addToLibrary} size="large">
{manga.inLibrary
? <FavoriteIcon sx={{ mr: 1 }} />
: <FavoriteBorderIcon sx={{ mr: 1 }} />}
<Typography sx={{ fontSize: { xs: '0.75em', sm: '0.85em' } }}>
{manga.inLibrary ? 'In Library' : 'Add To Library'}
</Typography>
</IconButton>
</div>
<a href={manga.realUrl} target="_blank" rel="noreferrer">
<IconButton size="large">
<PublicIcon sx={{ mr: 1 }} />
<Typography sx={{ fontSize: { xs: '0.75em', sm: '0.85em' } }}>
Open Site
</Typography>
</IconButton>
</a>
</div>
</div>
<div className={classes.bottom}>
<div className={classes.description}>
<h4>About</h4>
<p>{manga.description}</p>
</div>
<div className={classes.genre}>
{manga.genre.map((g) => <h5 key={g}>{g}</h5>)}
</div>
</div>
</div>
);
};
export default MangaDetails;

View File

@@ -0,0 +1,109 @@
/*
* 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 Label from '@mui/icons-material/Label';
import MoreHoriz from '@mui/icons-material/MoreHoriz';
import Refresh from '@mui/icons-material/Refresh';
import {
IconButton, ListItemIcon, ListItemText, Menu, MenuItem, Tooltip, useMediaQuery, useTheme,
} from '@mui/material';
import CategorySelect from 'components/navbar/action/CategorySelect';
import React, { useState } from 'react';
interface IProps {
manga: IManga;
onRefresh: () => any;
refreshing: boolean;
}
const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
const theme = useTheme();
const isLargeScreen = useMediaQuery(theme.breakpoints.up('sm'));
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClose = () => {
setAnchorEl(null);
};
const [editCategories, setEditCategories] = useState(false);
return (
<>
{isLargeScreen && (
<>
<Tooltip title="Reload data from source">
<IconButton onClick={() => { onRefresh(); }} disabled={refreshing}>
<Refresh />
</IconButton>
</Tooltip>
{manga.inLibrary && (
<Tooltip title="Edit manga categories">
<IconButton onClick={() => { setEditCategories(true); }}>
<Label />
</IconButton>
</Tooltip>
)}
</>
)}
{!isLargeScreen && (
<>
<IconButton
id="chaptersMenuButton"
aria-controls={open ? 'chaptersMenu' : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
onClick={(e) => setAnchorEl(e.currentTarget)}
>
<MoreHoriz />
</IconButton>
<Menu
id="chaptersMenu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{
'aria-labelledby': 'chaptersMenuButton',
}}
>
<MenuItem
onClick={() => { onRefresh(); handleClose(); }}
disabled={refreshing}
>
<ListItemIcon>
<Refresh fontSize="small" />
</ListItemIcon>
<ListItemText>
Reload data from source
</ListItemText>
</MenuItem>
{manga.inLibrary && (
<MenuItem
onClick={() => { setEditCategories(true); handleClose(); }}
>
<ListItemIcon>
<Label fontSize="small" />
</ListItemIcon>
<ListItemText>
Edit manga categories
</ListItemText>
</MenuItem>
)}
</Menu>
</>
)}
<CategorySelect
open={editCategories}
setOpen={setEditCategories}
mangaId={manga.id}
/>
</>
);
};
export default MangaToolbarMenu;

View File

@@ -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 (
<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

@@ -0,0 +1,80 @@
/*
* 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 Download from '@mui/icons-material/Download';
import MoreHoriz from '@mui/icons-material/MoreHoriz';
import {
Fab, ListItemIcon, ListItemText, Menu, MenuItem,
} from '@mui/material';
import { Box } from '@mui/system';
import { pluralize } from 'components/util/helpers';
import React, { useRef, useState } from 'react';
interface SelectionFABProps{
selectedChapters: IChapter[]
onAction: (action: 'download') => void
}
const SelectionFAB: React.FC<SelectionFABProps> = (props) => {
const { selectedChapters, onAction } = props;
const count = selectedChapters.length;
const anchorEl = useRef<HTMLElement>();
const [open, setOpen] = useState(false);
const handleClose = () => setOpen(false);
return (
<Box
sx={{
position: 'fixed', bottom: '2em', right: '3em', pt: 1,
}}
ref={anchorEl}
>
<Fab
variant="extended"
color="primary"
id="selectionMenuButton"
onClick={() => setOpen(true)}
>
{`${count} ${pluralize(count, 'chapter')}`}
<MoreHoriz sx={{ ml: 1 }} />
</Fab>
<Menu
id="selectionMenu"
anchorEl={anchorEl.current}
open={open}
onClose={handleClose}
anchorOrigin={{ horizontal: 'right', vertical: 'top' }}
transformOrigin={{ horizontal: 'right', vertical: 'bottom' }}
MenuListProps={{
'aria-labelledby': 'selectionMenuButton',
}}
>
<MenuItem
onClick={() => { onAction('download'); handleClose(); }}
>
<ListItemIcon>
<Download fontSize="small" />
</ListItemIcon>
<ListItemText>
Download selected
</ListItemText>
</MenuItem>
{/* <MenuItem onClick={() => { onClearSelection(); handleClose(); }}>
<ListItemIcon>
<Clear fontSize="small" />
</ListItemIcon>
<ListItemText>
ClearSelection
</ListItemText>
</MenuItem> */}
</Menu>
</Box>
);
};
export default SelectionFAB;

View File

@@ -0,0 +1,27 @@
/*
* 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 { useCallback, useState } from 'react';
import { mutate } from 'swr';
import { fetcher } from 'util/client';
// eslint-disable-next-line import/prefer-default-export
export const useRefreshManga = (mangaId: string) => {
const [fetchingOnline, setFetchingOnline] = useState(false);
const handleRefresh = useCallback(async () => {
setFetchingOnline(true);
await Promise.all([
fetcher(`/api/v1/manga/${mangaId}/?onlineFetch=true`)
.then((res) => mutate(`/api/v1/manga/${mangaId}/?onlineFetch=false`, res, { revalidate: false })),
fetcher(`/api/v1/manga/${mangaId}/chapters?onlineFetch=true`)
.then((res) => mutate(`/api/v1/manga/${mangaId}/chapters?onlineFetch=false`, res, { revalidate: false })),
]).finally(() => setFetchingOnline(false));
}, [mangaId]);
return [handleRefresh, { loading: fetchingOnline }] as const;
};

View File

@@ -0,0 +1,112 @@
/*
* 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 { useReducerLocalStorage } from 'util/useLocalStorage';
const defaultChapterOptions: ChapterListOptions = {
active: false,
unread: undefined,
downloaded: undefined,
bookmarked: undefined,
reverse: false,
sortBy: 'source',
showChapterNumber: false,
};
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;
}
}
function downloadFilter(downloaded: NullAndUndefined<boolean>,
{ downloaded: chapterDownload }: IChapter) {
switch (downloaded) {
case true:
return chapterDownload;
case false:
return !chapterDownload;
default:
return true;
}
}
function bookmarkedFilter(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)
&& bookmarkedFilter(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 const useChapterOptions = (mangaId: string) => useReducerLocalStorage<
ChapterListOptions,
ChapterOptionsReducerAction
>(
chapterOptionsReducer,
`${mangaId}filterOptions`, defaultChapterOptions,
);
export const SORT_OPTIONS: [ChapterSortMode, string][] = [
['source', 'By Source'],
['fetchedAt', 'By Fetch date'],
];
export const isFilterActive = (options: ChapterListOptions) => {
const { unread, downloaded, bookmarked } = options;
return unread != null || downloaded != null || bookmarked != null;
};