Feature/cleanup files (#528)
* Move menu components into new dir * Move chapter components into new dir * Extract menu helper functions * Move "SelectionFAB" to "collection" dir * Move debounce hook to global utils dir * Rename manga "hooks" to "useRefreshManga"
This commit is contained in:
185
src/components/chapter/ChapterActionMenuItems.tsx
Normal file
185
src/components/chapter/ChapterActionMenuItems.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* 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 CheckBoxOutlineBlank from '@mui/icons-material/CheckBoxOutlineBlank';
|
||||
import Delete from '@mui/icons-material/Delete';
|
||||
import Download from '@mui/icons-material/Download';
|
||||
import RemoveDone from '@mui/icons-material/RemoveDone';
|
||||
import Done from '@mui/icons-material/Done';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import BookmarkRemove from '@mui/icons-material/BookmarkRemove';
|
||||
import BookmarkAdd from '@mui/icons-material/BookmarkAdd';
|
||||
import DoneAll from '@mui/icons-material/DoneAll';
|
||||
import { useMemo } from 'react';
|
||||
import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts';
|
||||
import {
|
||||
actionToTranslationKey,
|
||||
ChapterAction,
|
||||
ChapterBookmarkInfo,
|
||||
ChapterDownloadInfo,
|
||||
ChapterReadInfo,
|
||||
Chapters,
|
||||
} from '@/lib/data/Chapters.ts';
|
||||
import { TChapter } from '@/typings.ts';
|
||||
import { MenuItem } from '@/components/menu/MenuItem.tsx';
|
||||
import { IChapterWithMeta } from '@/components/chapter/ChapterList.tsx';
|
||||
import { ChaptersWithMeta } from '@/lib/data/ChaptersWithMeta.ts';
|
||||
import { createGetMenuItemTitle, createIsMenuItemDisabled, createShouldShowMenuItem } from '@/components/menu/util.ts';
|
||||
|
||||
type BaseProps = { onClose: () => void };
|
||||
|
||||
type SingleModeProps = {
|
||||
chapter: ChapterDownloadInfo & ChapterBookmarkInfo & ChapterReadInfo;
|
||||
allChapters: TChapter[];
|
||||
handleSelection?: SelectableCollectionReturnType<TChapter['id']>['handleSelection'];
|
||||
canBeDownloaded: boolean;
|
||||
};
|
||||
|
||||
type SelectModeProps = {
|
||||
selectedChapters: IChapterWithMeta[];
|
||||
};
|
||||
|
||||
type Props =
|
||||
| (BaseProps & SingleModeProps & PropertiesNever<SelectModeProps>)
|
||||
| (BaseProps & PropertiesNever<SingleModeProps> & SelectModeProps);
|
||||
|
||||
export const ChapterActionMenuItems = ({
|
||||
chapter,
|
||||
allChapters,
|
||||
handleSelection,
|
||||
canBeDownloaded = false,
|
||||
selectedChapters = [],
|
||||
onClose,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isSingleMode = !!chapter;
|
||||
const { isDownloaded, isRead, isBookmarked } = chapter ?? {};
|
||||
|
||||
const getMenuItemTitle = createGetMenuItemTitle(isSingleMode, actionToTranslationKey);
|
||||
const shouldShowMenuItem = createShouldShowMenuItem(isSingleMode);
|
||||
const isMenuItemDisabled = createIsMenuItemDisabled(isSingleMode);
|
||||
|
||||
const {
|
||||
downloadableChapters,
|
||||
downloadedChapters,
|
||||
unbookmarkedChapters,
|
||||
bookmarkedChapters,
|
||||
unreadChapters,
|
||||
readChapters,
|
||||
} = useMemo(
|
||||
() => ({
|
||||
downloadableChapters: ChaptersWithMeta.getDownloadable(selectedChapters),
|
||||
downloadedChapters: ChaptersWithMeta.getDownloaded(selectedChapters),
|
||||
unbookmarkedChapters: ChaptersWithMeta.getNonBookmarked(selectedChapters),
|
||||
bookmarkedChapters: ChaptersWithMeta.getBookmarked(selectedChapters),
|
||||
unreadChapters: ChaptersWithMeta.getNonRead(selectedChapters),
|
||||
readChapters: ChaptersWithMeta.getRead(selectedChapters),
|
||||
}),
|
||||
[selectedChapters],
|
||||
);
|
||||
|
||||
const handleSelect = () => {
|
||||
handleSelection?.(chapter.id, true);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const performAction = (action: ChapterAction | 'mark_prev_as_read', chaptersWithMeta: IChapterWithMeta[]) => {
|
||||
const isMarkPrevAsRead = action === 'mark_prev_as_read';
|
||||
const actualAction: ChapterAction = isMarkPrevAsRead ? 'mark_as_read' : action;
|
||||
|
||||
const getChapters = (): (ChapterDownloadInfo & ChapterBookmarkInfo & ChapterReadInfo)[] => {
|
||||
// select mode
|
||||
if (!chapter) {
|
||||
return ChaptersWithMeta.getChapters(chaptersWithMeta);
|
||||
}
|
||||
|
||||
if (!isMarkPrevAsRead) {
|
||||
return [chapter];
|
||||
}
|
||||
|
||||
const index = allChapters.findIndex(({ id: chapterId }) => chapterId === chapter.id);
|
||||
|
||||
const isFirstChapter = index + 1 > allChapters.length - 1;
|
||||
if (isFirstChapter) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return allChapters.slice(index + 1);
|
||||
};
|
||||
|
||||
Chapters.performAction(actualAction, chapter ? [chapter.id] : ChaptersWithMeta.getIds(chaptersWithMeta), {
|
||||
chapters: getChapters(),
|
||||
wasManuallyMarkedAsRead: true,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isSingleMode && (
|
||||
<MenuItem onClick={handleSelect} Icon={CheckBoxOutlineBlank} title={t('chapter.action.label.select')} />
|
||||
)}
|
||||
{shouldShowMenuItem(canBeDownloaded) && (
|
||||
<MenuItem
|
||||
Icon={Download}
|
||||
isDisabled={isMenuItemDisabled(!downloadableChapters.length)}
|
||||
onClick={() => performAction('download', downloadableChapters)}
|
||||
title={getMenuItemTitle('download', downloadableChapters.length)}
|
||||
/>
|
||||
)}
|
||||
{shouldShowMenuItem(isDownloaded) && (
|
||||
<MenuItem
|
||||
Icon={Delete}
|
||||
isDisabled={isMenuItemDisabled(!downloadedChapters.length)}
|
||||
onClick={() => performAction('delete', downloadedChapters)}
|
||||
title={getMenuItemTitle('delete', downloadedChapters.length)}
|
||||
/>
|
||||
)}
|
||||
{shouldShowMenuItem(!isBookmarked) && (
|
||||
<MenuItem
|
||||
Icon={BookmarkAdd}
|
||||
isDisabled={isMenuItemDisabled(!unbookmarkedChapters.length)}
|
||||
onClick={() => performAction('bookmark', unbookmarkedChapters)}
|
||||
title={getMenuItemTitle('bookmark', unbookmarkedChapters.length)}
|
||||
/>
|
||||
)}
|
||||
{shouldShowMenuItem(isBookmarked) && (
|
||||
<MenuItem
|
||||
Icon={BookmarkRemove}
|
||||
isDisabled={isMenuItemDisabled(!bookmarkedChapters.length)}
|
||||
onClick={() => performAction('unbookmark', bookmarkedChapters)}
|
||||
title={getMenuItemTitle('unbookmark', bookmarkedChapters.length)}
|
||||
/>
|
||||
)}
|
||||
{shouldShowMenuItem(!isRead) && (
|
||||
<MenuItem
|
||||
Icon={Done}
|
||||
isDisabled={isMenuItemDisabled(!unreadChapters.length)}
|
||||
onClick={() => performAction('mark_as_read', unreadChapters)}
|
||||
title={getMenuItemTitle('mark_as_read', unreadChapters.length)}
|
||||
/>
|
||||
)}
|
||||
{shouldShowMenuItem(isRead) && (
|
||||
<MenuItem
|
||||
Icon={RemoveDone}
|
||||
isDisabled={isMenuItemDisabled(!readChapters.length)}
|
||||
onClick={() => performAction('mark_as_unread', readChapters)}
|
||||
title={getMenuItemTitle('mark_as_unread', readChapters.length)}
|
||||
/>
|
||||
)}
|
||||
{isSingleMode && (
|
||||
<MenuItem
|
||||
onClick={() => performAction('mark_prev_as_read', [])}
|
||||
Icon={DoneAll}
|
||||
title={t('chapter.action.mark_as_read.add.label.action.previous')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
162
src/components/chapter/ChapterCard.tsx
Normal file
162
src/components/chapter/ChapterCard.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* 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 MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import { CardActionArea, Checkbox, Stack, Tooltip } from '@mui/material';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React, { TouchEvent } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PopupState, { bindMenu, bindTrigger } from 'material-ui-popup-state';
|
||||
import { getUploadDateString } from '@/util/date.ts';
|
||||
import { DownloadStateIndicator } from '@/components/molecules/DownloadStateIndicator.tsx';
|
||||
import { DownloadType } from '@/lib/graphql/generated/graphql.ts';
|
||||
import { TChapter } from '@/typings.ts';
|
||||
import { ChapterActionMenuItems } from '@/components/chapter/ChapterActionMenuItems.tsx';
|
||||
import { Menu } from '@/components/menu/Menu.tsx';
|
||||
|
||||
interface IProps {
|
||||
chapter: TChapter;
|
||||
allChapters: TChapter[];
|
||||
downloadChapter: DownloadType | undefined;
|
||||
showChapterNumber: boolean;
|
||||
onSelect: (selected: boolean) => void;
|
||||
selected: boolean | null;
|
||||
}
|
||||
|
||||
export const ChapterCard: React.FC<IProps> = (props: IProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
const { chapter, allChapters, downloadChapter: dc, showChapterNumber, onSelect, selected } = props;
|
||||
const isSelecting = selected !== null;
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (isSelecting) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onSelect(!selected);
|
||||
}
|
||||
};
|
||||
|
||||
const { isDownloaded } = chapter;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<PopupState variant="popover" popupId="chapter-card-action-menu">
|
||||
{(popupState) => {
|
||||
const bindTriggerProps = bindTrigger(popupState);
|
||||
|
||||
const preventDefaultAction = (e: React.BaseSyntheticEvent<unknown>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleClickOpenMenu = (e: React.BaseSyntheticEvent<unknown>) => {
|
||||
preventDefaultAction(e);
|
||||
bindTriggerProps.onClick(e as any);
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: React.BaseSyntheticEvent<unknown>) => {
|
||||
preventDefaultAction(e);
|
||||
bindTriggerProps.onTouchStart(e as TouchEvent);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
sx={{
|
||||
position: 'relative',
|
||||
margin: 1,
|
||||
}}
|
||||
>
|
||||
<CardActionArea
|
||||
component={Link}
|
||||
to={`/manga/${chapter.manga.id}/chapter/${chapter.sourceOrder}`}
|
||||
style={{
|
||||
color: theme.palette.text[chapter.isRead ? '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.isBookmarked && (
|
||||
<BookmarkIcon
|
||||
color="primary"
|
||||
sx={{ mr: 0.5, position: 'relative', top: '0.15em' }}
|
||||
/>
|
||||
)}
|
||||
{showChapterNumber
|
||||
? `${t('chapter.title')} ${chapter.chapterNumber}`
|
||||
: chapter.name}
|
||||
</Typography>
|
||||
<Typography variant="caption">{chapter.scanlator}</Typography>
|
||||
<Typography variant="caption">
|
||||
{getUploadDateString(Number(chapter.uploadDate ?? 0))}
|
||||
{isDownloaded && ` • ${t('chapter.status.label.downloaded')}`}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{dc && <DownloadStateIndicator download={dc} />}
|
||||
|
||||
{selected === null ? (
|
||||
<Tooltip title={t('global.button.options')}>
|
||||
<IconButton
|
||||
{...bindTriggerProps}
|
||||
onClick={handleClickOpenMenu}
|
||||
onTouchStart={handleTouchStart}
|
||||
aria-label="more"
|
||||
size="large"
|
||||
>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip
|
||||
title={t(selected ? 'global.button.deselect' : 'global.button.select')}
|
||||
>
|
||||
<Checkbox checked={selected} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
{!isSelecting && popupState.isOpen && (
|
||||
<Menu {...bindMenu(popupState)}>
|
||||
{(onClose) => (
|
||||
<ChapterActionMenuItems
|
||||
onClose={onClose}
|
||||
chapter={chapter}
|
||||
allChapters={allChapters}
|
||||
handleSelection={() => onSelect(true)}
|
||||
canBeDownloaded={!chapter.isDownloaded && !dc}
|
||||
/>
|
||||
)}
|
||||
</Menu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</PopupState>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
214
src/components/chapter/ChapterList.tsx
Normal file
214
src/components/chapter/ChapterList.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* 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 { Box, CircularProgress, Stack, styled, Tooltip } from '@mui/material';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import DoneAllIcon from '@mui/icons-material/DoneAll';
|
||||
import { TChapter, TManga } from '@/typings.ts';
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import { ChapterCard } from '@/components/chapter/ChapterCard.tsx';
|
||||
import { ResumeFab } from '@/components/manga/ResumeFAB.tsx';
|
||||
import { filterAndSortChapters, useChapterOptions } from '@/components/chapter/util.tsx';
|
||||
import { EmptyView } from '@/components/util/EmptyView.tsx';
|
||||
import { ChaptersToolbarMenu } from '@/components/chapter/ChaptersToolbarMenu.tsx';
|
||||
import { SelectionFAB } from '@/components/collection/SelectionFAB.tsx';
|
||||
import { DEFAULT_FULL_FAB_HEIGHT } from '@/components/util/StyledFab.tsx';
|
||||
import { DownloadType } from '@/lib/graphql/generated/graphql.ts';
|
||||
import { useSelectableCollection } from '@/components/collection/useSelectableCollection.ts';
|
||||
import { SelectableCollectionSelectAll } from '@/components/collection/SelectableCollectionSelectAll.tsx';
|
||||
import { Chapters } from '@/lib/data/Chapters.ts';
|
||||
import { ChaptersWithMeta } from '@/lib/data/ChaptersWithMeta.ts';
|
||||
import { ChapterActionMenuItems } from '@/components/chapter/ChapterActionMenuItems.tsx';
|
||||
|
||||
const ChapterListHeader = styled(Stack)(({ theme }) => ({
|
||||
margin: 8,
|
||||
marginBottom: 0,
|
||||
marginRight: '10px',
|
||||
minHeight: 40,
|
||||
[theme.breakpoints.down('md')]: {
|
||||
marginRight: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
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,
|
||||
},
|
||||
}));
|
||||
|
||||
export interface IChapterWithMeta {
|
||||
chapter: TChapter;
|
||||
downloadChapter: DownloadType | undefined;
|
||||
selected: boolean | null;
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
manga: TManga;
|
||||
isRefreshing: boolean;
|
||||
}
|
||||
|
||||
export const ChapterList: React.FC<IProps> = ({ manga, isRefreshing }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: downloaderData } = requestManager.useDownloadSubscription();
|
||||
const queue = (downloaderData?.downloadChanged.queue as DownloadType[]) ?? [];
|
||||
|
||||
const [options, dispatch] = useChapterOptions(manga.id);
|
||||
const { data: chaptersData, loading: isLoading } = requestManager.useGetMangaChapters(manga.id);
|
||||
const chapters = useMemo(() => chaptersData?.chapters.nodes ?? [], [chaptersData?.chapters.nodes]);
|
||||
|
||||
const { areNoItemsSelected, areAllItemsSelected, selectedItemIds, handleSelectAll, handleSelection } =
|
||||
useSelectableCollection(chapters.length, { currentKey: 'default' });
|
||||
|
||||
const visibleChapters = useMemo(() => filterAndSortChapters(chapters, options), [chapters, options]);
|
||||
|
||||
const nextChapterIndexToRead = (manga.lastReadChapter?.sourceOrder ?? 0) + 1;
|
||||
const isLatestChapterRead = manga.chapters.totalCount === manga.lastReadChapter?.sourceOrder;
|
||||
|
||||
const areAllChaptersRead = manga.unreadCount === 0;
|
||||
const areAllChaptersDownloaded = manga.downloadCount === manga.chapters.totalCount;
|
||||
|
||||
const noChaptersFound = chapters.length === 0;
|
||||
const noChaptersMatchingFilter = !noChaptersFound && visibleChapters.length === 0;
|
||||
|
||||
const chaptersWithMeta: IChapterWithMeta[] = useMemo(
|
||||
() =>
|
||||
visibleChapters.map((chapter) => {
|
||||
const downloadChapter = queue?.find(
|
||||
(cd) => cd.chapter.sourceOrder === chapter.sourceOrder && cd.chapter.manga.id === chapter.manga.id,
|
||||
);
|
||||
const selected = !areNoItemsSelected ? selectedItemIds.includes(chapter.id) : null;
|
||||
return {
|
||||
chapter,
|
||||
downloadChapter,
|
||||
selected,
|
||||
};
|
||||
}),
|
||||
[queue, selectedItemIds, visibleChapters],
|
||||
);
|
||||
|
||||
const chapterListFAB = useMemo(() => {
|
||||
const selectedChapters = chaptersWithMeta.filter((chapter) => chapter.selected);
|
||||
|
||||
if (selectedChapters.length) {
|
||||
return (
|
||||
<SelectionFAB selectedItemsCount={selectedChapters.length} title="chapter.title">
|
||||
{(handleClose) => (
|
||||
<ChapterActionMenuItems selectedChapters={selectedChapters} onClose={handleClose} />
|
||||
)}
|
||||
</SelectionFAB>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLatestChapterRead) {
|
||||
return <ResumeFab chapterIndex={nextChapterIndexToRead} mangaId={manga.id} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [chaptersWithMeta, isLatestChapterRead]);
|
||||
|
||||
if (isLoading || (noChaptersFound && isRefreshing)) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
margin: '10px auto',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<CircularProgress thickness={5} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack direction="column" sx={{ position: 'relative' }}>
|
||||
<ChapterListHeader direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Typography variant="h5">
|
||||
{`${visibleChapters.length} ${t('chapter.title', {
|
||||
count: visibleChapters.length,
|
||||
})}`}
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" sx={{ paddingRight: '24px' }}>
|
||||
<Tooltip title={t('chapter.action.mark_as_read.add.label.action.current')}>
|
||||
<IconButton
|
||||
disabled={areAllChaptersRead}
|
||||
onClick={() =>
|
||||
Chapters.markAsRead(
|
||||
ChaptersWithMeta.getChapters(ChaptersWithMeta.getNonRead(chaptersWithMeta)),
|
||||
true,
|
||||
)
|
||||
}
|
||||
>
|
||||
<DoneAllIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('chapter.action.download.add.label.action')}>
|
||||
<IconButton
|
||||
disabled={areAllChaptersDownloaded}
|
||||
onClick={() =>
|
||||
Chapters.download(
|
||||
ChaptersWithMeta.getIds(ChaptersWithMeta.getNonDownloaded(chaptersWithMeta)),
|
||||
)
|
||||
}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<ChaptersToolbarMenu options={options} optionsDispatch={dispatch} />
|
||||
<SelectableCollectionSelectAll
|
||||
areAllItemsSelected={areAllItemsSelected}
|
||||
areNoItemsSelected={areNoItemsSelected}
|
||||
onChange={(checked) =>
|
||||
handleSelectAll(checked, checked ? chapters.map((chapter) => chapter.id) : [])
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</ChapterListHeader>
|
||||
|
||||
{noChaptersFound && <EmptyView message={t('chapter.error.label.no_chapter_found')} />}
|
||||
{noChaptersMatchingFilter && <EmptyView message={t('chapter.error.label.no_matches')} />}
|
||||
|
||||
<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',
|
||||
}}
|
||||
components={{ Footer: () => <Box sx={{ paddingBottom: DEFAULT_FULL_FAB_HEIGHT }} /> }}
|
||||
totalCount={visibleChapters.length}
|
||||
itemContent={(index: number) => (
|
||||
<ChapterCard
|
||||
{...chaptersWithMeta[index]}
|
||||
allChapters={chapters}
|
||||
showChapterNumber={options.showChapterNumber}
|
||||
onSelect={(selected) => handleSelection(chaptersWithMeta[index].chapter.id, selected)}
|
||||
/>
|
||||
)}
|
||||
useWindowScroll={window.innerWidth < 900}
|
||||
overscan={window.innerHeight * 0.5}
|
||||
/>
|
||||
</Stack>
|
||||
{chapterListFAB}
|
||||
</>
|
||||
);
|
||||
};
|
||||
112
src/components/chapter/ChapterOptions.tsx
Normal file
112
src/components/chapter/ChapterOptions.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* 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 { RadioGroup } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChapterListOptions, ChapterOptionsReducerAction, TranslationKey } from '@/typings.ts';
|
||||
import { RadioInput } from '@/components/atoms/RadioInput.tsx';
|
||||
import { SortRadioInput } from '@/components/atoms/SortRadioInput.tsx';
|
||||
import { ThreeStateCheckboxInput } from '@/components/atoms/ThreeStateCheckboxInput.tsx';
|
||||
import { OptionsTabs } from '@/components/molecules/OptionsTabs.tsx';
|
||||
import { SORT_OPTIONS } from '@/components/chapter/util.tsx';
|
||||
|
||||
interface IProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
options: ChapterListOptions;
|
||||
optionsDispatch: React.Dispatch<ChapterOptionsReducerAction>;
|
||||
}
|
||||
|
||||
const TITLES: { [key in 'filter' | 'sort' | 'display']: TranslationKey } = {
|
||||
filter: 'global.label.filter',
|
||||
sort: 'global.label.sort',
|
||||
display: 'global.label.display',
|
||||
};
|
||||
|
||||
export const ChapterOptions: React.FC<IProps> = ({ open, onClose, options, optionsDispatch }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<OptionsTabs<'filter' | 'sort' | 'display'>
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
minHeight={150}
|
||||
tabs={['filter', 'sort', 'display']}
|
||||
tabTitle={(key) => t(TITLES[key])}
|
||||
tabContent={(key) => {
|
||||
if (key === 'filter') {
|
||||
return (
|
||||
<>
|
||||
<ThreeStateCheckboxInput
|
||||
label={t('global.filter.label.unread')}
|
||||
checked={options.unread}
|
||||
onChange={(c) =>
|
||||
optionsDispatch({
|
||||
type: 'filter',
|
||||
filterType: 'unread',
|
||||
filterValue: c,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<ThreeStateCheckboxInput
|
||||
label={t('global.filter.label.downloaded')}
|
||||
checked={options.downloaded}
|
||||
onChange={(c) =>
|
||||
optionsDispatch({
|
||||
type: 'filter',
|
||||
filterType: 'downloaded',
|
||||
filterValue: c,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<ThreeStateCheckboxInput
|
||||
label={t('global.filter.label.bookmarked')}
|
||||
checked={options.bookmarked}
|
||||
onChange={(c) =>
|
||||
optionsDispatch({
|
||||
type: 'filter',
|
||||
filterType: 'bookmarked',
|
||||
filterValue: c,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (key === 'sort') {
|
||||
return SORT_OPTIONS.map(([mode, label]) => (
|
||||
<SortRadioInput
|
||||
key={mode}
|
||||
label={t(label)}
|
||||
checked={options.sortBy === mode}
|
||||
sortDescending={options.reverse}
|
||||
onClick={() =>
|
||||
mode !== options.sortBy
|
||||
? optionsDispatch({ type: 'sortBy', sortBy: mode })
|
||||
: optionsDispatch({ type: 'sortReverse' })
|
||||
}
|
||||
/>
|
||||
));
|
||||
}
|
||||
if (key === 'display') {
|
||||
return (
|
||||
<RadioGroup
|
||||
onChange={() => optionsDispatch({ type: 'showChapterNumber' })}
|
||||
value={options.showChapterNumber}
|
||||
>
|
||||
<RadioInput label={t('chapter.option.display.label.source_title')} value={false} />
|
||||
<RadioInput label={t('chapter.option.display.label.chapter_number')} value />
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
43
src/components/chapter/ChaptersToolbarMenu.tsx
Normal file
43
src/components/chapter/ChaptersToolbarMenu.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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 { IconButton, Tooltip } from '@mui/material';
|
||||
import * as React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChapterListOptions, ChapterOptionsReducerAction } from '@/typings.ts';
|
||||
import { ChapterOptions } from '@/components/chapter/ChapterOptions.tsx';
|
||||
import { isFilterActive } from '@/components/chapter/util.tsx';
|
||||
|
||||
interface IProps {
|
||||
options: ChapterListOptions;
|
||||
optionsDispatch: React.Dispatch<ChapterOptionsReducerAction>;
|
||||
}
|
||||
|
||||
export const ChaptersToolbarMenu = ({ options, optionsDispatch }: IProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const isFiltered = isFilterActive(options);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip title={t('settings.title')}>
|
||||
<IconButton onClick={() => setOpen(true)}>
|
||||
<FilterList color={isFiltered ? 'warning' : undefined} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<ChapterOptions
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
options={options}
|
||||
optionsDispatch={optionsDispatch}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
116
src/components/chapter/util.tsx
Normal file
116
src/components/chapter/util.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* 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 { t } from 'i18next';
|
||||
import {
|
||||
ChapterListOptions,
|
||||
ChapterOptionsReducerAction,
|
||||
ChapterSortMode,
|
||||
NullAndUndefined,
|
||||
TChapter,
|
||||
TranslationKey,
|
||||
} from '@/typings.ts';
|
||||
import { useReducerLocalStorage } from '@/util/useLocalStorage.tsx';
|
||||
|
||||
const defaultChapterOptions: ChapterListOptions = {
|
||||
active: false,
|
||||
unread: undefined,
|
||||
downloaded: undefined,
|
||||
bookmarked: undefined,
|
||||
reverse: false,
|
||||
sortBy: 'source',
|
||||
showChapterNumber: false,
|
||||
};
|
||||
|
||||
function chapterOptionsReducer(state: ChapterListOptions, actions: ChapterOptionsReducerAction): ChapterListOptions {
|
||||
switch (actions.type) {
|
||||
case 'filter':
|
||||
return {
|
||||
...state,
|
||||
active: state.unread !== false && state.downloaded !== false && state.bookmarked !== false,
|
||||
[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(t('global.error.label.invalid_action'));
|
||||
}
|
||||
}
|
||||
|
||||
export function unreadFilter(unread: NullAndUndefined<boolean>, { isRead: isChapterRead }: TChapter) {
|
||||
switch (unread) {
|
||||
case true:
|
||||
return !isChapterRead;
|
||||
case false:
|
||||
return isChapterRead;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFilter(downloaded: NullAndUndefined<boolean>, { isDownloaded: chapterDownload }: TChapter) {
|
||||
switch (downloaded) {
|
||||
case true:
|
||||
return chapterDownload;
|
||||
case false:
|
||||
return !chapterDownload;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function bookmarkedFilter(bookmarked: NullAndUndefined<boolean>, { isBookmarked: chapterBookmarked }: TChapter) {
|
||||
switch (bookmarked) {
|
||||
case true:
|
||||
return chapterBookmarked;
|
||||
case false:
|
||||
return !chapterBookmarked;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function filterAndSortChapters(chapters: TChapter[], options: ChapterListOptions): TChapter[] {
|
||||
const filtered = options.active
|
||||
? chapters.filter(
|
||||
(chp) =>
|
||||
unreadFilter(options.unread, chp) &&
|
||||
downloadFilter(options.downloaded, chp) &&
|
||||
bookmarkedFilter(options.bookmarked, chp),
|
||||
)
|
||||
: [...chapters];
|
||||
const Sorted =
|
||||
options.sortBy === 'fetchedAt'
|
||||
? filtered.sort((a, b) => Number(a.fetchedAt ?? 0) - Number(b.fetchedAt ?? 0))
|
||||
: filtered;
|
||||
if (options.reverse) {
|
||||
Sorted.reverse();
|
||||
}
|
||||
return Sorted;
|
||||
}
|
||||
|
||||
export const useChapterOptions = (mangaId: number) =>
|
||||
useReducerLocalStorage<ChapterListOptions, ChapterOptionsReducerAction>(
|
||||
chapterOptionsReducer,
|
||||
`${mangaId}filterOptions`,
|
||||
defaultChapterOptions,
|
||||
);
|
||||
|
||||
export const SORT_OPTIONS: [ChapterSortMode, TranslationKey][] = [
|
||||
['source', 'global.sort.label.by_source'],
|
||||
['fetchedAt', 'global.sort.label.by_fetch_date'],
|
||||
];
|
||||
|
||||
export const isFilterActive = (options: ChapterListOptions) => {
|
||||
const { unread, downloaded, bookmarked } = options;
|
||||
return unread != null || downloaded != null || bookmarked != null;
|
||||
};
|
||||
Reference in New Issue
Block a user