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
|
* 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/. */
|
* 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 FavoriteIcon from '@mui/icons-material/Favorite';
|
||||||
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
|
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
|
||||||
import FilterListIcon from '@mui/icons-material/FilterList';
|
|
||||||
import PublicIcon from '@mui/icons-material/Public';
|
import PublicIcon from '@mui/icons-material/Public';
|
||||||
import React, { useContext, useEffect, useState } from 'react';
|
import { Typography } from '@mui/material';
|
||||||
import NavbarContext from 'components/context/NavbarContext';
|
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 client from 'util/client';
|
||||||
import useLocalStorage from 'util/useLocalStorage';
|
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: {
|
root: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
[theme.breakpoints.up('md')]: {
|
[theme.breakpoints.up('md')]: {
|
||||||
@@ -68,11 +65,7 @@ const useStyles = (inLibrary: string) => makeStyles((theme: Theme) => ({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-around',
|
justifyContent: 'space-around',
|
||||||
'& button': {
|
'& button': {
|
||||||
color: inLibrary === 'In Library' ? '#2196f3' : 'inherit',
|
color: inLibrary ? '#2196f3' : 'inherit',
|
||||||
},
|
|
||||||
'& span': {
|
|
||||||
display: 'block',
|
|
||||||
fontSize: '0.85em',
|
|
||||||
},
|
},
|
||||||
'& a': {
|
'& a': {
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
@@ -120,8 +113,6 @@ const useStyles = (inLibrary: string) => makeStyles((theme: Theme) => ({
|
|||||||
|
|
||||||
interface IProps{
|
interface IProps{
|
||||||
manga: IManga
|
manga: IManga
|
||||||
onRefresh: () => Promise<any>
|
|
||||||
refreshing: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSourceName(source: ISource) {
|
function getSourceName(source: ISource) {
|
||||||
@@ -135,69 +126,23 @@ function getValueOrUnknown(val: string) {
|
|||||||
return val || 'UNKNOWN';
|
return val || 'UNKNOWN';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MangaDetails({ manga, onRefresh, refreshing }: IProps) {
|
const MangaDetails: React.FC<IProps> = ({ manga }) => {
|
||||||
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 [serverAddress] = useLocalStorage<String>('serverBaseURL', '');
|
const [serverAddress] = useLocalStorage<String>('serverBaseURL', '');
|
||||||
const [useCache] = useLocalStorage<boolean>('useCache', true);
|
const [useCache] = useLocalStorage<boolean>('useCache', true);
|
||||||
|
|
||||||
const classes = useStyles(inLibrary)();
|
const classes = useStyles(manga.inLibrary)();
|
||||||
|
|
||||||
function addToLibrary() {
|
const addToLibrary = () => {
|
||||||
// setInLibrary('adding');
|
mutate(`/api/v1/manga/${manga.id}/?onlineFetch=false`, { ...manga, inLibrary: true }, { revalidate: false });
|
||||||
client.get(`/api/v1/manga/${manga.id}/library/`).then(() => {
|
client.get(`/api/v1/manga/${manga.id}/library/`)
|
||||||
setInLibrary('In Library');
|
.then(() => mutate(`/api/v1/manga/${manga.id}/?onlineFetch=false`));
|
||||||
});
|
};
|
||||||
}
|
|
||||||
|
|
||||||
function removeFromLibrary() {
|
const removeFromLibrary = () => {
|
||||||
// setInLibrary('removing');
|
mutate(`/api/v1/manga/${manga.id}/?onlineFetch=false`, { ...manga, inLibrary: false }, { revalidate: false });
|
||||||
client.delete(`/api/v1/manga/${manga.id}/library/`).then(() => {
|
client.delete(`/api/v1/manga/${manga.id}/library/`)
|
||||||
setInLibrary('Add To Library');
|
.then(() => mutate(`/api/v1/manga/${manga.id}/?onlineFetch=false`));
|
||||||
});
|
};
|
||||||
}
|
|
||||||
|
|
||||||
function handleButtonClick() {
|
|
||||||
if (inLibrary === 'Add To Library') {
|
|
||||||
addToLibrary();
|
|
||||||
} else {
|
|
||||||
removeFromLibrary();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.root}>
|
<div className={classes.root}>
|
||||||
@@ -228,17 +173,21 @@ export default function MangaDetails({ manga, onRefresh, refreshing }: IProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className={classes.buttons}>
|
<div className={classes.buttons}>
|
||||||
<div>
|
<div>
|
||||||
<IconButton onClick={() => handleButtonClick()} size="large">
|
<IconButton onClick={manga.inLibrary ? removeFromLibrary : addToLibrary} size="large">
|
||||||
{inLibrary === 'In Library' && <FavoriteIcon />}
|
{manga.inLibrary
|
||||||
{inLibrary !== 'In Library' && <FavoriteBorderIcon />}
|
? <FavoriteIcon sx={{ mr: 1 }} />
|
||||||
<span>{inLibrary}</span>
|
: <FavoriteBorderIcon sx={{ mr: 1 }} />}
|
||||||
|
<Typography sx={{ fontSize: { xs: '0.75em', sm: '0.85em' } }}>
|
||||||
|
{manga.inLibrary ? 'In Library' : 'Add To Library'}
|
||||||
|
</Typography>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
{ /* eslint-disable-next-line react/jsx-no-target-blank */ }
|
<a href={manga.realUrl} target="_blank" rel="noreferrer">
|
||||||
<a href={manga.realUrl} target="_blank">
|
|
||||||
<IconButton size="large">
|
<IconButton size="large">
|
||||||
<PublicIcon />
|
<PublicIcon sx={{ mr: 1 }} />
|
||||||
<span>Open Site</span>
|
<Typography sx={{ fontSize: { xs: '0.75em', sm: '0.85em' } }}>
|
||||||
|
Open Site
|
||||||
|
</Typography>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -254,4 +203,6 @@ export default function MangaDetails({ manga, onRefresh, refreshing }: IProps) {
|
|||||||
</div>
|
</div>
|
||||||
</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
|
* 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/. */
|
* 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,
|
active: false,
|
||||||
unread: undefined,
|
unread: undefined,
|
||||||
downloaded: undefined,
|
downloaded: undefined,
|
||||||
@@ -15,7 +17,7 @@ export const defaultChapterOptions: ChapterListOptions = {
|
|||||||
showChapterNumber: false,
|
showChapterNumber: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function chapterOptionsReducer(state: ChapterListOptions,
|
function chapterOptionsReducer(state: ChapterListOptions,
|
||||||
actions: ChapterOptionsReducerAction)
|
actions: ChapterOptionsReducerAction)
|
||||||
: ChapterListOptions {
|
: ChapterListOptions {
|
||||||
switch (actions.type) {
|
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) {
|
{ downloaded: chapterDownload }: IChapter) {
|
||||||
switch (downloaded) {
|
switch (downloaded) {
|
||||||
case true:
|
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) {
|
{ bookmarked: chapterBookmarked }: IChapter) {
|
||||||
switch (bookmarked) {
|
switch (bookmarked) {
|
||||||
case true:
|
case true:
|
||||||
@@ -80,7 +82,7 @@ export function filterAndSortChapters(chapters: IChapter[], options: ChapterList
|
|||||||
const filtered = options.active
|
const filtered = options.active
|
||||||
? chapters.filter((chp) => unreadFilter(options.unread, chp)
|
? chapters.filter((chp) => unreadFilter(options.unread, chp)
|
||||||
&& downloadFilter(options.downloaded, chp)
|
&& downloadFilter(options.downloaded, chp)
|
||||||
&& bookmarkdFilter(options.bookmarked, chp))
|
&& bookmarkedFilter(options.bookmarked, chp))
|
||||||
: [...chapters];
|
: [...chapters];
|
||||||
const Sorted = options.sortBy === 'fetchedAt'
|
const Sorted = options.sortBy === 'fetchedAt'
|
||||||
? filtered.sort((a, b) => a.fetchedAt - b.fetchedAt)
|
? filtered.sort((a, b) => a.fetchedAt - b.fetchedAt)
|
||||||
@@ -91,9 +93,20 @@ export function filterAndSortChapters(chapters: IChapter[], options: ChapterList
|
|||||||
return Sorted;
|
return Sorted;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findFirstUnreadChapter(chapters: IChapter[]): IChapter | undefined {
|
export const useChapterOptions = (mangaId: string) => useReducerLocalStorage<
|
||||||
for (let index = chapters.length - 1; index >= 0; index--) {
|
ChapterListOptions,
|
||||||
if (!chapters[index].read) return chapters[index];
|
ChapterOptionsReducerAction
|
||||||
}
|
>(
|
||||||
return undefined;
|
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 DarkTheme from 'components/context/DarkTheme';
|
||||||
import ExtensionOutlinedIcon from 'components/util/CustomExtensionOutlinedIcon';
|
import ExtensionOutlinedIcon from 'components/util/CustomExtensionOutlinedIcon';
|
||||||
import { Box } from '@mui/system';
|
import { Box } from '@mui/system';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import DesktopSideBar from './navigation/DesktopSideBar';
|
import DesktopSideBar from './navigation/DesktopSideBar';
|
||||||
import MobileBottomBar from './navigation/MobileBottomBar';
|
import MobileBottomBar from './navigation/MobileBottomBar';
|
||||||
|
|
||||||
@@ -121,13 +122,25 @@ export default function DefaultNavBar() {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
<Typography variant={isMobileWidth ? 'h6' : 'h5'} sx={{ flexGrow: 1 }}>
|
<Typography variant={isMobileWidth ? 'h6' : 'h5'} sx={{ flexGrow: 1 }} noWrap textOverflow="ellipsis">
|
||||||
{title}
|
{title}
|
||||||
</Typography>
|
</Typography>
|
||||||
{action}
|
{action}
|
||||||
|
<div id="navbarToolbar" />
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
{navbar}
|
{navbar}
|
||||||
</Box>
|
</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'];
|
||||||
|
};
|
||||||
@@ -46,6 +46,10 @@ export default function Library() {
|
|||||||
<UpdateChecker />
|
<UpdateChecker />
|
||||||
</>,
|
</>,
|
||||||
);
|
);
|
||||||
|
return () => {
|
||||||
|
setTitle('');
|
||||||
|
setAction(<></>);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// a hack so MangaGrid doesn't stop working. I won't change it in case
|
// a hack so MangaGrid doesn't stop working. I won't change it in case
|
||||||
|
|||||||
@@ -5,38 +5,37 @@
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* 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/. */
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
import React, {
|
import { Warning } from '@mui/icons-material';
|
||||||
useCallback, useEffect, useContext, useState, useRef,
|
import {
|
||||||
} from 'react';
|
CircularProgress, IconButton, Stack, Tooltip,
|
||||||
|
} from '@mui/material';
|
||||||
import { Box } from '@mui/system';
|
import { Box } from '@mui/system';
|
||||||
import MangaDetails from 'components/MangaDetails';
|
|
||||||
import NavbarContext from 'components/context/NavbarContext';
|
import NavbarContext from 'components/context/NavbarContext';
|
||||||
import { fetcher, useQuery } from 'util/client';
|
import ChapterList from 'components/manga/ChapterList';
|
||||||
|
import { useRefreshManga } from 'components/manga/hooks';
|
||||||
|
import MangaDetails from 'components/manga/MangaDetails';
|
||||||
|
import MangaToolbarMenu from 'components/manga/MangaToolbarMenu';
|
||||||
|
import { NavbarToolbar } from 'components/navbar/DefaultNavBar';
|
||||||
|
import EmptyView from 'components/util/EmptyView';
|
||||||
import LoadingPlaceholder from 'components/util/LoadingPlaceholder';
|
import LoadingPlaceholder from 'components/util/LoadingPlaceholder';
|
||||||
import ChapterList from 'components/chapter/ChapterList';
|
import React, {
|
||||||
|
useContext, useEffect, useRef,
|
||||||
|
} from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { useQuery } from 'util/client';
|
||||||
|
|
||||||
const AUTOFETCH_AGE = 60 * 60 * 24; // 24 hours
|
const AUTOFETCH_AGE = 60 * 60 * 24; // 24 hours
|
||||||
|
|
||||||
export default function Manga() {
|
const Manga: React.FC = () => {
|
||||||
const { setTitle } = useContext(NavbarContext);
|
const { setTitle } = useContext(NavbarContext);
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const autofetchedRef = useRef(false);
|
const autofetchedRef = useRef(false);
|
||||||
|
|
||||||
const { data: manga, error, mutate: mutateManga } = useQuery<IManga>(`/api/v1/manga/${id}/?onlineFetch=false`);
|
const {
|
||||||
const { data: chaptersData, mutate: mutateChapters } = useQuery<IChapter[]>(`/api/v1/manga/${id}/chapters?onlineFetch=false`);
|
data: manga, error, loading, isValidating, mutate,
|
||||||
|
} = useQuery<IManga>(`/api/v1/manga/${id}/?onlineFetch=false`);
|
||||||
|
|
||||||
const [fetchingOnline, setFetchingOnline] = useState(false);
|
const [refresh, { loading: refreshing }] = useRefreshManga(id);
|
||||||
const fetchOnline = useCallback(async () => {
|
|
||||||
setFetchingOnline(true);
|
|
||||||
await Promise.all([
|
|
||||||
fetcher(`/api/v1/manga/${id}/?onlineFetch=true`)
|
|
||||||
.then((res) => mutateManga(res, { revalidate: false })),
|
|
||||||
fetcher(`/api/v1/manga/${id}/chapters?onlineFetch=true`)
|
|
||||||
.then((res) => mutateChapters(res, { revalidate: false })),
|
|
||||||
]);
|
|
||||||
setFetchingOnline(false);
|
|
||||||
}, [mutateManga, mutateChapters, id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Automatically fetch manga from source if data is older then 24 hours
|
// Automatically fetch manga from source if data is older then 24 hours
|
||||||
@@ -49,7 +48,7 @@ export default function Manga() {
|
|||||||
&& autofetchedRef.current === false
|
&& autofetchedRef.current === false
|
||||||
) {
|
) {
|
||||||
autofetchedRef.current = true;
|
autofetchedRef.current = true;
|
||||||
fetchOnline();
|
refresh();
|
||||||
}
|
}
|
||||||
}, [manga]);
|
}, [manga]);
|
||||||
|
|
||||||
@@ -57,17 +56,50 @@ export default function Manga() {
|
|||||||
setTitle(manga?.title ?? 'Manga');
|
setTitle(manga?.title ?? 'Manga');
|
||||||
}, [manga?.title]);
|
}, [manga?.title]);
|
||||||
|
|
||||||
|
if (error && !manga) {
|
||||||
|
return (
|
||||||
|
<EmptyView message="Could not load manga" messageExtra={error.message ?? error} />
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: { md: 'flex' }, overflow: 'hidden' }}>
|
<Box sx={{ display: { md: 'flex' }, overflow: 'hidden' }}>
|
||||||
{!manga && !error && <LoadingPlaceholder />}
|
<NavbarToolbar>
|
||||||
{manga && (
|
<Stack direction="row" alignItems="center">
|
||||||
<MangaDetails
|
{error && !isValidating && !refreshing && (
|
||||||
refreshing={fetchingOnline}
|
<Tooltip title={(
|
||||||
manga={manga}
|
<>
|
||||||
onRefresh={fetchOnline}
|
Could not fetch manga data
|
||||||
/>
|
<br />
|
||||||
)}
|
{error.message ?? error}
|
||||||
<ChapterList id={id} chaptersData={chaptersData} onRefresh={() => mutateChapters()} />
|
</>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<IconButton onClick={() => mutate()}>
|
||||||
|
<Warning color="error" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{(manga && (refreshing || isValidating)) && (
|
||||||
|
<IconButton disabled>
|
||||||
|
<CircularProgress size={16} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
{manga && (
|
||||||
|
<MangaToolbarMenu
|
||||||
|
manga={manga}
|
||||||
|
onRefresh={refresh}
|
||||||
|
refreshing={refreshing}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</NavbarToolbar>
|
||||||
|
|
||||||
|
{loading && <LoadingPlaceholder />}
|
||||||
|
|
||||||
|
{manga && <MangaDetails manga={manga} />}
|
||||||
|
<ChapterList mangaId={id} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default Manga;
|
||||||
|
|||||||
@@ -143,6 +143,10 @@ export default function SourceMangas(props: { popular: boolean }) {
|
|||||||
</>
|
</>
|
||||||
,
|
,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
setAction(<></>);
|
||||||
|
};
|
||||||
}, [isConfigurable]);
|
}, [isConfigurable]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
1
src/typings.d.ts
vendored
1
src/typings.d.ts
vendored
@@ -91,6 +91,7 @@ interface IManga {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface IChapter {
|
interface IChapter {
|
||||||
|
id: number
|
||||||
url: string
|
url: string
|
||||||
name: string
|
name: string
|
||||||
uploadDate: number
|
uploadDate: number
|
||||||
|
|||||||
Reference in New Issue
Block a user