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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
241
src/components/manga/ChapterCard.tsx
Normal file
241
src/components/manga/ChapterCard.tsx
Normal 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;
|
||||
218
src/components/manga/ChapterList.tsx
Normal file
218
src/components/manga/ChapterList.tsx
Normal 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;
|
||||
114
src/components/manga/ChapterOptions.tsx
Normal file
114
src/components/manga/ChapterOptions.tsx
Normal 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;
|
||||
40
src/components/manga/ChaptersToolbarMenu.tsx
Normal file
40
src/components/manga/ChaptersToolbarMenu.tsx
Normal 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;
|
||||
@@ -5,22 +5,19 @@
|
||||
* 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 makeStyles from '@mui/styles/makeStyles';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { Theme } from '@mui/material/styles';
|
||||
import FavoriteIcon from '@mui/icons-material/Favorite';
|
||||
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
|
||||
import FilterListIcon from '@mui/icons-material/FilterList';
|
||||
import PublicIcon from '@mui/icons-material/Public';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import NavbarContext from 'components/context/NavbarContext';
|
||||
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';
|
||||
import Refresh from '@mui/icons-material/Refresh';
|
||||
import CategorySelect from './navbar/action/CategorySelect';
|
||||
import LoadingIconButton from './atoms/LoadingIconButton';
|
||||
|
||||
const useStyles = (inLibrary: string) => makeStyles((theme: Theme) => ({
|
||||
const useStyles = (inLibrary: boolean) => makeStyles((theme: Theme) => ({
|
||||
root: {
|
||||
width: '100%',
|
||||
[theme.breakpoints.up('md')]: {
|
||||
@@ -68,11 +65,7 @@ const useStyles = (inLibrary: string) => makeStyles((theme: Theme) => ({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-around',
|
||||
'& button': {
|
||||
color: inLibrary === 'In Library' ? '#2196f3' : 'inherit',
|
||||
},
|
||||
'& span': {
|
||||
display: 'block',
|
||||
fontSize: '0.85em',
|
||||
color: inLibrary ? '#2196f3' : 'inherit',
|
||||
},
|
||||
'& a': {
|
||||
textDecoration: 'none',
|
||||
@@ -120,8 +113,6 @@ const useStyles = (inLibrary: string) => makeStyles((theme: Theme) => ({
|
||||
|
||||
interface IProps{
|
||||
manga: IManga
|
||||
onRefresh: () => Promise<any>
|
||||
refreshing: boolean
|
||||
}
|
||||
|
||||
function getSourceName(source: ISource) {
|
||||
@@ -135,69 +126,23 @@ function getValueOrUnknown(val: string) {
|
||||
return val || 'UNKNOWN';
|
||||
}
|
||||
|
||||
export default function MangaDetails({ manga, onRefresh, refreshing }: IProps) {
|
||||
const { setAction } = useContext(NavbarContext);
|
||||
|
||||
const [inLibrary, setInLibrary] = useState<string>(
|
||||
manga.inLibrary ? 'In Library' : 'Add To Library',
|
||||
);
|
||||
|
||||
const [categoryDialogOpen, setCategoryDialogOpen] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
setAction(
|
||||
<>
|
||||
<LoadingIconButton loading={refreshing} onClick={onRefresh}>
|
||||
<Refresh />
|
||||
</LoadingIconButton>
|
||||
{inLibrary === 'In Library' && (
|
||||
<>
|
||||
<IconButton
|
||||
onClick={() => setCategoryDialogOpen(true)}
|
||||
aria-label="display more actions"
|
||||
edge="end"
|
||||
color="inherit"
|
||||
size="large"
|
||||
>
|
||||
<FilterListIcon />
|
||||
</IconButton>
|
||||
<CategorySelect
|
||||
open={categoryDialogOpen}
|
||||
setOpen={setCategoryDialogOpen}
|
||||
mangaId={manga.id}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>,
|
||||
);
|
||||
}, [inLibrary, categoryDialogOpen, refreshing, onRefresh]);
|
||||
|
||||
const MangaDetails: React.FC<IProps> = ({ manga }) => {
|
||||
const [serverAddress] = useLocalStorage<String>('serverBaseURL', '');
|
||||
const [useCache] = useLocalStorage<boolean>('useCache', true);
|
||||
|
||||
const classes = useStyles(inLibrary)();
|
||||
const classes = useStyles(manga.inLibrary)();
|
||||
|
||||
function addToLibrary() {
|
||||
// setInLibrary('adding');
|
||||
client.get(`/api/v1/manga/${manga.id}/library/`).then(() => {
|
||||
setInLibrary('In Library');
|
||||
});
|
||||
}
|
||||
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`));
|
||||
};
|
||||
|
||||
function removeFromLibrary() {
|
||||
// setInLibrary('removing');
|
||||
client.delete(`/api/v1/manga/${manga.id}/library/`).then(() => {
|
||||
setInLibrary('Add To Library');
|
||||
});
|
||||
}
|
||||
|
||||
function handleButtonClick() {
|
||||
if (inLibrary === 'Add To Library') {
|
||||
addToLibrary();
|
||||
} else {
|
||||
removeFromLibrary();
|
||||
}
|
||||
}
|
||||
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}>
|
||||
@@ -228,17 +173,21 @@ export default function MangaDetails({ manga, onRefresh, refreshing }: IProps) {
|
||||
</div>
|
||||
<div className={classes.buttons}>
|
||||
<div>
|
||||
<IconButton onClick={() => handleButtonClick()} size="large">
|
||||
{inLibrary === 'In Library' && <FavoriteIcon />}
|
||||
{inLibrary !== 'In Library' && <FavoriteBorderIcon />}
|
||||
<span>{inLibrary}</span>
|
||||
<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>
|
||||
{ /* eslint-disable-next-line react/jsx-no-target-blank */ }
|
||||
<a href={manga.realUrl} target="_blank">
|
||||
<a href={manga.realUrl} target="_blank" rel="noreferrer">
|
||||
<IconButton size="large">
|
||||
<PublicIcon />
|
||||
<span>Open Site</span>
|
||||
<PublicIcon sx={{ mr: 1 }} />
|
||||
<Typography sx={{ fontSize: { xs: '0.75em', sm: '0.85em' } }}>
|
||||
Open Site
|
||||
</Typography>
|
||||
</IconButton>
|
||||
</a>
|
||||
</div>
|
||||
@@ -254,4 +203,6 @@ export default function MangaDetails({ manga, onRefresh, refreshing }: IProps) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default MangaDetails;
|
||||
109
src/components/manga/MangaToolbarMenu.tsx
Normal file
109
src/components/manga/MangaToolbarMenu.tsx
Normal 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;
|
||||
80
src/components/manga/SelectionFAB.tsx
Normal file
80
src/components/manga/SelectionFAB.tsx
Normal 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;
|
||||
27
src/components/manga/hooks.ts
Normal file
27
src/components/manga/hooks.ts
Normal 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;
|
||||
};
|
||||
@@ -5,7 +5,9 @@
|
||||
* 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 = {
|
||||
import { useReducerLocalStorage } from 'util/useLocalStorage';
|
||||
|
||||
const defaultChapterOptions: ChapterListOptions = {
|
||||
active: false,
|
||||
unread: undefined,
|
||||
downloaded: undefined,
|
||||
@@ -15,7 +17,7 @@ export const defaultChapterOptions: ChapterListOptions = {
|
||||
showChapterNumber: false,
|
||||
};
|
||||
|
||||
export function chapterOptionsReducer(state: ChapterListOptions,
|
||||
function chapterOptionsReducer(state: ChapterListOptions,
|
||||
actions: ChapterOptionsReducerAction)
|
||||
: ChapterListOptions {
|
||||
switch (actions.type) {
|
||||
@@ -51,7 +53,7 @@ export function unreadFilter(unread: NullAndUndefined<boolean>, { read: isChapte
|
||||
}
|
||||
}
|
||||
|
||||
export function downloadFilter(downloaded: NullAndUndefined<boolean>,
|
||||
function downloadFilter(downloaded: NullAndUndefined<boolean>,
|
||||
{ downloaded: chapterDownload }: IChapter) {
|
||||
switch (downloaded) {
|
||||
case true:
|
||||
@@ -63,7 +65,7 @@ export function downloadFilter(downloaded: NullAndUndefined<boolean>,
|
||||
}
|
||||
}
|
||||
|
||||
export function bookmarkdFilter(bookmarked: NullAndUndefined<boolean>,
|
||||
function bookmarkedFilter(bookmarked: NullAndUndefined<boolean>,
|
||||
{ bookmarked: chapterBookmarked }: IChapter) {
|
||||
switch (bookmarked) {
|
||||
case true:
|
||||
@@ -80,7 +82,7 @@ export function filterAndSortChapters(chapters: IChapter[], options: ChapterList
|
||||
const filtered = options.active
|
||||
? chapters.filter((chp) => unreadFilter(options.unread, chp)
|
||||
&& downloadFilter(options.downloaded, chp)
|
||||
&& bookmarkdFilter(options.bookmarked, chp))
|
||||
&& bookmarkedFilter(options.bookmarked, chp))
|
||||
: [...chapters];
|
||||
const Sorted = options.sortBy === 'fetchedAt'
|
||||
? filtered.sort((a, b) => a.fetchedAt - b.fetchedAt)
|
||||
@@ -91,9 +93,20 @@ export function filterAndSortChapters(chapters: IChapter[], options: ChapterList
|
||||
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;
|
||||
}
|
||||
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;
|
||||
};
|
||||
@@ -28,6 +28,7 @@ import NavBarContext from 'components/context/NavbarContext';
|
||||
import DarkTheme from 'components/context/DarkTheme';
|
||||
import ExtensionOutlinedIcon from 'components/util/CustomExtensionOutlinedIcon';
|
||||
import { Box } from '@mui/system';
|
||||
import { createPortal } from 'react-dom';
|
||||
import DesktopSideBar from './navigation/DesktopSideBar';
|
||||
import MobileBottomBar from './navigation/MobileBottomBar';
|
||||
|
||||
@@ -121,13 +122,25 @@ export default function DefaultNavBar() {
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
<Typography variant={isMobileWidth ? 'h6' : 'h5'} sx={{ flexGrow: 1 }}>
|
||||
<Typography variant={isMobileWidth ? 'h6' : 'h5'} sx={{ flexGrow: 1 }} noWrap textOverflow="ellipsis">
|
||||
{title}
|
||||
</Typography>
|
||||
{action}
|
||||
<div id="navbarToolbar" />
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
{navbar}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface INavbarToolbarProps {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export const NavbarToolbar: React.FC<INavbarToolbarProps> = ({ children }) => {
|
||||
const container = document.getElementById('navbarToolbar');
|
||||
if (!container) return null;
|
||||
|
||||
return createPortal(children, container);
|
||||
};
|
||||
|
||||
14
src/components/util/helpers.ts
Normal file
14
src/components/util/helpers.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* 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/. */
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const pluralize = (count: number, input: string | { one: string, many: string }) => {
|
||||
if (typeof input === 'string') {
|
||||
return `${input}${count === 1 ? '' : 's'}`;
|
||||
}
|
||||
return input[count === 1 ? 'one' : 'many'];
|
||||
};
|
||||
Reference in New Issue
Block a user