Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/features/category/services/CategoryMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const DEFAULT_CATEGORY_METADATA: ICategoryMetadata = {
hasUnreadChapters: undefined,
hasReadChapters: undefined,
hasDuplicateChapters: undefined,
isNovel: undefined,
hasTrackerBinding: {},
hasStatus: {} as LibraryOptions['hasStatus'],
};
Expand Down
1 change: 1 addition & 0 deletions src/features/library/Library.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export interface LibraryOptions {
hasUnreadChapters: NullAndUndefined<boolean>;
hasReadChapters: NullAndUndefined<boolean>;
hasDuplicateChapters: NullAndUndefined<boolean>;
isNovel: NullAndUndefined<boolean>;
hasTrackerBinding: Record<TrackerIdInfo['id'], NullAndUndefined<boolean>>;
hasStatus: Record<MangaStatus, NullAndUndefined<boolean>>;
}
Expand Down
5 changes: 5 additions & 0 deletions src/features/library/components/LibraryOptionsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ export const LibraryOptionsPanel = ({
checked={categoryLibraryOptions.hasDuplicateChapters}
onChange={(c) => updateCategoryLibraryOptions('hasDuplicateChapters', c)}
/>
<ThreeStateCheckboxInput
label={t`Novel`}
checked={categoryLibraryOptions.isNovel}
onChange={(c) => updateCategoryLibraryOptions('isNovel', c)}
/>
<FormLabel sx={{ mt: 2 }}>{t`Status`}</FormLabel>
{Object.values(MangaStatus).map((status) => (
<ThreeStateCheckboxInput
Expand Down
1 change: 1 addition & 0 deletions src/features/library/components/LibraryToolbarMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const LibraryToolbarMenu = ({
options.hasReadChapters != null ||
options.hasBookmarkedChapters != null ||
options.hasDuplicateChapters != null ||
options.isNovel != null ||
Object.values(options.hasStatus).some((hasStatus) => hasStatus != null) ||
Object.values(options.hasTrackerBinding).some((trackerFilterStatus) => trackerFilterStatus != null);

Expand Down
8 changes: 8 additions & 0 deletions src/features/library/hooks/useGetVisibleLibraryMangas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
MangaGenreInfo,
MangaIdInfo,
MangaInLibraryInfo,
MangaIsNovelInfo,
MangaSourceIdInfo,
MangaSourceNameInfo,
MangaStatusInfo,
Expand Down Expand Up @@ -124,6 +125,7 @@ type TMangaFilterOptions = Pick<
| 'hasDownloadedChapters'
| 'hasBookmarkedChapters'
| 'hasDuplicateChapters'
| 'isNovel'
| 'hasTrackerBinding'
| 'hasStatus'
>;
Expand All @@ -132,6 +134,7 @@ type TMangaFilter = Pick<MangaType, 'bookmarkCount' | 'hasDuplicateChapters'> &
MangaStatusInfo &
MangaChapterCountInfo &
MangaDownloadInfo &
MangaIsNovelInfo &
MangaUnreadInfo;
const filterManga = (
manga: TMangaFilter,
Expand All @@ -141,6 +144,7 @@ const filterManga = (
hasReadChapters,
hasBookmarkedChapters,
hasDuplicateChapters,
isNovel,
hasTrackerBinding,
hasStatus,
}: TMangaFilterOptions,
Expand All @@ -150,6 +154,7 @@ const filterManga = (
triStateFilterNumber(hasReadChapters, manga.chapters.totalCount - manga.unreadCount) &&
triStateFilterNumber(hasBookmarkedChapters, manga.bookmarkCount) &&
triStateFilterBoolean(hasDuplicateChapters, manga.hasDuplicateChapters) &&
triStateFilterBoolean(isNovel, !!manga.source?.isNovel) &&
trackerFilter(hasTrackerBinding, manga) &&
statusFilter(hasStatus, manga);

Expand Down Expand Up @@ -242,6 +247,7 @@ export const useGetVisibleLibraryMangas = <Manga extends MangaIdInfo & TMangasFi
hasBookmarkedChapters,
hasTrackerBinding,
hasDuplicateChapters,
isNovel,
hasStatus,
} = options;
const { settings } = useMetadataServerSettings();
Expand All @@ -261,6 +267,7 @@ export const useGetVisibleLibraryMangas = <Manga extends MangaIdInfo & TMangasFi
hasBookmarkedChapters,
hasTrackerBinding,
hasDuplicateChapters,
isNovel,
hasStatus,
settings.ignoreFilters,
],
Expand All @@ -278,6 +285,7 @@ export const useGetVisibleLibraryMangas = <Manga extends MangaIdInfo & TMangasFi
hasReadChapters != null ||
hasDownloadedChapters != null ||
hasBookmarkedChapters != null ||
isNovel != null ||
!!query ||
isATrackFilterActive) &&
filteredMangas.length === 0 &&
Expand Down
1 change: 1 addition & 0 deletions src/features/manga/Manga.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export type MangaTrackRecordInfo = MangaIdInfo & {
export type MangaGenreInfo = Pick<MangaTypeGql, 'genre'>;
export type MangaSourceIdInfo = Pick<MangaTypeGql, 'sourceId'>;
export type MangaSourceNameInfo = { source?: Maybe<Pick<SourceType, 'name' | 'displayName'>> };
export type MangaIsNovelInfo = { source?: Maybe<Pick<SourceType, 'isNovel'>> };
export type MangaSourceLngInfo = { source?: Maybe<Pick<SourceType, 'lang'>> };
export type MangaArtistInfo = Pick<MangaTypeGql, 'artist'>;
export type MangaAuthorInfo = Pick<MangaTypeGql, 'author'>;
Expand Down
4 changes: 4 additions & 0 deletions src/features/metadata/Metadata.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,9 @@ export const APP_METADATA: Record<
hasDuplicateChapters: {
convert: convertToBooleanNullAndUndefined,
},
isNovel: {
convert: convertToBooleanNullAndUndefined,
},
hasTrackerBinding: {
convert: convertToObject<LibraryOptions['hasTrackerBinding']>,
},
Expand Down Expand Up @@ -432,6 +435,7 @@ export const GLOBAL_METADATA_KEYS: AppMetadataKeys[] = [
'hasUnreadChapters',
'hasReadChapters',
'hasDuplicateChapters',
'isNovel',
'hasTrackerBinding',
'hasStatus',
// sort
Expand Down
184 changes: 184 additions & 0 deletions src/features/reader/novel/NovelReader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/*
* 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 { memo, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import { useTheme } from '@mui/material/styles';
import { useLingui } from '@lingui/react/macro';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { Chapters } from '@/features/chapter/services/Chapters.ts';
import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholder.tsx';
import { EmptyViewAbsoluteCentered } from '@/base/components/feedback/EmptyViewAbsoluteCentered.tsx';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import type { TChapterReader } from '@/features/chapter/Chapter.types.ts';

const buildSrcDoc = (body: string, fontSize: number, background: string, text: string): string =>
`<!doctype html><html><head><meta charset="utf-8">` +
`<style>` +
`body{margin:0 !important;padding:28px 20px !important;font-family:Georgia,serif;` +
`font-size:${fontSize}px !important;line-height:1.75 !important;` +
`color:${text} !important;background:${background} !important;}` +
`body *{color:${text} !important;background-color:transparent !important;}` +
`a{color:${text} !important;text-decoration:underline !important;}` +
`p{margin:0 0 1.1em;}img{max-width:100% !important;height:auto !important;}` +
`</style>` +
`</head><body>${body}</body></html>`;

const BaseNovelReader = ({
mangaId,
mangaTitle,
currentChapter,
mangaChapters,
}: {
mangaId: number;
mangaTitle: string;
currentChapter: TChapterReader;
mangaChapters: TChapterReader[];
}) => {
const { t } = useLingui();
const navigate = useNavigate();
const appTheme = useTheme();

const background = appTheme.palette.background.default;
const textColor = appTheme.palette.getContrastText(background);

const [html, setHtml] = useState<string | null>(null);
const [error, setError] = useState<unknown>(null);
const [fontSize, setFontSize] = useState<number>(19);

const chapterIndex = currentChapter.sourceOrder;

const { prevChapter, nextChapter } = useMemo(() => {
const sorted = [...mangaChapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
const idx = sorted.findIndex((c) => c.sourceOrder === chapterIndex);
return {
prevChapter: idx > 0 ? sorted[idx - 1] : undefined,
nextChapter: idx >= 0 && idx < sorted.length - 1 ? sorted[idx + 1] : undefined,
};
}, [mangaChapters, chapterIndex]);

useEffect(() => {
let cancelled = false;
setHtml(null);
setError(null);

const url = requestManager.getValidImgUrlFor(
`manga/${mangaId}/chapter/${chapterIndex}/text`,
'/api/v1/',
);

requestManager
.getClient()
.fetcher(url, { checkResponseIsJson: false })
.then((res) => res.text())
.then((text) => {
if (!cancelled) {
if (!text.trim()) {
throw new Error('Empty chapter');
}
setHtml(text);
}
})
.catch((e) => {
if (!cancelled) {
setError(e);
}
});

return () => {
cancelled = true;
};
}, [mangaId, chapterIndex]);

const openChapter = (chapter?: TChapterReader) => {
if (chapter) {
navigate(Chapters.getReaderUrl(chapter));
}
};

if (error) {
return <EmptyViewAbsoluteCentered message={t`Unable to load chapter`} messageExtra={getErrorMessage(error)} />;
}

return (
<Box
sx={{
position: 'fixed',
inset: 0,
zIndex: (theme) => theme.zIndex.appBar + 2,
bgcolor: 'background.default',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
userSelect: 'text',
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 2,
py: 1,
borderBottom: 1,
borderColor: 'divider',
flexShrink: 0,
}}
>
<Typography variant="body2" color="text.secondary" noWrap sx={{ flex: 1 }}>
{mangaTitle} · {currentChapter.name}
</Typography>
<Button size="small" onClick={() => setFontSize((s) => Math.max(12, s - 2))}>
A-
</Button>
<Button size="small" onClick={() => setFontSize((s) => Math.min(40, s + 2))}>
A+
</Button>
</Box>

<Box sx={{ flex: 1, overflow: 'auto', display: 'flex' }}>
{html === null ? (
<LoadingPlaceholder />
) : (
<Box
component="iframe"
title={currentChapter.name}
sandbox=""
srcDoc={buildSrcDoc(html, fontSize, background, textColor)}
sx={{ flex: 1, width: '100%', maxWidth: 800, mx: 'auto', border: 0 }}
/>
)}
</Box>

<Box
sx={{
display: 'flex',
justifyContent: 'center',
gap: 2,
px: 2,
py: 1,
borderTop: 1,
borderColor: 'divider',
flexShrink: 0,
}}
>
<Button disabled={!prevChapter} onClick={() => openChapter(prevChapter)}>
{t`Previous`}
</Button>
<Button disabled={!nextChapter} onClick={() => openChapter(nextChapter)}>
{t`Next`}
</Button>
</Box>
</Box>
);
};

export const NovelReader = memo(BaseNovelReader);
22 changes: 21 additions & 1 deletion src/features/reader/screens/Reader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { GET_CHAPTERS_READER } from '@/lib/graphql/chapter/ChapterQuery.ts';
import { TapZoneLayout } from '@/features/reader/tap-zones/TapZoneLayout.tsx';
import { ReaderRGBAFilter } from '@/features/reader/filters/ReaderRGBAFilter.tsx';
import { ReaderViewer } from '@/features/reader/viewer/ReaderViewer.tsx';
import { NovelReader } from '@/features/reader/novel/NovelReader.tsx';
import { READER_BACKGROUND_TO_COLOR } from '@/features/reader/settings/ReaderSettings.constants.tsx';
import { ReaderHotkeys } from '@/features/reader/hotkeys/ReaderHotkeys.tsx';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
Expand Down Expand Up @@ -145,7 +146,15 @@ const BaseReader = ({
chapterListOptions,
);

const isNovelReader = !!manga?.source?.isNovel;

useLayoutEffect(() => {
// novel reader has its own chrome, skip the manga overlay click-catcher
if (isNovelReader) {
setOverride({ status: false, value: null });
return () => setOverride({ status: false, value: null });
}

setOverride({
status: true,
value: (
Expand All @@ -170,7 +179,7 @@ const BaseReader = ({
});

return () => setOverride({ status: false, value: null });
}, [scrollElementRef.current]);
}, [scrollElementRef.current, isNovelReader]);

if (error) {
return (
Expand Down Expand Up @@ -219,6 +228,17 @@ const BaseReader = ({
return null;
}

if (isNovelReader) {
return (
<NovelReader
mangaId={manga.id}
mangaTitle={manga.title}
currentChapter={currentChapter}
mangaChapters={mangaChapters ?? []}
/>
);
}

return (
<Box
sx={{
Expand Down
3 changes: 2 additions & 1 deletion src/lib/graphql/generated/apollo-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1088,7 +1088,7 @@ export type SourceNodeListFieldPolicy = {
pageInfo?: FieldPolicy<any> | FieldReadFunction<any>,
totalCount?: FieldPolicy<any> | FieldReadFunction<any>
};
export type SourceTypeKeySpecifier = ('baseUrl' | 'displayName' | 'extension' | 'filters' | 'iconUrl' | 'id' | 'isConfigurable' | 'isNsfw' | 'lang' | 'manga' | 'meta' | 'name' | 'preferences' | 'supportsLatest' | SourceTypeKeySpecifier)[];
export type SourceTypeKeySpecifier = ('baseUrl' | 'displayName' | 'extension' | 'filters' | 'iconUrl' | 'id' | 'isConfigurable' | 'isNovel' | 'isNsfw' | 'lang' | 'manga' | 'meta' | 'name' | 'preferences' | 'supportsLatest' | SourceTypeKeySpecifier)[];
export type SourceTypeFieldPolicy = {
baseUrl?: FieldPolicy<any> | FieldReadFunction<any>,
displayName?: FieldPolicy<any> | FieldReadFunction<any>,
Expand All @@ -1097,6 +1097,7 @@ export type SourceTypeFieldPolicy = {
iconUrl?: FieldPolicy<any> | FieldReadFunction<any>,
id?: FieldPolicy<any> | FieldReadFunction<any>,
isConfigurable?: FieldPolicy<any> | FieldReadFunction<any>,
isNovel?: FieldPolicy<any> | FieldReadFunction<any>,
isNsfw?: FieldPolicy<any> | FieldReadFunction<any>,
lang?: FieldPolicy<any> | FieldReadFunction<any>,
manga?: FieldPolicy<any> | FieldReadFunction<any>,
Expand Down
1 change: 1 addition & 0 deletions src/lib/graphql/generated/graphql-base.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2662,6 +2662,7 @@ export type SourceType = {
iconUrl: Scalars['String']['output'];
id: Scalars['LongString']['output'];
isConfigurable: Scalars['Boolean']['output'];
isNovel: Scalars['Boolean']['output'];
isNsfw: Scalars['Boolean']['output'];
lang: Scalars['String']['output'];
manga: MangaNodeList;
Expand Down
Loading