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>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import React from 'react';
|
||||
import { Fab } from '@mui/material';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PlayArrow } from '@mui/icons-material';
|
||||
|
||||
interface ResumeFABProps{
|
||||
chapter: IChapter
|
||||
mangaId: string
|
||||
}
|
||||
|
||||
export default function ResumeFab(props: ResumeFABProps) {
|
||||
const { chapter: { index, lastPageRead }, mangaId } = props;
|
||||
return (
|
||||
<Fab
|
||||
sx={{ position: 'fixed', bottom: '2em', right: '3em' }}
|
||||
component={Link}
|
||||
variant="extended"
|
||||
color="primary"
|
||||
to={`/manga/${mangaId}/chapter/${index}/page/${lastPageRead}`}
|
||||
>
|
||||
<PlayArrow />
|
||||
{index === 1 ? 'Start' : 'Resume' }
|
||||
</Fab>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
export const defaultChapterOptions: ChapterListOptions = {
|
||||
active: false,
|
||||
unread: undefined,
|
||||
downloaded: undefined,
|
||||
bookmarked: undefined,
|
||||
reverse: false,
|
||||
sortBy: 'source',
|
||||
showChapterNumber: false,
|
||||
};
|
||||
|
||||
export function chapterOptionsReducer(state: ChapterListOptions,
|
||||
actions: ChapterOptionsReducerAction)
|
||||
: ChapterListOptions {
|
||||
switch (actions.type) {
|
||||
case 'filter':
|
||||
// eslint-disable-next-line no-case-declarations
|
||||
const active = state.unread !== false
|
||||
&& state.downloaded !== false
|
||||
&& state.bookmarked !== false;
|
||||
return {
|
||||
...state,
|
||||
active,
|
||||
[actions.filterType!]: actions.filterValue,
|
||||
};
|
||||
case 'sortBy':
|
||||
return { ...state, sortBy: actions.sortBy };
|
||||
case 'sortReverse':
|
||||
return { ...state, reverse: !state.reverse };
|
||||
case 'showChapterNumber':
|
||||
return { ...state, showChapterNumber: !state.showChapterNumber };
|
||||
default:
|
||||
throw Error('This is not a valid Action');
|
||||
}
|
||||
}
|
||||
|
||||
export function unreadFilter(unread: NullAndUndefined<boolean>, { read: isChapterRead }: IChapter) {
|
||||
switch (unread) {
|
||||
case true:
|
||||
return !isChapterRead;
|
||||
case false:
|
||||
return isChapterRead;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function downloadFilter(downloaded: NullAndUndefined<boolean>,
|
||||
{ downloaded: chapterDownload }: IChapter) {
|
||||
switch (downloaded) {
|
||||
case true:
|
||||
return chapterDownload;
|
||||
case false:
|
||||
return !chapterDownload;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function bookmarkdFilter(bookmarked: NullAndUndefined<boolean>,
|
||||
{ bookmarked: chapterBookmarked }: IChapter) {
|
||||
switch (bookmarked) {
|
||||
case true:
|
||||
return chapterBookmarked;
|
||||
case false:
|
||||
return !chapterBookmarked;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function filterAndSortChapters(chapters: IChapter[], options: ChapterListOptions)
|
||||
: IChapter[] {
|
||||
const filtered = options.active
|
||||
? chapters.filter((chp) => unreadFilter(options.unread, chp)
|
||||
&& downloadFilter(options.downloaded, chp)
|
||||
&& bookmarkdFilter(options.bookmarked, chp))
|
||||
: [...chapters];
|
||||
const Sorted = options.sortBy === 'fetchedAt'
|
||||
? filtered.sort((a, b) => a.fetchedAt - b.fetchedAt)
|
||||
: filtered;
|
||||
if (options.reverse) {
|
||||
Sorted.reverse();
|
||||
}
|
||||
return Sorted;
|
||||
}
|
||||
|
||||
export function findFirstUnreadChapter(chapters: IChapter[]): IChapter | undefined {
|
||||
for (let index = chapters.length - 1; index >= 0; index--) {
|
||||
if (!chapters[index].read) return chapters[index];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user