diff --git a/src/components/chapter/ChapterCard.tsx b/src/components/chapter/ChapterCard.tsx deleted file mode 100644 index 811726739f..0000000000 --- a/src/components/chapter/ChapterCard.tsx +++ /dev/null @@ -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); - - const handleClick = (event: React.MouseEvent) => { - // 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 ( - <> -
  • - - - - -
    - - - {chapter.bookmarked && } - - { showChapterNumber ? `Chapter ${chapter.chapterNumber}` : chapter.name} - - - {chapter.scanlator} - {chapter.scanlator && ' '} - {dateStr} - {downloadStatusString} - -
    -
    - - - - -
    - - - {downloadStatusString.endsWith('Downloaded') - && Delete} - {downloadStatusString.length === 0 - && Download } - sendChange('bookmarked', !chapter.bookmarked)}> - {chapter.bookmarked && 'Remove bookmark'} - {!chapter.bookmarked && 'Bookmark'} - - sendChange('read', !chapter.read)}> - {`Mark as ${chapter.read ? 'unread' : 'read'}`} - - sendChange('markPrevRead', true)}> - Mark previous as Read - - -
    -
  • - - ); -} diff --git a/src/components/chapter/ChapterList.tsx b/src/components/chapter/ChapterList.tsx deleted file mode 100644 index 3cd1ce4c29..0000000000 --- a/src/components/chapter/ChapterList.tsx +++ /dev/null @@ -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(); - const [filteredChapters, setFilteredChapters] = useState([]); - // eslint-disable-next-line max-len - const [options, optionsDispatch] = useReducerLocalStorage( - chapterOptionsReducer, `${id}filterOptions`, defaultChapterOptions, - ); - - const prevQueueRef = useRef(); - const queue = useSubscription('/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 ( -
    - -
    - ); - } - - return ( - <> - - - - {`${filteredChapters.length} Chapters`} - - - - - ( - - )} - useWindowScroll={window.innerWidth < 900} - overscan={window.innerHeight * 0.5} - /> - - {firstUnreadChapter && } - - ); -} diff --git a/src/components/chapter/ChapterOptions.tsx b/src/components/chapter/ChapterOptions.tsx deleted file mode 100644 index 3e86abb7d1..0000000000 --- a/src/components/chapter/ChapterOptions.tsx +++ /dev/null @@ -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 -} - -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, name: string) => { - optionsDispatch({ type: 'filter', filterType: name.toLowerCase(), filterValue: value }); - }, [], - ); - - return ( - <> - setFiltersOpen(!filtersOpen)} - color={options.active ? 'warning' : 'default'} - > - - - - setFiltersOpen(false)} - PaperProps={{ - style: { - maxWidth: 600, - padding: '1em', - marginLeft: 'auto', - marginRight: 'auto', - minHeight: '150px', - }, - }} - > - - setTabNum(newTab)} - indicatorColor="primary" - textColor="primary" - > - - - - - - - } label="Unread" /> - } label="Downloaded" /> - } label="Bookmarked" /> - - - - - { - SortTab.map((item) => ( - (item[0] !== options.sortBy - ? optionsDispatch({ type: 'sortBy', sortBy: item[0] }) - : optionsDispatch({ type: 'sortReverse' }))} - > - - { - options.sortBy === item[0] - && (options.reverse - ? () : ()) - } - - {item[1]} - - - )) - } - - - - - optionsDispatch({ type: 'showChapterNumber' })} value={options.showChapterNumber}> - } /> - } /> - - - - - - - - ); -} diff --git a/src/components/manga/ChapterCard.tsx b/src/components/manga/ChapterCard.tsx new file mode 100644 index 0000000000..66c2e921f3 --- /dev/null +++ b/src/components/manga/ChapterCard.tsx @@ -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 = (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); + + const handleMenuClick = (event: React.MouseEvent) => { + // 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) => { + if (isSelecting) { + event.preventDefault(); + event.stopPropagation(); + onSelect(!selected); + } + }; + + const isDownloaded = chapter.downloaded; + const canBeDownloaded = !chapter.downloaded && dc === undefined; + + return ( +
  • + + + + + + {chapter.bookmarked && ( + + )} + { showChapterNumber ? `Chapter ${chapter.chapterNumber}` : chapter.name} + + + {chapter.scanlator} + + + {dateStr} + {isDownloaded && ' • Downloaded'} + {dc && ` • Downloading (${(dc.progress * 100).toFixed(2)}%)`} + + + + {selected === null ? ( + + + + ) : ( + + )} + + + {dc != null && ( + + )} + + + + + + + Select + + + {isDownloaded && ( + + + + + + Delete + + + )} + {canBeDownloaded && ( + + + + + + Download + + + ) } + sendChange('bookmarked', !chapter.bookmarked)}> + + {chapter.bookmarked && } + {!chapter.bookmarked && } + + + {chapter.bookmarked && 'Remove bookmark'} + {!chapter.bookmarked && 'Add bookmark'} + + + sendChange('read', !chapter.read)}> + + {chapter.read && } + {!chapter.read && } + + + {chapter.read && 'Mark as unread'} + {!chapter.read && 'Mark as read'} + + + sendChange('markPrevRead', true)}> + + + + + Mark previous as Read + + + + +
  • + ); +}; + +export default ChapterCard; diff --git a/src/components/manga/ChapterList.tsx b/src/components/manga/ChapterList.tsx new file mode 100644 index 0000000000..f7bdaf9a04 --- /dev/null +++ b/src/components/manga/ChapterList.tsx @@ -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 = ({ mangaId }) => { + const [selection, setSelection] = useState(null); + const prevQueueRef = useRef(); + const queue = useSubscription('/api/v1/downloads').data?.queue; + + const [options, dispatch] = useChapterOptions(mangaId); + const { + data: chaptersData, + mutate, + loading, + } = useQuery(`/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 ( +
    + +
    + ); + } + + 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 ( + <> + + + + {`${visibleChapters.length} Chapter${visibleChapters.length === 1 ? '' : 's'}`} + + + {selection === null ? ( + + ) : ( + + + + + )} + + + {noChaptersFound && ( + + )} + {noChaptersMatchingFilter && ( + + )} + + ( + mutate()} + onSelect={() => handleSelection(index)} + /> + )} + useWindowScroll={window.innerWidth < 900} + overscan={window.innerHeight * 0.5} + /> + + {selectedChapters !== null ? ( + + ) : ( + firstUnreadChapter && + )} + + ); +}; + +export default ChapterList; diff --git a/src/components/manga/ChapterOptions.tsx b/src/components/manga/ChapterOptions.tsx new file mode 100644 index 0000000000..eeecd83191 --- /dev/null +++ b/src/components/manga/ChapterOptions.tsx @@ -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 +} + +const TabContent: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + + {children} + +); + +const ChapterOptions: React.FC = ({ + open, onClose, options, optionsDispatch, +}) => { + const [tabNum, setTabNum] = useState(0); + + const handleFilterChange = useCallback( + (value: NullAndUndefined, name: string) => { + optionsDispatch({ type: 'filter', filterType: name.toLowerCase(), filterValue: value }); + }, [], + ); + + return ( + <> + + + setTabNum(newTab)} + indicatorColor="primary" + textColor="primary" + > + + + + + + + } label="Unread" /> + } label="Downloaded" /> + } label="Bookmarked" /> + + + + + { + SORT_OPTIONS.map(([mode, label]) => ( + : } + onClick={() => (mode !== options.sortBy + ? optionsDispatch({ type: 'sortBy', sortBy: mode }) + : optionsDispatch({ type: 'sortReverse' }))} + /> + )} + label={label} + /> + )) + } + + + + + optionsDispatch({ type: 'showChapterNumber' })} value={options.showChapterNumber}> + } /> + } /> + + + + + + + ); +}; + +export default ChapterOptions; diff --git a/src/components/manga/ChaptersToolbarMenu.tsx b/src/components/manga/ChaptersToolbarMenu.tsx new file mode 100644 index 0000000000..df0b124fa1 --- /dev/null +++ b/src/components/manga/ChaptersToolbarMenu.tsx @@ -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 +} + +const ChaptersToolbarMenu = ({ options, optionsDispatch }: IProps) => { + const [open, setOpen] = React.useState(false); + const isFiltered = isFilterActive(options); + + return ( + <> + setOpen(true)}> + + + + + setOpen(false)} + options={options} + optionsDispatch={optionsDispatch} + /> + + ); +}; + +export default ChaptersToolbarMenu; diff --git a/src/components/MangaDetails.tsx b/src/components/manga/MangaDetails.tsx similarity index 61% rename from src/components/MangaDetails.tsx rename to src/components/manga/MangaDetails.tsx index 08aca02e2e..7d652de184 100644 --- a/src/components/MangaDetails.tsx +++ b/src/components/manga/MangaDetails.tsx @@ -5,22 +5,19 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import makeStyles from '@mui/styles/makeStyles'; -import IconButton from '@mui/material/IconButton'; -import { Theme } from '@mui/material/styles'; import FavoriteIcon from '@mui/icons-material/Favorite'; import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder'; -import FilterListIcon from '@mui/icons-material/FilterList'; import PublicIcon from '@mui/icons-material/Public'; -import React, { useContext, useEffect, useState } from 'react'; -import NavbarContext from 'components/context/NavbarContext'; +import { Typography } from '@mui/material'; +import IconButton from '@mui/material/IconButton'; +import { Theme } from '@mui/material/styles'; +import makeStyles from '@mui/styles/makeStyles'; +import React from 'react'; +import { mutate } from 'swr'; import client from 'util/client'; import useLocalStorage from 'util/useLocalStorage'; -import Refresh from '@mui/icons-material/Refresh'; -import CategorySelect from './navbar/action/CategorySelect'; -import LoadingIconButton from './atoms/LoadingIconButton'; -const useStyles = (inLibrary: string) => makeStyles((theme: Theme) => ({ +const useStyles = (inLibrary: boolean) => makeStyles((theme: Theme) => ({ root: { width: '100%', [theme.breakpoints.up('md')]: { @@ -68,11 +65,7 @@ const useStyles = (inLibrary: string) => makeStyles((theme: Theme) => ({ display: 'flex', justifyContent: 'space-around', '& button': { - color: inLibrary === 'In Library' ? '#2196f3' : 'inherit', - }, - '& span': { - display: 'block', - fontSize: '0.85em', + color: inLibrary ? '#2196f3' : 'inherit', }, '& a': { textDecoration: 'none', @@ -120,8 +113,6 @@ const useStyles = (inLibrary: string) => makeStyles((theme: Theme) => ({ interface IProps{ manga: IManga - onRefresh: () => Promise - refreshing: boolean } function getSourceName(source: ISource) { @@ -135,69 +126,23 @@ function getValueOrUnknown(val: string) { return val || 'UNKNOWN'; } -export default function MangaDetails({ manga, onRefresh, refreshing }: IProps) { - const { setAction } = useContext(NavbarContext); - - const [inLibrary, setInLibrary] = useState( - manga.inLibrary ? 'In Library' : 'Add To Library', - ); - - const [categoryDialogOpen, setCategoryDialogOpen] = useState(false); - - useEffect(() => { - setAction( - <> - - - - {inLibrary === 'In Library' && ( - <> - setCategoryDialogOpen(true)} - aria-label="display more actions" - edge="end" - color="inherit" - size="large" - > - - - - - )} - , - ); - }, [inLibrary, categoryDialogOpen, refreshing, onRefresh]); - +const MangaDetails: React.FC = ({ manga }) => { const [serverAddress] = useLocalStorage('serverBaseURL', ''); const [useCache] = useLocalStorage('useCache', true); - const classes = useStyles(inLibrary)(); - - function addToLibrary() { - // setInLibrary('adding'); - client.get(`/api/v1/manga/${manga.id}/library/`).then(() => { - setInLibrary('In Library'); - }); - } + const classes = useStyles(manga.inLibrary)(); - function removeFromLibrary() { - // setInLibrary('removing'); - client.delete(`/api/v1/manga/${manga.id}/library/`).then(() => { - setInLibrary('Add To Library'); - }); - } + const addToLibrary = () => { + mutate(`/api/v1/manga/${manga.id}/?onlineFetch=false`, { ...manga, inLibrary: true }, { revalidate: false }); + client.get(`/api/v1/manga/${manga.id}/library/`) + .then(() => mutate(`/api/v1/manga/${manga.id}/?onlineFetch=false`)); + }; - function handleButtonClick() { - if (inLibrary === 'Add To Library') { - addToLibrary(); - } else { - removeFromLibrary(); - } - } + const removeFromLibrary = () => { + mutate(`/api/v1/manga/${manga.id}/?onlineFetch=false`, { ...manga, inLibrary: false }, { revalidate: false }); + client.delete(`/api/v1/manga/${manga.id}/library/`) + .then(() => mutate(`/api/v1/manga/${manga.id}/?onlineFetch=false`)); + }; return (
    @@ -228,17 +173,21 @@ export default function MangaDetails({ manga, onRefresh, refreshing }: IProps) {
    - handleButtonClick()} size="large"> - {inLibrary === 'In Library' && } - {inLibrary !== 'In Library' && } - {inLibrary} + + {manga.inLibrary + ? + : } + + {manga.inLibrary ? 'In Library' : 'Add To Library'} +
    - { /* eslint-disable-next-line react/jsx-no-target-blank */ } - + - - Open Site + + + Open Site +
    @@ -254,4 +203,6 @@ export default function MangaDetails({ manga, onRefresh, refreshing }: IProps) { ); -} +}; + +export default MangaDetails; diff --git a/src/components/manga/MangaToolbarMenu.tsx b/src/components/manga/MangaToolbarMenu.tsx new file mode 100644 index 0000000000..07a11994d0 --- /dev/null +++ b/src/components/manga/MangaToolbarMenu.tsx @@ -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); + const open = Boolean(anchorEl); + const handleClose = () => { + setAnchorEl(null); + }; + + const [editCategories, setEditCategories] = useState(false); + + return ( + <> + {isLargeScreen && ( + <> + + { onRefresh(); }} disabled={refreshing}> + + + + {manga.inLibrary && ( + + { setEditCategories(true); }}> + + + )} + + )} + {!isLargeScreen && ( + <> + setAnchorEl(e.currentTarget)} + > + + + + { onRefresh(); handleClose(); }} + disabled={refreshing} + > + + + + + Reload data from source + + + {manga.inLibrary && ( + { setEditCategories(true); handleClose(); }} + > + + + + Edit manga categories + + + )} + + + )} + + + + ); +}; + +export default MangaToolbarMenu; diff --git a/src/components/chapter/ResumeFAB.tsx b/src/components/manga/ResumeFAB.tsx similarity index 100% rename from src/components/chapter/ResumeFAB.tsx rename to src/components/manga/ResumeFAB.tsx diff --git a/src/components/manga/SelectionFAB.tsx b/src/components/manga/SelectionFAB.tsx new file mode 100644 index 0000000000..83b6ab57b6 --- /dev/null +++ b/src/components/manga/SelectionFAB.tsx @@ -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 = (props) => { + const { selectedChapters, onAction } = props; + const count = selectedChapters.length; + + const anchorEl = useRef(); + const [open, setOpen] = useState(false); + const handleClose = () => setOpen(false); + + return ( + + setOpen(true)} + > + {`${count} ${pluralize(count, 'chapter')}`} + + + + { onAction('download'); handleClose(); }} + > + + + + + Download selected + + + {/* { onClearSelection(); handleClose(); }}> + + + + + ClearSelection + + */} + + + ); +}; + +export default SelectionFAB; diff --git a/src/components/manga/hooks.ts b/src/components/manga/hooks.ts new file mode 100644 index 0000000000..0356741318 --- /dev/null +++ b/src/components/manga/hooks.ts @@ -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; +}; diff --git a/src/components/chapter/util.tsx b/src/components/manga/util.tsx similarity index 73% rename from src/components/chapter/util.tsx rename to src/components/manga/util.tsx index ae0eb38fa4..244629c7c8 100644 --- a/src/components/chapter/util.tsx +++ b/src/components/manga/util.tsx @@ -5,7 +5,9 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -export const defaultChapterOptions: ChapterListOptions = { +import { useReducerLocalStorage } from 'util/useLocalStorage'; + +const defaultChapterOptions: ChapterListOptions = { active: false, unread: undefined, downloaded: undefined, @@ -15,7 +17,7 @@ export const defaultChapterOptions: ChapterListOptions = { showChapterNumber: false, }; -export function chapterOptionsReducer(state: ChapterListOptions, +function chapterOptionsReducer(state: ChapterListOptions, actions: ChapterOptionsReducerAction) : ChapterListOptions { switch (actions.type) { @@ -51,7 +53,7 @@ export function unreadFilter(unread: NullAndUndefined, { read: isChapte } } -export function downloadFilter(downloaded: NullAndUndefined, +function downloadFilter(downloaded: NullAndUndefined, { downloaded: chapterDownload }: IChapter) { switch (downloaded) { case true: @@ -63,7 +65,7 @@ export function downloadFilter(downloaded: NullAndUndefined, } } -export function bookmarkdFilter(bookmarked: NullAndUndefined, +function bookmarkedFilter(bookmarked: NullAndUndefined, { bookmarked: chapterBookmarked }: IChapter) { switch (bookmarked) { case true: @@ -80,7 +82,7 @@ export function filterAndSortChapters(chapters: IChapter[], options: ChapterList const filtered = options.active ? chapters.filter((chp) => unreadFilter(options.unread, chp) && downloadFilter(options.downloaded, chp) - && bookmarkdFilter(options.bookmarked, chp)) + && bookmarkedFilter(options.bookmarked, chp)) : [...chapters]; const Sorted = options.sortBy === 'fetchedAt' ? filtered.sort((a, b) => a.fetchedAt - b.fetchedAt) @@ -91,9 +93,20 @@ export function filterAndSortChapters(chapters: IChapter[], options: ChapterList return Sorted; } -export function findFirstUnreadChapter(chapters: IChapter[]): IChapter | undefined { - for (let index = chapters.length - 1; index >= 0; index--) { - if (!chapters[index].read) return chapters[index]; - } - return undefined; -} +export const useChapterOptions = (mangaId: string) => useReducerLocalStorage< +ChapterListOptions, +ChapterOptionsReducerAction +>( + chapterOptionsReducer, + `${mangaId}filterOptions`, defaultChapterOptions, +); + +export const SORT_OPTIONS: [ChapterSortMode, string][] = [ + ['source', 'By Source'], + ['fetchedAt', 'By Fetch date'], +]; + +export const isFilterActive = (options: ChapterListOptions) => { + const { unread, downloaded, bookmarked } = options; + return unread != null || downloaded != null || bookmarked != null; +}; diff --git a/src/components/navbar/DefaultNavBar.tsx b/src/components/navbar/DefaultNavBar.tsx index e7448750f4..7ff9f03ad9 100644 --- a/src/components/navbar/DefaultNavBar.tsx +++ b/src/components/navbar/DefaultNavBar.tsx @@ -28,6 +28,7 @@ import NavBarContext from 'components/context/NavbarContext'; import DarkTheme from 'components/context/DarkTheme'; import ExtensionOutlinedIcon from 'components/util/CustomExtensionOutlinedIcon'; import { Box } from '@mui/system'; +import { createPortal } from 'react-dom'; import DesktopSideBar from './navigation/DesktopSideBar'; import MobileBottomBar from './navigation/MobileBottomBar'; @@ -121,13 +122,25 @@ export default function DefaultNavBar() { ) } - + {title} {action} +