Feature/batch chapter download (#191)

* Add NavbarToolbar component for rendering items in toolbar through portal

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

* Fix warning

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

* Add space to bookmark icon

* Clear actions when leaving source mangas page

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

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

* Move chapters filter to ChapterList

* Fixup error display overlapping with loaded content when revalidation fails

* Fixup spacing around ChapterList title

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

* Prevent title wrapping

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

View File

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

View File

@@ -1,150 +0,0 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import React, {
useState, useEffect, useCallback, useMemo, useRef,
} from 'react';
import { Box, styled } from '@mui/system';
import { Virtuoso } from 'react-virtuoso';
import Typography from '@mui/material/Typography';
import { CircularProgress, Stack } from '@mui/material';
import makeToast from 'components/util/Toast';
import ChapterOptions from 'components/chapter/ChapterOptions';
import ChapterCard from 'components/chapter/ChapterCard';
import { useReducerLocalStorage } from 'util/useLocalStorage';
import {
chapterOptionsReducer, defaultChapterOptions, findFirstUnreadChapter,
filterAndSortChapters,
} from 'components/chapter/util';
import ResumeFab from 'components/chapter/ResumeFAB';
import useSubscription from 'components/library/useSubscription';
const CustomVirtuoso = styled(Virtuoso)(({ theme }) => ({
listStyle: 'none',
padding: 0,
minHeight: '200px',
[theme.breakpoints.up('md')]: {
width: '50vw',
// 64px for the Appbar, 48px for the ChapterCount Header
height: 'calc(100vh - 64px - 48px)',
margin: 0,
},
}));
interface IProps {
id: string
chaptersData: IChapter[] | undefined
onRefresh: () => void;
}
export default function ChapterList({ id, chaptersData, onRefresh }: IProps) {
const noChaptersFound = chaptersData?.length === 0;
const chapters = useMemo(() => chaptersData ?? [], [chaptersData]);
const [firstUnreadChapter, setFirstUnreadChapter] = useState<IChapter>();
const [filteredChapters, setFilteredChapters] = useState<IChapter[]>([]);
// eslint-disable-next-line max-len
const [options, optionsDispatch] = useReducerLocalStorage<ChapterListOptions, ChapterOptionsReducerAction>(
chapterOptionsReducer, `${id}filterOptions`, defaultChapterOptions,
);
const prevQueueRef = useRef<IDownloadChapter[]>();
const queue = useSubscription<IQueue>('/api/v1/downloads').data?.queue;
const downloadStatusStringFor = useCallback((chapter: IChapter) => {
let rtn = '';
if (chapter.downloaded) {
rtn = ' • Downloaded';
}
queue?.forEach((q) => {
if (chapter.index === q.chapterIndex && chapter.mangaId === q.mangaId) {
rtn = ` • Downloading (${(q.progress * 100).toFixed(2)}%)`;
}
});
return rtn;
}, [queue]);
useEffect(() => {
if (prevQueueRef.current && queue) {
const prevQueue = prevQueueRef.current;
const changedDownloads = queue.filter((cd) => {
const prevChapterDownload = prevQueue
.find((pcd) => cd.chapterIndex === pcd.chapterIndex
&& cd.mangaId === pcd.mangaId);
if (!prevChapterDownload) return true;
return cd.state !== prevChapterDownload.state;
});
if (changedDownloads.length > 0) {
onRefresh();
}
}
prevQueueRef.current = queue;
}, [queue]);
useEffect(() => {
const filtered = filterAndSortChapters(chapters, options);
setFilteredChapters(filtered);
setFirstUnreadChapter(findFirstUnreadChapter(filtered));
}, [options, chapters]);
useEffect(() => {
if (noChaptersFound) {
makeToast('No chapters found', 'warning');
}
}, [noChaptersFound]);
if (chapters.length === 0 || noChaptersFound) {
return (
<div style={{
margin: '10px auto',
display: 'flex',
justifyContent: 'center',
}}
>
<CircularProgress thickness={5} />
</div>
);
}
return (
<>
<Stack direction="column">
<Box sx={{
display: 'flex', justifyContent: 'space-between', px: 1.5, mt: 1,
}}
>
<Typography variant="h5">
{`${filteredChapters.length} Chapters`}
</Typography>
<ChapterOptions options={options} optionsDispatch={optionsDispatch} />
</Box>
<CustomVirtuoso
style={{ // override Virtuoso default values and set them with class
height: 'undefined',
// 900 is the md breakpoint in MUI
overflowY: window.innerWidth < 900 ? 'visible' : 'auto',
}}
totalCount={filteredChapters.length}
itemContent={(index:number) => (
<ChapterCard
showChapterNumber={options.showChapterNumber}
chapter={filteredChapters[index]}
downloadStatusString={downloadStatusStringFor(filteredChapters[index])}
triggerChaptersUpdate={onRefresh}
/>
)}
useWindowScroll={window.innerWidth < 900}
overscan={window.innerHeight * 0.5}
/>
</Stack>
{firstUnreadChapter && <ResumeFab chapter={firstUnreadChapter} mangaId={id} />}
</>
);
}

View File

@@ -1,119 +0,0 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import React, { useState, useCallback } from 'react';
import FilterListIcon from '@mui/icons-material/FilterList';
import {
Drawer, FormControlLabel, IconButton, Typography, Tab, Tabs, Radio, RadioGroup, Stack,
} from '@mui/material';
import ThreeStateCheckbox from 'components/util/ThreeStateCheckbox';
import { Box } from '@mui/system';
import { ArrowDownward, ArrowUpward } from '@mui/icons-material';
import TabPanel from 'components/util/TabPanel';
interface IProps{
options: ChapterListOptions
optionsDispatch: React.Dispatch<ChapterOptionsReducerAction>
}
const SortTab: [ChapterSortMode, string][] = [['source', 'By Source'], ['fetchedAt', 'By Fetch date']];
export default function ChapterOptions(props: IProps) {
const { options, optionsDispatch } = props;
const [filtersOpen, setFiltersOpen] = useState(false);
const [tabNum, setTabNum] = useState(0);
const filterOptions = useCallback(
(value: NullAndUndefined<boolean>, name: string) => {
optionsDispatch({ type: 'filter', filterType: name.toLowerCase(), filterValue: value });
}, [],
);
return (
<>
<IconButton
onClick={() => setFiltersOpen(!filtersOpen)}
color={options.active ? 'warning' : 'default'}
>
<FilterListIcon />
</IconButton>
<Drawer
anchor="bottom"
open={filtersOpen}
onClose={() => setFiltersOpen(false)}
PaperProps={{
style: {
maxWidth: 600,
padding: '1em',
marginLeft: 'auto',
marginRight: 'auto',
minHeight: '150px',
},
}}
>
<Box>
<Tabs
key={tabNum}
value={tabNum}
variant="fullWidth"
onChange={(e, newTab) => setTabNum(newTab)}
indicatorColor="primary"
textColor="primary"
>
<Tab value={0} label="Filter" />
<Tab value={1} label="Sort" />
<Tab value={2} label="Display" />
</Tabs>
<TabPanel index={0} currentIndex={tabNum}>
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '150px' }}>
<FormControlLabel control={<ThreeStateCheckbox name="Unread" checked={options.unread} onChange={filterOptions} />} label="Unread" />
<FormControlLabel control={<ThreeStateCheckbox name="Downloaded" checked={options.downloaded} onChange={filterOptions} />} label="Downloaded" />
<FormControlLabel control={<ThreeStateCheckbox name="Bookmarked" checked={options.bookmarked} onChange={filterOptions} />} label="Bookmarked" />
</Box>
</TabPanel>
<TabPanel index={1} currentIndex={tabNum}>
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '150px' }}>
{
SortTab.map((item) => (
<Stack
direction="row"
alignItems="center"
spacing="2"
sx={{ py: 1, height: 42 }}
onClick={() => (item[0] !== options.sortBy
? optionsDispatch({ type: 'sortBy', sortBy: item[0] })
: optionsDispatch({ type: 'sortReverse' }))}
>
<Box sx={{ height: 24, width: 24 }}>
{
options.sortBy === item[0]
&& (options.reverse
? (<ArrowUpward color="primary" />) : (<ArrowDownward color="primary" />))
}
</Box>
<Typography>{item[1]}</Typography>
</Stack>
))
}
</Box>
</TabPanel>
<TabPanel index={2} currentIndex={tabNum}>
<Stack flexDirection="column" sx={{ minHeight: '150px' }}>
<RadioGroup name="chapter-title-display" onChange={() => optionsDispatch({ type: 'showChapterNumber' })} value={options.showChapterNumber}>
<FormControlLabel label="By Source Title" value="title" control={<Radio checked={!options.showChapterNumber} />} />
<FormControlLabel label="By Chapter Number" value="chapterNumber" control={<Radio checked={options.showChapterNumber} />} />
</RadioGroup>
</Stack>
</TabPanel>
</Box>
</Drawer>
</>
);
}

View File

@@ -0,0 +1,241 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import BookmarkIcon from '@mui/icons-material/Bookmark';
import BookmarkAdd from '@mui/icons-material/BookmarkAdd';
import BookmarkRemove from '@mui/icons-material/BookmarkRemove';
import CheckBoxOutlineBlank from '@mui/icons-material/CheckBoxOutlineBlank';
import Delete from '@mui/icons-material/Delete';
import Done from '@mui/icons-material/Done';
import DoneAll from '@mui/icons-material/DoneAll';
import Download from '@mui/icons-material/Download';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import RemoveDone from '@mui/icons-material/RemoveDone';
import {
LinearProgress,
Checkbox, ListItemIcon, ListItemText, Stack,
} from '@mui/material';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import IconButton from '@mui/material/IconButton';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { useTheme } from '@mui/material/styles';
import Typography from '@mui/material/Typography';
import React from 'react';
import { Link } from 'react-router-dom';
import client from 'util/client';
interface IProps{
chapter: IChapter
triggerChaptersUpdate: () => void
downloadChapter: IDownloadChapter | undefined
showChapterNumber: boolean
onSelect: (selected: boolean) => void
selected: boolean | null
}
const ChapterCard: React.FC<IProps> = (props: IProps) => {
const theme = useTheme();
const {
chapter, triggerChaptersUpdate, downloadChapter: dc, showChapterNumber, onSelect, selected,
} = props;
const isSelecting = selected !== null;
const dateStr = chapter.uploadDate && new Date(chapter.uploadDate).toLocaleDateString();
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const handleMenuClick = (event: React.MouseEvent<HTMLButtonElement>) => {
// prevent parent tags from getting the event
event.stopPropagation();
event.preventDefault();
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const sendChange = (key: string, value: any) => {
handleClose();
const formData = new FormData();
formData.append(key, value);
if (key === 'read') { formData.append('lastPageRead', '1'); }
client.patch(`/api/v1/manga/${chapter.mangaId}/chapter/${chapter.index}`, formData)
.then(() => triggerChaptersUpdate());
};
const downloadChapter = () => {
client.get(`/api/v1/download/${chapter.mangaId}/chapter/${chapter.index}`);
handleClose();
};
const deleteChapter = () => {
client.delete(`/api/v1/manga/${chapter.mangaId}/chapter/${chapter.index}`)
.then(() => triggerChaptersUpdate());
handleClose();
};
const handleSelect = () => {
onSelect(true);
handleClose();
};
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (isSelecting) {
event.preventDefault();
event.stopPropagation();
onSelect(!selected);
}
};
const isDownloaded = chapter.downloaded;
const canBeDownloaded = !chapter.downloaded && dc === undefined;
return (
<li>
<Card
sx={{
position: 'relative',
margin: 1,
':hover': {
backgroundColor: 'action.hover',
transition: 'background-color 100ms cubic-bezier(0.4, 0, 0.2, 1) 0ms',
cursor: 'pointer',
},
':active': {
backgroundColor: 'action.selected',
transition: 'background-color 100ms cubic-bezier(0.4, 0, 0.2, 1) 0ms',
},
}}
>
<Link
to={`/manga/${chapter.mangaId}/chapter/${chapter.index}`}
style={{
textDecoration: 'none',
color: theme.palette.text[chapter.read ? 'disabled' : 'primary'],
}}
onClick={handleClick}
>
<CardContent
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: 2,
'&:last-child': { pb: 2 },
}}
>
<Stack direction="column" flex={1}>
<Typography variant="h5" component="h2">
{chapter.bookmarked && (
<BookmarkIcon color="primary" sx={{ mr: 0.5, position: 'relative', top: '0.15em' }} />
)}
{ showChapterNumber ? `Chapter ${chapter.chapterNumber}` : chapter.name}
</Typography>
<Typography variant="caption">
{chapter.scanlator}
</Typography>
<Typography variant="caption">
{dateStr}
{isDownloaded && ' • Downloaded'}
{dc && ` • Downloading (${(dc.progress * 100).toFixed(2)}%)`}
</Typography>
</Stack>
{selected === null ? (
<IconButton aria-label="more" onClick={handleMenuClick} size="large">
<MoreVertIcon />
</IconButton>
) : (
<Checkbox checked={selected} />
)}
</CardContent>
</Link>
{dc != null && (
<LinearProgress
sx={{
position: 'absolute', bottom: 0, width: '100%', opacity: 0.5,
}}
variant="determinate"
value={dc.progress * 100}
color="inherit"
/>
)}
<Menu
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={handleClose}
>
<MenuItem onClick={handleSelect}>
<ListItemIcon>
<CheckBoxOutlineBlank fontSize="small" />
</ListItemIcon>
<ListItemText>
Select
</ListItemText>
</MenuItem>
{isDownloaded && (
<MenuItem onClick={deleteChapter}>
<ListItemIcon>
<Delete fontSize="small" />
</ListItemIcon>
<ListItemText>
Delete
</ListItemText>
</MenuItem>
)}
{canBeDownloaded && (
<MenuItem onClick={downloadChapter}>
<ListItemIcon>
<Download fontSize="small" />
</ListItemIcon>
<ListItemText>
Download
</ListItemText>
</MenuItem>
) }
<MenuItem onClick={() => sendChange('bookmarked', !chapter.bookmarked)}>
<ListItemIcon>
{chapter.bookmarked && <BookmarkRemove fontSize="small" />}
{!chapter.bookmarked && <BookmarkAdd fontSize="small" />}
</ListItemIcon>
<ListItemText>
{chapter.bookmarked && 'Remove bookmark'}
{!chapter.bookmarked && 'Add bookmark'}
</ListItemText>
</MenuItem>
<MenuItem onClick={() => sendChange('read', !chapter.read)}>
<ListItemIcon>
{chapter.read && <RemoveDone fontSize="small" />}
{!chapter.read && <Done fontSize="small" />}
</ListItemIcon>
<ListItemText>
{chapter.read && 'Mark as unread'}
{!chapter.read && 'Mark as read'}
</ListItemText>
</MenuItem>
<MenuItem onClick={() => sendChange('markPrevRead', true)}>
<ListItemIcon>
<DoneAll fontSize="small" />
</ListItemIcon>
<ListItemText>
Mark previous as Read
</ListItemText>
</MenuItem>
</Menu>
</Card>
</li>
);
};
export default ChapterCard;

View File

@@ -0,0 +1,218 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import {
Button, CircularProgress, Stack,
} from '@mui/material';
import Typography from '@mui/material/Typography';
import { styled } from '@mui/system';
import useSubscription from 'components/library/useSubscription';
import ChapterCard from 'components/manga/ChapterCard';
import ResumeFab from 'components/manga/ResumeFAB';
import { filterAndSortChapters, useChapterOptions } from 'components/manga/util';
import EmptyView from 'components/util/EmptyView';
import { pluralize } from 'components/util/helpers';
import makeToast from 'components/util/Toast';
import React, {
useEffect, useMemo, useRef, useState,
} from 'react';
import { Virtuoso } from 'react-virtuoso';
import client, { useQuery } from 'util/client';
import ChaptersToolbarMenu from './ChaptersToolbarMenu';
import SelectionFAB from './SelectionFAB';
const StyledVirtuoso = styled(Virtuoso)(({ theme }) => ({
listStyle: 'none',
padding: 0,
minHeight: '200px',
[theme.breakpoints.up('md')]: {
width: '50vw',
// 64px for the Appbar, 48px for the ChapterCount Header
height: 'calc(100vh - 64px - 48px)',
margin: 0,
},
}));
interface IProps {
mangaId: string
}
const ChapterList: React.FC<IProps> = ({ mangaId }) => {
const [selection, setSelection] = useState<number[] | null>(null);
const prevQueueRef = useRef<IDownloadChapter[]>();
const queue = useSubscription<IQueue>('/api/v1/downloads').data?.queue;
const [options, dispatch] = useChapterOptions(mangaId);
const {
data: chaptersData,
mutate,
loading,
} = useQuery<IChapter[]>(`/api/v1/manga/${mangaId}/chapters?onlineFetch=false`);
const chapters = useMemo(() => chaptersData ?? [], [chaptersData]);
useEffect(() => {
if (prevQueueRef.current && queue) {
const prevQueue = prevQueueRef.current;
const changedDownloads = queue.filter((cd) => {
const prevChapterDownload = prevQueue
.find((pcd) => cd.chapterIndex === pcd.chapterIndex
&& cd.mangaId === pcd.mangaId);
if (!prevChapterDownload) return true;
return cd.state !== prevChapterDownload.state;
});
if (changedDownloads.length > 0) {
mutate();
}
}
prevQueueRef.current = queue;
}, [queue]);
const visibleChapters = useMemo(() => filterAndSortChapters(chapters, options), //
[chapters, options]);
const firstUnreadChapter = useMemo(() => visibleChapters.slice()
.reverse()
.find((c) => c.read === false),
[visibleChapters]);
const selectedChapters = useMemo(() => {
if (selection === null) return null;
return visibleChapters.filter((chap) => selection.includes(chap.id));
}, [visibleChapters, selection]);
const handleSelection = (index: number) => {
const chapter = visibleChapters[index];
if (!chapter) return;
if (selection === null) {
setSelection([chapter.id]);
} else if (selection.includes(chapter.id)) {
const newSelection = selection.filter((cid) => cid !== chapter.id);
setSelection(newSelection.length > 0 ? newSelection : null);
} else {
setSelection([...selection, chapter.id]);
}
};
const handleSelectAll = () => {
if (selection === null) return;
setSelection(visibleChapters.map((c) => c.id));
};
const handleClear = () => {
if (selection === null) return;
setSelection(null);
};
const handleFabAction = (action: 'download') => {
if (!selectedChapters || selectedChapters.length === 0) return;
const chapterIds = selectedChapters.map((c) => c.id);
if (action === 'download') {
client.post('/api/v1/download/batch', { chapterIds })
.then(() => makeToast(`${chapterIds.length} ${pluralize(chapterIds.length, 'download')} added`, 'success'))
.then(() => mutate())
.catch(() => makeToast('Error adding downloads', 'error'));
}
};
if (loading) {
return (
<div style={{
margin: '10px auto',
display: 'flex',
justifyContent: 'center',
}}
>
<CircularProgress thickness={5} />
</div>
);
}
const noChaptersFound = chapters.length === 0;
const noChaptersMatchingFilter = !noChaptersFound && visibleChapters.length === 0;
const scrollCache = visibleChapters.map((chapter) => {
const downloadChapter = queue?.find(
(cd) => cd.chapterIndex === chapter.index
&& cd.mangaId === chapter.mangaId,
);
const selected = selection?.includes(chapter.id) ?? null;
return {
chapter,
downloadChapter,
selected,
};
});
return (
<>
<Stack direction="column" sx={{ position: 'relative' }}>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
sx={{
m: 1, mb: 0, mr: 2, minHeight: 40,
}}
>
<Typography variant="h5">
{`${visibleChapters.length} Chapter${visibleChapters.length === 1 ? '' : 's'}`}
</Typography>
{selection === null ? (
<ChaptersToolbarMenu options={options} optionsDispatch={dispatch} />
) : (
<Stack direction="row">
<Button size="small" onClick={handleSelectAll}>Select all</Button>
<Button size="small" onClick={handleClear}>Clear</Button>
</Stack>
)}
</Stack>
{noChaptersFound && (
<EmptyView message="No chapters found" />
)}
{noChaptersMatchingFilter && (
<EmptyView message="No chapters matching filter" />
)}
<StyledVirtuoso
style={{ // override Virtuoso default values and set them with class
height: 'undefined',
// 900 is the md breakpoint in MUI
overflowY: window.innerWidth < 900 ? 'visible' : 'auto',
}}
totalCount={visibleChapters.length}
itemContent={(index:number) => (
<ChapterCard
// eslint-disable-next-line react/jsx-props-no-spreading
{...scrollCache[index]}
showChapterNumber={options.showChapterNumber}
triggerChaptersUpdate={() => mutate()}
onSelect={() => handleSelection(index)}
/>
)}
useWindowScroll={window.innerWidth < 900}
overscan={window.innerHeight * 0.5}
/>
</Stack>
{selectedChapters !== null ? (
<SelectionFAB
selectedChapters={selectedChapters}
onAction={handleFabAction}
/>
) : (
firstUnreadChapter && <ResumeFab chapter={firstUnreadChapter} mangaId={mangaId} />
)}
</>
);
};
export default ChapterList;

View File

@@ -0,0 +1,114 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import { ArrowDownward, ArrowUpward } from '@mui/icons-material';
import {
Drawer, FormControlLabel, Radio, RadioGroup, Tab, Tabs,
} from '@mui/material';
import { Box } from '@mui/system';
import TabPanel from 'components/util/TabPanel';
import ThreeStateCheckbox from 'components/util/ThreeStateCheckbox';
import React, { useCallback, useState } from 'react';
import { SORT_OPTIONS } from './util';
interface IProps{
open: boolean
onClose: () => void;
options: ChapterListOptions
optionsDispatch: React.Dispatch<ChapterOptionsReducerAction>
}
const TabContent: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<Box sx={{
px: 3, py: 1, display: 'flex', flexDirection: 'column', minHeight: 150,
}}
>
{children}
</Box>
);
const ChapterOptions: React.FC<IProps> = ({
open, onClose, options, optionsDispatch,
}) => {
const [tabNum, setTabNum] = useState(0);
const handleFilterChange = useCallback(
(value: NullAndUndefined<boolean>, name: string) => {
optionsDispatch({ type: 'filter', filterType: name.toLowerCase(), filterValue: value });
}, [],
);
return (
<>
<Drawer
anchor="bottom"
open={open}
onClose={onClose}
PaperProps={{
style: {
maxWidth: 600,
marginLeft: 'auto',
marginRight: 'auto',
minHeight: '150px',
},
}}
>
<Box>
<Tabs
value={tabNum}
variant="fullWidth"
onChange={(e, newTab) => setTabNum(newTab)}
indicatorColor="primary"
textColor="primary"
>
<Tab value={0} label="Filter" />
<Tab value={1} label="Sort" />
<Tab value={2} label="Display" />
</Tabs>
<TabPanel index={0} currentIndex={tabNum}>
<TabContent>
<FormControlLabel control={<ThreeStateCheckbox name="Unread" checked={options.unread} onChange={handleFilterChange} />} label="Unread" />
<FormControlLabel control={<ThreeStateCheckbox name="Downloaded" checked={options.downloaded} onChange={handleFilterChange} />} label="Downloaded" />
<FormControlLabel control={<ThreeStateCheckbox name="Bookmarked" checked={options.bookmarked} onChange={handleFilterChange} />} label="Bookmarked" />
</TabContent>
</TabPanel>
<TabPanel index={1} currentIndex={tabNum}>
<TabContent>
{
SORT_OPTIONS.map(([mode, label]) => (
<FormControlLabel
key={mode}
control={(
<Radio
checked={options.sortBy === mode}
checkedIcon={options.reverse ? <ArrowUpward color="primary" /> : <ArrowDownward color="primary" />}
onClick={() => (mode !== options.sortBy
? optionsDispatch({ type: 'sortBy', sortBy: mode })
: optionsDispatch({ type: 'sortReverse' }))}
/>
)}
label={label}
/>
))
}
</TabContent>
</TabPanel>
<TabPanel index={2} currentIndex={tabNum}>
<TabContent>
<RadioGroup onChange={() => optionsDispatch({ type: 'showChapterNumber' })} value={options.showChapterNumber}>
<FormControlLabel label="Source Title" value="title" control={<Radio checked={!options.showChapterNumber} />} />
<FormControlLabel label="Chapter Number" value="chapterNumber" control={<Radio checked={options.showChapterNumber} />} />
</RadioGroup>
</TabContent>
</TabPanel>
</Box>
</Drawer>
</>
);
};
export default ChapterOptions;

View File

@@ -0,0 +1,40 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import FilterList from '@mui/icons-material/FilterList';
import { Badge, IconButton } from '@mui/material';
import * as React from 'react';
import ChapterOptions from './ChapterOptions';
import { isFilterActive } from './util';
interface IProps {
options: ChapterListOptions
optionsDispatch: React.Dispatch<ChapterOptionsReducerAction>
}
const ChaptersToolbarMenu = ({ options, optionsDispatch }: IProps) => {
const [open, setOpen] = React.useState(false);
const isFiltered = isFilterActive(options);
return (
<>
<IconButton onClick={() => setOpen(true)}>
<Badge color="primary" variant="dot" invisible={!isFiltered}>
<FilterList />
</Badge>
</IconButton>
<ChapterOptions
open={open}
onClose={() => setOpen(false)}
options={options}
optionsDispatch={optionsDispatch}
/>
</>
);
};
export default ChaptersToolbarMenu;

View File

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

View File

@@ -0,0 +1,109 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import Label from '@mui/icons-material/Label';
import MoreHoriz from '@mui/icons-material/MoreHoriz';
import Refresh from '@mui/icons-material/Refresh';
import {
IconButton, ListItemIcon, ListItemText, Menu, MenuItem, Tooltip, useMediaQuery, useTheme,
} from '@mui/material';
import CategorySelect from 'components/navbar/action/CategorySelect';
import React, { useState } from 'react';
interface IProps {
manga: IManga;
onRefresh: () => any;
refreshing: boolean;
}
const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
const theme = useTheme();
const isLargeScreen = useMediaQuery(theme.breakpoints.up('sm'));
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClose = () => {
setAnchorEl(null);
};
const [editCategories, setEditCategories] = useState(false);
return (
<>
{isLargeScreen && (
<>
<Tooltip title="Reload data from source">
<IconButton onClick={() => { onRefresh(); }} disabled={refreshing}>
<Refresh />
</IconButton>
</Tooltip>
{manga.inLibrary && (
<Tooltip title="Edit manga categories">
<IconButton onClick={() => { setEditCategories(true); }}>
<Label />
</IconButton>
</Tooltip>
)}
</>
)}
{!isLargeScreen && (
<>
<IconButton
id="chaptersMenuButton"
aria-controls={open ? 'chaptersMenu' : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
onClick={(e) => setAnchorEl(e.currentTarget)}
>
<MoreHoriz />
</IconButton>
<Menu
id="chaptersMenu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{
'aria-labelledby': 'chaptersMenuButton',
}}
>
<MenuItem
onClick={() => { onRefresh(); handleClose(); }}
disabled={refreshing}
>
<ListItemIcon>
<Refresh fontSize="small" />
</ListItemIcon>
<ListItemText>
Reload data from source
</ListItemText>
</MenuItem>
{manga.inLibrary && (
<MenuItem
onClick={() => { setEditCategories(true); handleClose(); }}
>
<ListItemIcon>
<Label fontSize="small" />
</ListItemIcon>
<ListItemText>
Edit manga categories
</ListItemText>
</MenuItem>
)}
</Menu>
</>
)}
<CategorySelect
open={editCategories}
setOpen={setEditCategories}
mangaId={manga.id}
/>
</>
);
};
export default MangaToolbarMenu;

View File

@@ -0,0 +1,80 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import Download from '@mui/icons-material/Download';
import MoreHoriz from '@mui/icons-material/MoreHoriz';
import {
Fab, ListItemIcon, ListItemText, Menu, MenuItem,
} from '@mui/material';
import { Box } from '@mui/system';
import { pluralize } from 'components/util/helpers';
import React, { useRef, useState } from 'react';
interface SelectionFABProps{
selectedChapters: IChapter[]
onAction: (action: 'download') => void
}
const SelectionFAB: React.FC<SelectionFABProps> = (props) => {
const { selectedChapters, onAction } = props;
const count = selectedChapters.length;
const anchorEl = useRef<HTMLElement>();
const [open, setOpen] = useState(false);
const handleClose = () => setOpen(false);
return (
<Box
sx={{
position: 'fixed', bottom: '2em', right: '3em', pt: 1,
}}
ref={anchorEl}
>
<Fab
variant="extended"
color="primary"
id="selectionMenuButton"
onClick={() => setOpen(true)}
>
{`${count} ${pluralize(count, 'chapter')}`}
<MoreHoriz sx={{ ml: 1 }} />
</Fab>
<Menu
id="selectionMenu"
anchorEl={anchorEl.current}
open={open}
onClose={handleClose}
anchorOrigin={{ horizontal: 'right', vertical: 'top' }}
transformOrigin={{ horizontal: 'right', vertical: 'bottom' }}
MenuListProps={{
'aria-labelledby': 'selectionMenuButton',
}}
>
<MenuItem
onClick={() => { onAction('download'); handleClose(); }}
>
<ListItemIcon>
<Download fontSize="small" />
</ListItemIcon>
<ListItemText>
Download selected
</ListItemText>
</MenuItem>
{/* <MenuItem onClick={() => { onClearSelection(); handleClose(); }}>
<ListItemIcon>
<Clear fontSize="small" />
</ListItemIcon>
<ListItemText>
ClearSelection
</ListItemText>
</MenuItem> */}
</Menu>
</Box>
);
};
export default SelectionFAB;

View File

@@ -0,0 +1,27 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import { useCallback, useState } from 'react';
import { mutate } from 'swr';
import { fetcher } from 'util/client';
// eslint-disable-next-line import/prefer-default-export
export const useRefreshManga = (mangaId: string) => {
const [fetchingOnline, setFetchingOnline] = useState(false);
const handleRefresh = useCallback(async () => {
setFetchingOnline(true);
await Promise.all([
fetcher(`/api/v1/manga/${mangaId}/?onlineFetch=true`)
.then((res) => mutate(`/api/v1/manga/${mangaId}/?onlineFetch=false`, res, { revalidate: false })),
fetcher(`/api/v1/manga/${mangaId}/chapters?onlineFetch=true`)
.then((res) => mutate(`/api/v1/manga/${mangaId}/chapters?onlineFetch=false`, res, { revalidate: false })),
]).finally(() => setFetchingOnline(false));
}, [mangaId]);
return [handleRefresh, { loading: fetchingOnline }] as const;
};

View File

@@ -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;
};

View File

@@ -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);
};

View 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'];
};

View File

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

View File

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

View File

@@ -143,6 +143,10 @@ export default function SourceMangas(props: { popular: boolean }) {
</> </>
, ,
); );
return () => {
setAction(<></>);
};
}, [isConfigurable]); }, [isConfigurable]);
useEffect(() => { useEffect(() => {

1
src/typings.d.ts vendored
View File

@@ -91,6 +91,7 @@ interface IManga {
} }
interface IChapter { interface IChapter {
id: number
url: string url: string
name: string name: string
uploadDate: number uploadDate: number