Merge pull request #395 from schroda/feature/use_graphql

Feature/use graphql
This commit is contained in:
schroda
2023-10-27 21:10:07 +02:00
committed by GitHub
100 changed files with 11157 additions and 1665 deletions

View File

@@ -1 +1,2 @@
.eslintrc.js .eslintrc.js
src/lib/graphql/generated

View File

@@ -2,7 +2,7 @@ module.exports = {
extends: ['airbnb', 'airbnb-typescript', 'prettier'], extends: ['airbnb', 'airbnb-typescript', 'prettier'],
plugins: ['@typescript-eslint', 'no-relative-import-paths', 'prettier', 'header'], plugins: ['@typescript-eslint', 'no-relative-import-paths', 'prettier', 'header'],
parserOptions: { parserOptions: {
project: ['./tsconfig.json', './tools/scripts/tsconfig.json'], project: ['./tsconfig.json', './tsconfig.node.json', './tools/scripts/tsconfig.json'],
}, },
overrides: [ overrides: [
{ {
@@ -27,6 +27,8 @@ module.exports = {
'prettier/prettier': 'error', 'prettier/prettier': 'error',
'class-methods-use-this': 'off',
'no-plusplus': ['error', { allowForLoopAfterthoughts: true }], 'no-plusplus': ['error', { allowForLoopAfterthoughts: true }],
// just why // just why

4
.gitignore vendored
View File

@@ -6,4 +6,6 @@ node_modules/
build/* build/*
tools/scripts/github_token.json tools/scripts/github_token.json
src/lib/graphql/schema.json

37
gql_codegen.ts Normal file
View File

@@ -0,0 +1,37 @@
/*
* 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 type { CodegenConfig } from '@graphql-codegen/cli';
const config: CodegenConfig = {
overwrite: true,
schema: 'http://localhost:4567/api/graphql',
documents: [
'src/lib/graphql/queries/**',
'src/lib/graphql/mutations/**',
'src/lib/graphql/subscriptions/**',
'src/lib/graphql/Fragments.ts',
],
ignoreNoDocuments: true,
generates: {
'src/lib/graphql/generated/graphql.ts': {
plugins: ['typescript', 'typescript-operations'],
config: {
namingConvention: {
typeNames: 'change-case-all#pascalCase',
transformUnderscore: true,
},
},
},
'src/lib/graphql/generated/apollo-helpers.ts': {
plugins: ['typescript-apollo-client-helpers'],
},
},
};
export default config;

View File

@@ -11,7 +11,10 @@
"build-zip": "cd build && rev=$(git rev-list HEAD --count) && echo r$rev > revision && zip -9 -r ../buildZip/Tachidesk-WebUI-r$rev *", "build-zip": "cd build && rev=$(git rev-list HEAD --count) && echo r$rev > revision && zip -9 -r ../buildZip/Tachidesk-WebUI-r$rev *",
"lint": "eslint src --ext .ts,.tsx,.js,.jsx", "lint": "eslint src --ext .ts,.tsx,.js,.jsx",
"createChangelog": "ts-node tools/scripts/createReleaseChanglog.ts", "createChangelog": "ts-node tools/scripts/createReleaseChanglog.ts",
"updateDeps": "yarn outdated && yarn upgrade && yarn syncyarnlock -s -k && yarn && git add package.json yarn.lock && git commit -m \"Update dependencies\"" "updateDeps": "yarn outdated && yarn upgrade && yarn syncyarnlock -s -k && yarn && git add package.json yarn.lock && git commit -m \"Update dependencies\"",
"gql:codegen-base": "graphql-codegen --config gql_codegen.ts",
"gql:codegen-formatter": "ts-node tools/scripts/codegenFormatter.ts",
"gql:codegen": "yarn gql:codegen-base & yarn gql:codegen-formatter"
}, },
"browserslist": { "browserslist": {
"production": [ "production": [
@@ -26,14 +29,18 @@
] ]
}, },
"dependencies": { "dependencies": {
"@apollo/client": "^3.8.1",
"@emotion/react": "^11.11.1", "@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@fontsource/roboto": "^5.0.8", "@fontsource/roboto": "^5.0.8",
"@mui/icons-material": "^5.14.9", "@mui/icons-material": "^5.14.9",
"@mui/material": "^5.14.9", "@mui/material": "^5.14.9",
"@vitejs/plugin-react-swc": "^3.3.2", "@vitejs/plugin-react-swc": "^3.3.2",
"apollo-upload-client": "^17.0.0",
"axios": "^1.5.0", "axios": "^1.5.0",
"file-selector": "^0.6.0", "file-selector": "^0.6.0",
"graphql-tag": "^2.12.6",
"graphql-ws": "^5.14.1",
"i18next": "^23.5.1", "i18next": "^23.5.1",
"i18next-browser-languagedetector": "^7.1.0", "i18next-browser-languagedetector": "^7.1.0",
"react": "^18.2.0", "react": "^18.2.0",
@@ -42,12 +49,16 @@
"react-i18next": "^13.2.2", "react-i18next": "^13.2.2",
"react-router-dom": "^6.16.0", "react-router-dom": "^6.16.0",
"react-virtuoso": "^4.5.1", "react-virtuoso": "^4.5.1",
"swr": "^2.2.2",
"use-query-params": "^2.2.1", "use-query-params": "^2.2.1",
"vite": "^4.4.9", "vite": "^4.4.9",
"vite-tsconfig-paths": "^4.2.1" "vite-tsconfig-paths": "^4.2.1"
}, },
"devDependencies": { "devDependencies": {
"@graphql-codegen/cli": "^5.0.0",
"@graphql-codegen/client-preset": "^4.1.0",
"@graphql-codegen/typescript-apollo-client-helpers": "^2.2.6",
"@graphql-codegen/typescript-operations": "^4.0.1",
"@types/apollo-upload-client": "^17.0.2",
"@types/node": "^20.6.2", "@types/node": "^20.6.2",
"@types/react": "^18.2.21", "@types/react": "^18.2.21",
"@types/react-beautiful-dnd": "^13.1.4", "@types/react-beautiful-dnd": "^13.1.4",

View File

@@ -10,6 +10,8 @@ import { Container } from '@mui/material';
import CssBaseline from '@mui/material/CssBaseline'; import CssBaseline from '@mui/material/CssBaseline';
import React from 'react'; import React from 'react';
import { Navigate, Route, Routes } from 'react-router-dom'; import { Navigate, Route, Routes } from 'react-router-dom';
import { loadErrorMessages, loadDevMessages } from '@apollo/client/dev';
import { __DEV__ } from '@apollo/client/utilities/globals';
import AppContext from '@/components/context/AppContext'; import AppContext from '@/components/context/AppContext';
import Browse from '@/screens/Browse'; import Browse from '@/screens/Browse';
import DownloadQueue from '@/screens/DownloadQueue'; import DownloadQueue from '@/screens/DownloadQueue';
@@ -31,6 +33,11 @@ import '@/i18n';
import LibrarySettings from '@/screens/settings/LibrarySettings'; import LibrarySettings from '@/screens/settings/LibrarySettings';
import DefaultNavBar from '@/components/navbar/DefaultNavBar'; import DefaultNavBar from '@/components/navbar/DefaultNavBar';
if (__DEV__) {
// Adds messages only in a dev environment
loadDevMessages();
loadErrorMessages();
}
const App: React.FC = () => ( const App: React.FC = () => (
<AppContext> <AppContext>
<CssBaseline /> <CssBaseline />

View File

@@ -14,12 +14,11 @@ import Avatar from '@mui/material/Avatar';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { Box } from '@mui/material'; import { Box } from '@mui/material';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IExtension, TranslationKey } from '@/typings'; import { PartialExtension, TranslationKey } from '@/typings';
import requestManager from '@/lib/RequestManager'; import requestManager from '@/lib/requests/RequestManager.ts';
interface IProps { interface IProps {
extension: IExtension; extension: PartialExtension;
notifyInstall: () => void;
} }
enum ExtensionAction { enum ExtensionAction {
@@ -65,17 +64,16 @@ export default function ExtensionCard(props: IProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { const {
extension: { name, lang, versionName, installed, hasUpdate, obsolete, pkgName, iconUrl, isNsfw }, extension: { name, lang, versionName, isInstalled, hasUpdate, isObsolete, pkgName, iconUrl, isNsfw },
notifyInstall,
} = props; } = props;
const [installedState, setInstalledState] = useState<InstalledStates>(() => { const [installedState, setInstalledState] = useState<InstalledStates>(() => {
if (obsolete) { if (isObsolete) {
return InstalledState.OBSOLETE; return InstalledState.OBSOLETE;
} }
if (hasUpdate) { if (hasUpdate) {
return InstalledState.UPDATE; return InstalledState.UPDATE;
} }
return installed ? InstalledState.UNINSTALL : InstalledState.INSTALL; return isInstalled ? InstalledState.UNINSTALL : InstalledState.INSTALL;
}); });
const langPress = lang === 'all' ? t('extension.language.all') : lang.toUpperCase(); const langPress = lang === 'all' ? t('extension.language.all') : lang.toUpperCase();
@@ -87,19 +85,18 @@ export default function ExtensionCard(props: IProps) {
setInstalledState(state); setInstalledState(state);
switch (action) { switch (action) {
case ExtensionAction.INSTALL: case ExtensionAction.INSTALL:
await requestManager.installExtension(pkgName).response; await requestManager.updateExtension(pkgName, { install: true }).response;
break; break;
case ExtensionAction.UNINSTALL: case ExtensionAction.UNINSTALL:
await requestManager.uninstallExtension(pkgName).response; await requestManager.updateExtension(pkgName, { uninstall: true }).response;
break; break;
case ExtensionAction.UPDATE: case ExtensionAction.UPDATE:
await requestManager.updateExtension(pkgName).response; await requestManager.updateExtension(pkgName, { update: true }).response;
break; break;
default: default:
throw new Error(`Unexpected ExtensionAction "${action}"`); throw new Error(`Unexpected ExtensionAction "${action}"`);
} }
setInstalledState(nextAction); setInstalledState(nextAction);
notifyInstall();
}; };
function handleButtonClick() { function handleButtonClick() {

View File

@@ -12,10 +12,10 @@ import Typography from '@mui/material/Typography';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Avatar, Box, CardContent, styled } from '@mui/material'; import { Avatar, Box, CardContent, styled } from '@mui/material';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IMangaCard } from '@/typings'; import requestManager from '@/lib/requests/RequestManager.ts';
import requestManager from '@/lib/RequestManager';
import { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext'; import { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
import SpinnerImage from '@/components/util/SpinnerImage'; import SpinnerImage from '@/components/util/SpinnerImage';
import { TPartialManga } from '@/typings.ts';
const BottomGradient = styled('div')({ const BottomGradient = styled('div')({
position: 'absolute', position: 'absolute',
@@ -66,7 +66,7 @@ const BadgeContainer = styled('div')({
}); });
interface IProps { interface IProps {
manga: IMangaCard; manga: TPartialManga;
gridLayout?: GridLayout; gridLayout?: GridLayout;
inLibraryIndicator?: boolean; inLibraryIndicator?: boolean;
} }
@@ -75,10 +75,11 @@ const MangaCard = (props: IProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { const {
manga: { id, title, thumbnailUrl, downloadCount, unreadCount: unread, inLibrary }, manga: { id, title, thumbnailUrl: tmpThumbnailUrl, downloadCount, unreadCount: unread, inLibrary },
gridLayout, gridLayout,
inLibraryIndicator, inLibraryIndicator,
} = props; } = props;
const thumbnailUrl = tmpThumbnailUrl ?? 'nonExistingMangaUrl';
const { const {
options: { showUnreadBadge, showDownloadBadge }, options: { showUnreadBadge, showDownloadBadge },
} = useLibraryOptionsContext(); } = useLibraryOptionsContext();
@@ -119,10 +120,10 @@ const MangaCard = (props: IProps) => {
{t('manga.button.in_library')} {t('manga.button.in_library')}
</Typography> </Typography>
)} )}
{showUnreadBadge && unread! > 0 && ( {showUnreadBadge && (unread ?? 0) > 0 && (
<Typography sx={{ backgroundColor: 'primary.dark' }}>{unread}</Typography> <Typography sx={{ backgroundColor: 'primary.dark' }}>{unread}</Typography>
)} )}
{showDownloadBadge && downloadCount! > 0 && ( {showDownloadBadge && (downloadCount ?? 0) > 0 && (
<Typography <Typography
sx={{ sx={{
backgroundColor: 'success.dark', backgroundColor: 'success.dark',

View File

@@ -11,12 +11,12 @@ import Grid, { GridTypeMap } from '@mui/material/Grid';
import { Box, Typography } from '@mui/material'; import { Box, Typography } from '@mui/material';
import { GridItemProps, VirtuosoGrid, VirtuosoGridHandle } from 'react-virtuoso'; import { GridItemProps, VirtuosoGrid, VirtuosoGridHandle } from 'react-virtuoso';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { IMangaCard } from '@/typings';
import EmptyView from '@/components/util/EmptyView'; import EmptyView from '@/components/util/EmptyView';
import LoadingPlaceholder from '@/components/util/LoadingPlaceholder'; import LoadingPlaceholder from '@/components/util/LoadingPlaceholder';
import MangaCard from '@/components/MangaCard'; import MangaCard from '@/components/MangaCard';
import { GridLayout } from '@/components/context/LibraryOptionsContext'; import { GridLayout } from '@/components/context/LibraryOptionsContext';
import useLocalStorage from '@/util/useLocalStorage'; import useLocalStorage from '@/util/useLocalStorage';
import { TPartialManga } from '@/typings.ts';
const GridContainer = React.forwardRef<HTMLDivElement, GridTypeMap['props']>(({ children, ...props }, ref) => ( const GridContainer = React.forwardRef<HTMLDivElement, GridTypeMap['props']>(({ children, ...props }, ref) => (
<Grid {...props} ref={ref} container sx={{ paddingLeft: '5px', paddingRight: '13px' }}> <Grid {...props} ref={ref} container sx={{ paddingLeft: '5px', paddingRight: '13px' }}>
@@ -40,13 +40,13 @@ const GridItemContainerWithDimension = (
); );
}; };
const createMangaCard = (manga: IMangaCard, gridLayout?: GridLayout, inLibraryIndicator?: boolean) => ( const createMangaCard = (manga: TPartialManga, gridLayout?: GridLayout, inLibraryIndicator?: boolean) => (
<MangaCard key={manga.id} manga={manga} gridLayout={gridLayout} inLibraryIndicator={inLibraryIndicator} /> <MangaCard key={manga.id} manga={manga} gridLayout={gridLayout} inLibraryIndicator={inLibraryIndicator} />
); );
type DefaultGridProps = { type DefaultGridProps = {
isLoading: boolean; isLoading: boolean;
mangas: IMangaCard[]; mangas: TPartialManga[];
inLibraryIndicator?: boolean; inLibraryIndicator?: boolean;
GridItemContainer: (props: GridTypeMap['props'] & Partial<GridItemProps>) => JSX.Element; GridItemContainer: (props: GridTypeMap['props'] & Partial<GridItemProps>) => JSX.Element;
gridLayout?: GridLayout; gridLayout?: GridLayout;
@@ -150,7 +150,7 @@ const VerticalGrid = ({
}; };
export interface IMangaGridProps { export interface IMangaGridProps {
mangas: IMangaCard[]; mangas: TPartialManga[];
isLoading: boolean; isLoading: boolean;
message?: string; message?: string;
messageExtra?: JSX.Element; messageExtra?: JSX.Element;

View File

@@ -16,7 +16,7 @@ import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { ISource } from '@/typings'; import { ISource } from '@/typings';
import requestManager from '@/lib/RequestManager'; import requestManager from '@/lib/requests/RequestManager.ts';
import { translateExtensionLanguage } from '@/screens/util/Extensions'; import { translateExtensionLanguage } from '@/screens/util/Extensions';
import { SourceContentType } from '@/screens/SourceMangas'; import { SourceContentType } from '@/screens/SourceMangas';

View File

@@ -9,7 +9,6 @@
import { StyledEngineProvider, ThemeProvider } from '@mui/material/styles'; import { StyledEngineProvider, ThemeProvider } from '@mui/material/styles';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { BrowserRouter as Router } from 'react-router-dom'; import { BrowserRouter as Router } from 'react-router-dom';
import { SWRConfig } from 'swr';
import { QueryParamProvider } from 'use-query-params'; import { QueryParamProvider } from 'use-query-params';
import { ReactRouter6Adapter } from 'use-query-params/adapters/react-router-6'; import { ReactRouter6Adapter } from 'use-query-params/adapters/react-router-6';
import createTheme from '@/theme'; import createTheme from '@/theme';
@@ -36,21 +35,19 @@ const AppContext: React.FC<Props> = ({ children }) => {
const theme = useMemo(() => createTheme(darkTheme), [darkTheme]); const theme = useMemo(() => createTheme(darkTheme), [darkTheme]);
return ( return (
<SWRConfig> <Router>
<Router> <StyledEngineProvider injectFirst>
<StyledEngineProvider injectFirst> <ThemeProvider theme={theme}>
<ThemeProvider theme={theme}> <DarkTheme.Provider value={darkThemeContext}>
<DarkTheme.Provider value={darkThemeContext}> <QueryParamProvider adapter={ReactRouter6Adapter}>
<QueryParamProvider adapter={ReactRouter6Adapter}> <LibraryOptionsContextProvider>
<LibraryOptionsContextProvider> <NavBarContextProvider>{children}</NavBarContextProvider>
<NavBarContextProvider>{children}</NavBarContextProvider> </LibraryOptionsContextProvider>
</LibraryOptionsContextProvider> </QueryParamProvider>
</QueryParamProvider> </DarkTheme.Provider>
</DarkTheme.Provider> </ThemeProvider>
</ThemeProvider> </StyledEngineProvider>
</StyledEngineProvider> </Router>
</Router>
</SWRConfig>
); );
}; };

View File

@@ -9,12 +9,12 @@
import React, { useEffect, useMemo } from 'react'; import React, { useEffect, useMemo } from 'react';
import { StringParam, useQueryParam } from 'use-query-params'; import { StringParam, useQueryParam } from 'use-query-params';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IMangaCard, LibrarySortMode, NullAndUndefined } from '@/typings'; import { LibrarySortMode, NullAndUndefined, TManga } from '@/typings';
import { useSearchSettings } from '@/util/searchSettings'; import { useSearchSettings } from '@/util/searchSettings';
import { useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext'; import { useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
import MangaGrid from '@/components/MangaGrid'; import MangaGrid from '@/components/MangaGrid';
const unreadFilter = (unread: NullAndUndefined<boolean>, { unreadCount }: IMangaCard): boolean => { const unreadFilter = (unread: NullAndUndefined<boolean>, { unreadCount }: TManga): boolean => {
switch (unread) { switch (unread) {
case true: case true:
return !!unreadCount && unreadCount >= 1; return !!unreadCount && unreadCount >= 1;
@@ -25,7 +25,7 @@ const unreadFilter = (unread: NullAndUndefined<boolean>, { unreadCount }: IManga
} }
}; };
const downloadedFilter = (downloaded: NullAndUndefined<boolean>, { downloadCount }: IMangaCard): boolean => { const downloadedFilter = (downloaded: NullAndUndefined<boolean>, { downloadCount }: TManga): boolean => {
switch (downloaded) { switch (downloaded) {
case true: case true:
return !!downloadCount && downloadCount >= 1; return !!downloadCount && downloadCount >= 1;
@@ -36,24 +36,24 @@ const downloadedFilter = (downloaded: NullAndUndefined<boolean>, { downloadCount
} }
}; };
const queryFilter = (query: NullAndUndefined<string>, { title }: IMangaCard): boolean => { const queryFilter = (query: NullAndUndefined<string>, { title }: TManga): boolean => {
if (!query) return true; if (!query) return true;
return title.toLowerCase().includes(query.toLowerCase()); return title.toLowerCase().includes(query.toLowerCase());
}; };
const queryGenreFilter = (query: NullAndUndefined<string>, { genre }: IMangaCard): boolean => { const queryGenreFilter = (query: NullAndUndefined<string>, { genre }: TManga): boolean => {
if (!query) return true; if (!query) return true;
const queries = query.split(',').map((str) => str.toLowerCase().trim()); const queries = query.split(',').map((str) => str.toLowerCase().trim());
return queries.every((element) => genre.map((el) => el.toLowerCase()).includes(element)); return queries.every((element) => genre.map((el) => el.toLowerCase()).includes(element));
}; };
const filterManga = ( const filterManga = (
mangas: IMangaCard[], mangas: TManga[],
query: NullAndUndefined<string>, query: NullAndUndefined<string>,
unread: NullAndUndefined<boolean>, unread: NullAndUndefined<boolean>,
downloaded: NullAndUndefined<boolean>, downloaded: NullAndUndefined<boolean>,
ignoreFilters: boolean, ignoreFilters: boolean,
): IMangaCard[] => ): TManga[] =>
mangas.filter((manga) => { mangas.filter((manga) => {
const ignoreFiltersWhileSearching = ignoreFilters && query?.length; const ignoreFiltersWhileSearching = ignoreFilters && query?.length;
const matchesSearch = queryFilter(query, manga) || queryGenreFilter(query, manga); const matchesSearch = queryFilter(query, manga) || queryGenreFilter(query, manga);
@@ -63,19 +63,20 @@ const filterManga = (
return matchesSearch && matchesFilters; return matchesSearch && matchesFilters;
}); });
const sortByUnread = (a: IMangaCard, b: IMangaCard): number => (a.unreadCount ?? 0) - (b.unreadCount ?? 0); const sortByUnread = (a: TManga, b: TManga): number => (a.unreadCount ?? 0) - (b.unreadCount ?? 0);
const sortByTitle = (a: IMangaCard, b: IMangaCard): number => a.title.localeCompare(b.title); const sortByTitle = (a: TManga, b: TManga): number => a.title.localeCompare(b.title);
const sortByDateAdded = (a: IMangaCard, b: IMangaCard): number => a.inLibraryAt - b.inLibraryAt; const sortByDateAdded = (a: TManga, b: TManga): number => Number(a.inLibraryAt) - Number(b.inLibraryAt);
const sortByLastRead = (a: IMangaCard, b: IMangaCard): number => b.lastReadAt - a.lastReadAt; const sortByLastRead = (a: TManga, b: TManga): number =>
Number(b.lastReadChapter?.lastReadAt ?? 0) - Number(a.lastReadChapter?.lastReadAt ?? 0);
const sortManga = ( const sortManga = (
manga: IMangaCard[], manga: TManga[],
sort: NullAndUndefined<LibrarySortMode>, sort: NullAndUndefined<LibrarySortMode>,
desc: NullAndUndefined<boolean>, desc: NullAndUndefined<boolean>,
): IMangaCard[] => { ): TManga[] => {
const result = [...manga]; const result = [...manga];
switch (sort) { switch (sort) {
@@ -103,17 +104,12 @@ const sortManga = (
}; };
interface LibraryMangaGridProps { interface LibraryMangaGridProps {
mangas: IMangaCard[]; mangas: TManga[];
isLoading: boolean; isLoading: boolean;
message?: string; message?: string;
} }
const LibraryMangaGrid: React.FC<LibraryMangaGridProps & { lastLibraryUpdate: number }> = ({ const LibraryMangaGrid: React.FC<LibraryMangaGridProps> = ({ mangas, isLoading, message }) => {
mangas,
isLoading,
message,
lastLibraryUpdate,
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [query] = useQueryParam('query', StringParam); const [query] = useQueryParam('query', StringParam);
@@ -127,7 +123,7 @@ const LibraryMangaGrid: React.FC<LibraryMangaGridProps & { lastLibraryUpdate: nu
); );
const sortedMangas = useMemo( const sortedMangas = useMemo(
() => sortManga(filteredMangas, options.sorts, options.sortDesc), () => sortManga(filteredMangas, options.sorts, options.sortDesc),
[filteredMangas, lastLibraryUpdate, options.sorts, options.sortDesc], [filteredMangas, options.sorts, options.sortDesc],
); );
const showFilteredOutMessage = const showFilteredOutMessage =
@@ -135,7 +131,7 @@ const LibraryMangaGrid: React.FC<LibraryMangaGridProps & { lastLibraryUpdate: nu
useEffect(() => { useEffect(() => {
window.scrollTo(0, 0); window.scrollTo(0, 0);
}, [filteredMangas]); }, [query, unread, downloaded]);
return ( return (
<MangaGrid <MangaGrid

View File

@@ -6,16 +6,16 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import { useEffect, useState } from 'react'; import { useMemo } from 'react';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import RefreshIcon from '@mui/icons-material/Refresh'; import RefreshIcon from '@mui/icons-material/Refresh';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import { Box } from '@mui/material'; import { Box } from '@mui/material';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IUpdateStatus } from '@/typings'; import requestManager from '@/lib/requests/RequestManager.ts';
import requestManager from '@/lib/RequestManager';
import makeToast from '@/components/util/Toast'; import makeToast from '@/components/util/Toast';
import { UpdaterSubscription } from '@/lib/graphql/generated/graphql.ts';
interface IProgressProps { interface IProgressProps {
progress: number; progress: number;
@@ -32,62 +32,49 @@ function Progress({ progress }: IProgressProps) {
); );
} }
interface IUpdateCheckerProps { const calcProgress = (status: UpdaterSubscription['updateStatusChanged'] | undefined) => {
handleFinishedUpdate: (time: number) => void; if (!status) {
} return 0;
}
function UpdateChecker({ handleFinishedUpdate }: IUpdateCheckerProps) { const finishedUpdates = status.failedJobs.mangas.totalCount + status.completeJobs.mangas.totalCount;
const totalMangas = finishedUpdates + status.pendingJobs.mangas.totalCount + status.runningJobs.mangas.totalCount;
const progress = 100 * (finishedUpdates / totalMangas);
return Number.isNaN(progress) ? 0 : progress;
};
function UpdateChecker({ handleFinishedUpdate }: { handleFinishedUpdate: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [loading, setLoading] = useState(false); const { data: updaterData } = requestManager.useUpdaterSubscription();
const [progress, setProgress] = useState(0); const status = updaterData?.updateStatusChanged;
const loading = !!status?.isRunning;
const progress = useMemo(
() => calcProgress(status),
[
status?.failedJobs.mangas.totalCount,
status?.completeJobs.mangas.totalCount,
status?.pendingJobs.mangas.totalCount,
status?.runningJobs.mangas.totalCount,
],
);
const isUpdateFinished = progress === 100;
if (isUpdateFinished) {
handleFinishedUpdate();
}
const onClick = async () => { const onClick = async () => {
try { try {
setLoading(true);
setProgress(0);
await requestManager.startGlobalUpdate().response; await requestManager.startGlobalUpdate().response;
} catch (e) { } catch (e) {
makeToast(t('global.error.label.update_failed'), 'error'); makeToast(t('global.error.label.update_failed'), 'error');
setLoading(false);
} }
}; };
useEffect(() => {
const wsc = requestManager.getUpdateWebSocket();
// "loading" can't be used since it will be outdated once the state gets changed
// it could be used by adding it as a dependency of "useEffect" but then the socket would
// get closed and connected again every time it changes
let updateStarted = false;
wsc.onmessage = (e) => {
const { running, mangaStatusMap } = JSON.parse(e.data) as IUpdateStatus;
const { COMPLETE = [], RUNNING = [], PENDING = [] } = mangaStatusMap;
const currentProgress = 100 * (COMPLETE.length / (COMPLETE.length + RUNNING.length + PENDING.length));
const isUpdateFinished = currentProgress === 100;
const ignoreFaultyMessage = !updateStarted && !running && isUpdateFinished;
// for some reason the server sends 100% completed manga updates when connecting to the
// socket while no update is running
if (ignoreFaultyMessage) {
return;
}
updateStarted = running;
setLoading(running);
setProgress(Number.isNaN(currentProgress) ? 0 : currentProgress);
if (isUpdateFinished) {
handleFinishedUpdate(Date.now());
}
};
return () => wsc.close();
}, []);
return ( return (
<IconButton onClick={onClick} disabled={loading}> <IconButton onClick={onClick} disabled={loading}>
{loading ? <Progress progress={progress} /> : <RefreshIcon />} {loading ? <Progress progress={progress} /> : <RefreshIcon />}

View File

@@ -1,37 +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 { useEffect, useState } from 'react';
import requestManager from '@/lib/RequestManager';
const useSubscription = <T>(path: string, callback?: (newValue: T) => boolean | void) => {
const [state, setState] = useState<T | undefined>();
useEffect(() => {
const wsc = new WebSocket(requestManager.getValidWebSocketUrl(path));
wsc.onmessage = (e) => {
const data = JSON.parse(e.data) as T;
if (callback) {
// If callback is specified, only update state if callback returns true
// This is so that useSubscription can be used without causing rerender
if (callback(data) === true) {
setState(data);
}
} else {
setState(data);
}
};
return () => wsc.close();
}, [path]);
return { data: state };
};
export default useSubscription;

View File

@@ -27,15 +27,16 @@ import Typography from '@mui/material/Typography';
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IChapter, IDownloadChapter } from '@/typings'; import requestManager from '@/lib/requests/RequestManager.ts';
import requestManager from '@/lib/RequestManager';
import { getUploadDateString } from '@/util/date'; import { getUploadDateString } from '@/util/date';
import DownloadStateIndicator from '@/components/molecules/DownloadStateIndicator'; import DownloadStateIndicator from '@/components/molecules/DownloadStateIndicator';
import { DownloadType, UpdateChapterPatchInput } from '@/lib/graphql/generated/graphql.ts';
import { TChapter } from '@/typings.ts';
interface IProps { interface IProps {
chapter: IChapter; chapter: TChapter;
triggerChaptersUpdate: () => void; chapterIds: number[];
downloadChapter: IDownloadChapter | undefined; downloadChapter: DownloadType | undefined;
showChapterNumber: boolean; showChapterNumber: boolean;
onSelect: (selected: boolean) => void; onSelect: (selected: boolean) => void;
selected: boolean | null; selected: boolean | null;
@@ -45,7 +46,7 @@ const ChapterCard: React.FC<IProps> = (props: IProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const theme = useTheme(); const theme = useTheme();
const { chapter, triggerChaptersUpdate, downloadChapter: dc, showChapterNumber, onSelect, selected } = props; const { chapter, chapterIds, downloadChapter: dc, showChapterNumber, onSelect, selected } = props;
const isSelecting = selected !== null; const isSelecting = selected !== null;
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
@@ -62,26 +63,29 @@ const ChapterCard: React.FC<IProps> = (props: IProps) => {
setAnchorEl(null); setAnchorEl(null);
}; };
const sendChange = (key: string, value: any) => { type UpdatePatchInput = UpdateChapterPatchInput & { markPrevRead?: boolean };
const sendChange = <Key extends keyof UpdatePatchInput>(key: Key, value: UpdatePatchInput[Key]) => {
handleClose(); handleClose();
requestManager if (key === 'markPrevRead') {
.updateChapter(chapter.mangaId, chapter.index, { const index = chapterIds.findIndex((chapterId) => chapterId === chapter.id);
[key]: value, requestManager.updateChapters(chapterIds.slice(index, -1), { isRead: true });
lastPageRead: key === 'read' ? 0 : undefined, return;
}) }
.response.then(() => triggerChaptersUpdate());
requestManager.updateChapter(chapter.id, {
[key]: value,
lastPageRead: key === 'isRead' ? 0 : undefined,
});
}; };
const downloadChapter = () => { const downloadChapter = () => {
requestManager.addChapterToDownloadQueue(chapter.mangaId, chapter.index); requestManager.addChapterToDownloadQueue(chapter.id);
handleClose(); handleClose();
}; };
const deleteChapter = () => { const deleteChapter = () => {
requestManager requestManager.deleteDownloadedChapter(chapter.id);
.deleteDownloadedChapter(chapter.mangaId, chapter.index)
.response.then(() => triggerChaptersUpdate());
handleClose(); handleClose();
}; };
@@ -98,8 +102,8 @@ const ChapterCard: React.FC<IProps> = (props: IProps) => {
} }
}; };
const isDownloaded = chapter.downloaded; const { isDownloaded } = chapter;
const canBeDownloaded = !chapter.downloaded && dc === undefined; const canBeDownloaded = !chapter.isDownloaded && dc === undefined;
return ( return (
<li> <li>
@@ -111,9 +115,9 @@ const ChapterCard: React.FC<IProps> = (props: IProps) => {
> >
<CardActionArea <CardActionArea
component={Link} component={Link}
to={`/manga/${chapter.mangaId}/chapter/${chapter.index}`} to={`/manga/${chapter.manga.id}/chapter/${chapter.sourceOrder}`}
style={{ style={{
color: theme.palette.text[chapter.read ? 'disabled' : 'primary'], color: theme.palette.text[chapter.isRead ? 'disabled' : 'primary'],
}} }}
onClick={handleClick} onClick={handleClick}
> >
@@ -128,7 +132,7 @@ const ChapterCard: React.FC<IProps> = (props: IProps) => {
> >
<Stack direction="column" flex={1}> <Stack direction="column" flex={1}>
<Typography variant="h5" component="h2"> <Typography variant="h5" component="h2">
{chapter.bookmarked && ( {chapter.isBookmarked && (
<BookmarkIcon <BookmarkIcon
color="primary" color="primary"
sx={{ mr: 0.5, position: 'relative', top: '0.15em' }} sx={{ mr: 0.5, position: 'relative', top: '0.15em' }}
@@ -138,7 +142,7 @@ const ChapterCard: React.FC<IProps> = (props: IProps) => {
</Typography> </Typography>
<Typography variant="caption">{chapter.scanlator}</Typography> <Typography variant="caption">{chapter.scanlator}</Typography>
<Typography variant="caption"> <Typography variant="caption">
{getUploadDateString(chapter.uploadDate)} {getUploadDateString(Number(chapter.uploadDate ?? 0))}
{isDownloaded && `${t('chapter.status.label.downloaded')}`} {isDownloaded && `${t('chapter.status.label.downloaded')}`}
</Typography> </Typography>
</Stack> </Stack>
@@ -177,24 +181,24 @@ const ChapterCard: React.FC<IProps> = (props: IProps) => {
<ListItemText>{t('chapter.action.download.add.label.action')}</ListItemText> <ListItemText>{t('chapter.action.download.add.label.action')}</ListItemText>
</MenuItem> </MenuItem>
)} )}
<MenuItem onClick={() => sendChange('bookmarked', !chapter.bookmarked)}> <MenuItem onClick={() => sendChange('isBookmarked', !chapter.isBookmarked)}>
<ListItemIcon> <ListItemIcon>
{chapter.bookmarked && <BookmarkRemove fontSize="small" />} {chapter.isBookmarked && <BookmarkRemove fontSize="small" />}
{!chapter.bookmarked && <BookmarkAdd fontSize="small" />} {!chapter.isBookmarked && <BookmarkAdd fontSize="small" />}
</ListItemIcon> </ListItemIcon>
<ListItemText> <ListItemText>
{chapter.bookmarked && t('chapter.action.bookmark.remove.label.action')} {chapter.isBookmarked && t('chapter.action.bookmark.remove.label.action')}
{!chapter.bookmarked && t('chapter.action.bookmark.add.label.action')} {!chapter.isBookmarked && t('chapter.action.bookmark.add.label.action')}
</ListItemText> </ListItemText>
</MenuItem> </MenuItem>
<MenuItem onClick={() => sendChange('read', !chapter.read)}> <MenuItem onClick={() => sendChange('isRead', !chapter.isRead)}>
<ListItemIcon> <ListItemIcon>
{chapter.read && <RemoveDone fontSize="small" />} {chapter.isRead && <RemoveDone fontSize="small" />}
{!chapter.read && <Done fontSize="small" />} {!chapter.isRead && <Done fontSize="small" />}
</ListItemIcon> </ListItemIcon>
<ListItemText> <ListItemText>
{chapter.read && t('chapter.action.mark_as_read.remove.label.action')} {chapter.isRead && t('chapter.action.mark_as_read.remove.label.action')}
{!chapter.read && t('chapter.action.mark_as_read.add.label.action.current')} {!chapter.isRead && t('chapter.action.mark_as_read.add.label.action.current')}
</ListItemText> </ListItemText>
</MenuItem> </MenuItem>
<MenuItem onClick={() => sendChange('markPrevRead', true)}> <MenuItem onClick={() => sendChange('markPrevRead', true)}>

View File

@@ -11,9 +11,8 @@ import Typography from '@mui/material/Typography';
import React, { ComponentProps, useEffect, useMemo, useRef, useState } from 'react'; import React, { ComponentProps, useEffect, useMemo, useRef, useState } from 'react';
import { Virtuoso } from 'react-virtuoso'; import { Virtuoso } from 'react-virtuoso';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { BatchChaptersChange, IChapter, IDownloadChapter, IQueue, TranslationKey } from '@/typings'; import { TChapter, TManga, TranslationKey } from '@/typings';
import requestManager from '@/lib/RequestManager'; import requestManager from '@/lib/requests/RequestManager.ts';
import useSubscription from '@/components/library/useSubscription';
import ChapterCard from '@/components/manga/ChapterCard'; import ChapterCard from '@/components/manga/ChapterCard';
import ResumeFab from '@/components/manga/ResumeFAB'; import ResumeFab from '@/components/manga/ResumeFAB';
import { filterAndSortChapters, useChapterOptions } from '@/components/manga/util'; import { filterAndSortChapters, useChapterOptions } from '@/components/manga/util';
@@ -22,6 +21,7 @@ import makeToast from '@/components/util/Toast';
import ChaptersToolbarMenu from '@/components/manga/ChaptersToolbarMenu'; import ChaptersToolbarMenu from '@/components/manga/ChaptersToolbarMenu';
import SelectionFAB from '@/components/manga/SelectionFAB'; import SelectionFAB from '@/components/manga/SelectionFAB';
import { DEFAULT_FULL_FAB_HEIGHT } from '@/components/util/StyledFab'; import { DEFAULT_FULL_FAB_HEIGHT } from '@/components/util/StyledFab';
import { DownloadType, UpdateChapterPatchInput } from '@/lib/graphql/generated/graphql.ts';
const StyledVirtuoso = styled(Virtuoso)(({ theme }) => ({ const StyledVirtuoso = styled(Virtuoso)(({ theme }) => ({
listStyle: 'none', listStyle: 'none',
@@ -68,58 +68,54 @@ const actionsStrings: {
}; };
export interface IChapterWithMeta { export interface IChapterWithMeta {
chapter: IChapter; chapter: TChapter;
downloadChapter: IDownloadChapter | undefined; downloadChapter: DownloadType | undefined;
selected: boolean | null; selected: boolean | null;
} }
interface IProps { interface IProps {
mangaId: string; manga: TManga;
isRefreshing: boolean;
} }
const ChapterList: React.FC<IProps> = ({ mangaId }) => { const ChapterList: React.FC<IProps> = ({ manga, isRefreshing }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [selection, setSelection] = useState<number[] | null>(null); const [selection, setSelection] = useState<number[] | null>(null);
const prevQueueRef = useRef<IDownloadChapter[]>(); const prevQueueRef = useRef<DownloadType[]>();
const queue = useSubscription<IQueue>('downloads').data?.queue; const { data: downloaderData } = requestManager.useDownloadSubscription();
const queue = (downloaderData?.downloadChanged.queue as DownloadType[]) ?? [];
const [options, dispatch] = useChapterOptions(mangaId); const [options, dispatch] = useChapterOptions(manga.id);
const { data: chaptersData, mutate, isLoading } = requestManager.useGetMangaChapters(mangaId); const { data: chaptersData, loading: isLoading, refetch } = requestManager.useGetMangaChapters(manga.id);
const chapters = useMemo(() => chaptersData ?? [], [chaptersData]); const chapters = useMemo(() => chaptersData?.chapters.nodes ?? [], [chaptersData?.chapters.nodes]);
const mangaChapterIds = useMemo(() => chapters.map((chapter) => chapter.id), [chapters]);
useEffect(() => { useEffect(() => {
if (prevQueueRef.current && queue) { if (prevQueueRef.current && queue) {
const prevQueue = prevQueueRef.current; const prevQueue = prevQueueRef.current;
const changedDownloads = queue.filter((cd) => { const changedDownloads = queue.filter((cd) => {
const prevChapterDownload = prevQueue.find( const prevChapterDownload = prevQueue.find(
(pcd) => cd.chapterIndex === pcd.chapterIndex && cd.mangaId === pcd.mangaId, (pcd) =>
cd.chapter.sourceOrder === pcd.chapter.sourceOrder &&
cd.chapter.manga.id === pcd.chapter.manga.id,
); );
if (!prevChapterDownload) return true; if (!prevChapterDownload) return true;
return cd.state !== prevChapterDownload.state; return cd.state !== prevChapterDownload.state;
}); });
if (changedDownloads.length > 0) { if (changedDownloads.length > 0 || prevQueue?.length !== queue.length) {
mutate(); refetch();
} }
} }
prevQueueRef.current = queue; prevQueueRef.current = queue;
}, [queue]); }, [queue]);
const visibleChapters = useMemo( const visibleChapters = useMemo(() => filterAndSortChapters(chapters, options), [chapters, options]);
() => filterAndSortChapters(chapters, options), //
[chapters, options],
);
const firstUnreadChapter = useMemo( const nextChapterIndexToRead = (manga.lastReadChapter?.sourceOrder ?? 0) + 1;
() => const isLatestChapterRead = manga.chapters.totalCount === manga.lastReadChapter?.sourceOrder;
chapters
.slice()
.reverse()
.find((chapter) => !chapter.read),
[chapters],
);
const handleSelection = (index: number) => { const handleSelection = (index: number) => {
const chapter = visibleChapters[index]; const chapter = visibleChapters[index];
@@ -154,26 +150,67 @@ const ChapterList: React.FC<IProps> = ({ mangaId }) => {
if (action === 'download') { if (action === 'download') {
actionPromise = requestManager.addChaptersToDownloadQueue(chapterIds).response; actionPromise = requestManager.addChaptersToDownloadQueue(chapterIds).response;
} else { } else {
const change: BatchChaptersChange = {}; const change: UpdateChapterPatchInput = {};
if (action === 'delete') change.delete = true; if (action === 'bookmark') change.isBookmarked = true;
else if (action === 'bookmark') change.isBookmarked = true;
else if (action === 'unbookmark') change.isBookmarked = false; else if (action === 'unbookmark') change.isBookmarked = false;
else if (action === 'mark_as_read' || action === 'mark_as_unread') { else if (action === 'mark_as_read' || action === 'mark_as_unread') {
change.isRead = action === 'mark_as_read'; change.isRead = action === 'mark_as_read';
change.lastPageRead = 0; change.lastPageRead = 0;
} }
actionPromise = requestManager.updateChapters(chapterIds, change).response; if (action === 'delete') {
actionPromise = requestManager.deleteDownloadedChapters(chapterIds).response;
} else {
actionPromise = requestManager.updateChapters(chapterIds, change).response;
}
} }
actionPromise actionPromise
.then(() => makeToast(t(actionsStrings[action].success, { count: chapterIds.length }), 'success')) .then(() => makeToast(t(actionsStrings[action].success, { count: chapterIds.length }), 'success'))
.then(() => mutate())
.catch(() => makeToast(t(actionsStrings[action].error, { count: chapterIds.length }), 'error')); .catch(() => makeToast(t(actionsStrings[action].error, { count: chapterIds.length }), 'error'));
}; };
if (isLoading) { 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 = selection?.includes(chapter.id) ?? null;
return {
chapter,
downloadChapter,
selected,
};
}),
[queue, selection, visibleChapters],
);
const selectedChapters = useMemo(() => {
if (!selection) {
return null;
}
return chaptersWithMeta.filter(({ chapter }) => selection.includes(chapter.id));
}, [selection, chapters]);
const chapterListFAB = useMemo(() => {
if (selectedChapters) {
return <SelectionFAB selectedChapters={selectedChapters} onAction={handleFabAction} />;
}
if (!isLatestChapterRead) {
return <ResumeFab chapterIndex={nextChapterIndexToRead} mangaId={manga.id} />;
}
return null;
}, [selectedChapters, isLatestChapterRead]);
if (isLoading || (noChaptersFound && isRefreshing)) {
return ( return (
<div <div
style={{ style={{
@@ -187,24 +224,6 @@ const ChapterList: React.FC<IProps> = ({ mangaId }) => {
); );
} }
const noChaptersFound = chapters.length === 0;
const noChaptersMatchingFilter = !noChaptersFound && visibleChapters.length === 0;
const chaptersWithMeta: IChapterWithMeta[] = 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,
};
});
const selectedChapters =
selection === null ? null : chaptersWithMeta.filter(({ chapter }) => selection.includes(chapter.id));
return ( return (
<> <>
<Stack direction="column" sx={{ position: 'relative' }}> <Stack direction="column" sx={{ position: 'relative' }}>
@@ -266,8 +285,8 @@ const ChapterList: React.FC<IProps> = ({ mangaId }) => {
return ( return (
<ChapterCard <ChapterCard
{...chaptersWithMeta[index]} {...chaptersWithMeta[index]}
chapterIds={mangaChapterIds}
showChapterNumber={options.showChapterNumber} showChapterNumber={options.showChapterNumber}
triggerChaptersUpdate={() => mutate()}
onSelect={() => handleSelection(index)} onSelect={() => handleSelection(index)}
/> />
); );
@@ -276,11 +295,7 @@ const ChapterList: React.FC<IProps> = ({ mangaId }) => {
overscan={window.innerHeight * 0.5} overscan={window.innerHeight * 0.5}
/> />
</Stack> </Stack>
{selectedChapters !== null ? ( {chapterListFAB}
<SelectionFAB selectedChapters={selectedChapters} onAction={handleFabAction} />
) : (
firstUnreadChapter && <ResumeFab chapter={firstUnreadChapter} mangaId={mangaId} />
)}
</> </>
); );
}; };

View File

@@ -10,13 +10,12 @@ import FavoriteIcon from '@mui/icons-material/Favorite';
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder'; import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
import PublicIcon from '@mui/icons-material/Public'; import PublicIcon from '@mui/icons-material/Public';
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
import React, { useEffect } from 'react'; import React, { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { mutate } from 'swr';
import { t as translate } from 'i18next'; import { t as translate } from 'i18next';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import { IManga, ISource } from '@/typings'; import { ISource, TManga } from '@/typings';
import requestManager from '@/lib/RequestManager'; import requestManager from '@/lib/requests/RequestManager.ts';
import makeToast from '@/components/util/Toast'; import makeToast from '@/components/util/Toast';
const DetailsWrapper = styled('div')(({ theme }) => ({ const DetailsWrapper = styled('div')(({ theme }) => ({
@@ -125,11 +124,36 @@ const Genres = styled('div')(() => ({
}, },
})); }));
const OpenSourceButton = ({ url }: { url?: string | null }) => {
const { t } = useTranslation();
const button = useMemo(
() => (
<Button disabled={!!url} startIcon={<PublicIcon />} size="large">
{t('global.button.open_site')}
</Button>
),
[url],
);
if (!url) {
return button;
}
return (
<a href={url} target="_blank" rel="noreferrer">
<Button startIcon={<PublicIcon />} size="large">
{t('global.button.open_site')}
</Button>
</a>
);
};
interface IProps { interface IProps {
manga: IManga; manga: TManga;
} }
function getSourceName(source: ISource) { function getSourceName(source?: ISource | null) {
if (!source) { if (!source) {
return translate('global.label.unknown'); return translate('global.label.unknown');
} }
@@ -137,12 +161,16 @@ function getSourceName(source: ISource) {
return source.displayName ?? source.id; return source.displayName ?? source.id;
} }
function getValueOrUnknown(val: string) { function getValueOrUnknown(val?: string | null) {
return val || 'UNKNOWN'; return val || 'UNKNOWN';
} }
const MangaDetails: React.FC<IProps> = ({ manga }) => { const MangaDetails: React.FC<IProps> = ({ manga }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: categoriesData, loading: areCategoriesLoading } = requestManager.useGetCategories();
const categories = categoriesData?.categories.nodes ?? [];
const defaultCategoryIds = categories.filter((category) => category.default).map((category) => category.id);
const [updateMangaCategories] = requestManager.useUpdateMangaCategories();
useEffect(() => { useEffect(() => {
if (!manga.source) { if (!manga.source) {
@@ -151,13 +179,24 @@ const MangaDetails: React.FC<IProps> = ({ manga }) => {
}, [manga.source]); }, [manga.source]);
const addToLibrary = () => { const addToLibrary = () => {
mutate(`/api/v1/manga/${manga.id}`, { ...manga, inLibrary: true }, { revalidate: false }); Promise.all([
requestManager.addMangaToLibrary(manga.id).response.then(() => mutate(`/api/v1/manga/${manga.id}`)); requestManager.updateManga(manga.id, { inLibrary: true }).response,
updateMangaCategories({
variables: { input: { id: manga.id, patch: { addToCategories: defaultCategoryIds } } },
}),
])
.then(() => makeToast(t('library.info.label.added_to_library'), 'success'))
.catch(() => {
makeToast(t('library.error.label.add_to_library'), 'error');
});
}; };
const removeFromLibrary = () => { const removeFromLibrary = () => {
mutate(`/api/v1/manga/${manga.id}`, { ...manga, inLibrary: false }, { revalidate: false }); Promise.all([requestManager.updateManga(manga.id, { inLibrary: false }).response])
requestManager.removeMangaFromLibrary(manga.id).response.then(() => mutate(`/api/v1/manga/${manga.id}`)); .then(() => makeToast(t('library.info.label.removed_from_library'), 'success'))
.catch(() => {
makeToast(t('library.error.label.remove_from_library'), 'error');
});
}; };
return ( return (
@@ -165,7 +204,9 @@ const MangaDetails: React.FC<IProps> = ({ manga }) => {
<TopContentWrapper> <TopContentWrapper>
<ThumbnailMetadataWrapper> <ThumbnailMetadataWrapper>
<Thumbnail> <Thumbnail>
<img src={requestManager.getValidImgUrlFor(manga.thumbnailUrl)} alt="Manga Thumbnail" /> {manga.thumbnailUrl && (
<img src={requestManager.getValidImgUrlFor(manga.thumbnailUrl)} alt="Manga Thumbnail" />
)}
</Thumbnail> </Thumbnail>
<Metadata> <Metadata>
<h1>{manga.title}</h1> <h1>{manga.title}</h1>
@@ -184,6 +225,7 @@ const MangaDetails: React.FC<IProps> = ({ manga }) => {
<MangaButtonsContainer inLibrary={manga.inLibrary}> <MangaButtonsContainer inLibrary={manga.inLibrary}>
<div> <div>
<Button <Button
disabled={areCategoriesLoading}
startIcon={manga.inLibrary ? <FavoriteIcon /> : <FavoriteBorderIcon />} startIcon={manga.inLibrary ? <FavoriteIcon /> : <FavoriteBorderIcon />}
onClick={manga.inLibrary ? removeFromLibrary : addToLibrary} onClick={manga.inLibrary ? removeFromLibrary : addToLibrary}
size="large" size="large"
@@ -191,11 +233,7 @@ const MangaDetails: React.FC<IProps> = ({ manga }) => {
{manga.inLibrary ? t('manga.button.in_library') : t('manga.button.add_to_library')} {manga.inLibrary ? t('manga.button.in_library') : t('manga.button.add_to_library')}
</Button> </Button>
</div> </div>
<a href={manga.realUrl} target="_blank" rel="noreferrer"> <OpenSourceButton url={manga.realUrl} />
<Button startIcon={<PublicIcon />} size="large">
{t('global.button.open_site')}
</Button>
</a>
</MangaButtonsContainer> </MangaButtonsContainer>
</TopContentWrapper> </TopContentWrapper>
<BottomContentWrapper> <BottomContentWrapper>

View File

@@ -21,11 +21,11 @@ import {
} from '@mui/material'; } from '@mui/material';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IManga } from '@/typings';
import CategorySelect from '@/components/navbar/action/CategorySelect'; import CategorySelect from '@/components/navbar/action/CategorySelect';
import { TManga } from '@/typings.ts';
interface IProps { interface IProps {
manga: IManga; manga: TManga;
onRefresh: () => any; onRefresh: () => any;
refreshing: boolean; refreshing: boolean;
} }

View File

@@ -9,25 +9,21 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { PlayArrow } from '@mui/icons-material'; import { PlayArrow } from '@mui/icons-material';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IChapter } from '@/typings';
import StyledFab from '@/components/util/StyledFab'; import StyledFab from '@/components/util/StyledFab';
interface ResumeFABProps { interface ResumeFABProps {
chapter: IChapter; chapterIndex: number;
mangaId: string; mangaId: number;
} }
export default function ResumeFab(props: ResumeFABProps) { export default function ResumeFab(props: ResumeFABProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { const { chapterIndex, mangaId } = props;
chapter: { index },
mangaId,
} = props;
return ( return (
<StyledFab component={Link} variant="extended" color="primary" to={`/manga/${mangaId}/chapter/${index}`}> <StyledFab component={Link} variant="extended" color="primary" to={`/manga/${mangaId}/chapter/${chapterIndex}`}>
<PlayArrow /> <PlayArrow />
{index === 1 ? t('global.button.start') : t('global.button.resume')} {chapterIndex === 1 ? t('global.button.start') : t('global.button.resume')}
</StyledFab> </StyledFab>
); );
} }

View File

@@ -63,38 +63,38 @@ const SelectionFAB: React.FC<SelectionFABProps> = (props) => {
<SelectionFABActionItem <SelectionFABActionItem
action="download" action="download"
matchingChapters={selectedChapters.filter( matchingChapters={selectedChapters.filter(
({ chapter: c, downloadChapter: dc }) => !c.downloaded && dc === undefined, ({ chapter: c, downloadChapter: dc }) => !c.isDownloaded && dc === undefined,
)} )}
onClick={handleAction} onClick={handleAction}
title={t('chapter.action.download.add.button.selected')} title={t('chapter.action.download.add.button.selected')}
/> />
<SelectionFABActionItem <SelectionFABActionItem
action="delete" action="delete"
matchingChapters={selectedChapters.filter(({ chapter }) => chapter.downloaded)} matchingChapters={selectedChapters.filter(({ chapter }) => chapter.isDownloaded)}
onClick={handleAction} onClick={handleAction}
title={t('chapter.action.download.delete.button.selected')} title={t('chapter.action.download.delete.button.selected')}
/> />
<SelectionFABActionItem <SelectionFABActionItem
action="bookmark" action="bookmark"
matchingChapters={selectedChapters.filter(({ chapter }) => !chapter.bookmarked)} matchingChapters={selectedChapters.filter(({ chapter }) => !chapter.isBookmarked)}
onClick={handleAction} onClick={handleAction}
title={t('chapter.action.bookmark.add.button.selected')} title={t('chapter.action.bookmark.add.button.selected')}
/> />
<SelectionFABActionItem <SelectionFABActionItem
action="unbookmark" action="unbookmark"
matchingChapters={selectedChapters.filter(({ chapter }) => chapter.bookmarked)} matchingChapters={selectedChapters.filter(({ chapter }) => chapter.isBookmarked)}
onClick={handleAction} onClick={handleAction}
title={t('chapter.action.bookmark.remove.button.selected')} title={t('chapter.action.bookmark.remove.button.selected')}
/> />
<SelectionFABActionItem <SelectionFABActionItem
action="mark_as_read" action="mark_as_read"
matchingChapters={selectedChapters.filter(({ chapter }) => !chapter.read)} matchingChapters={selectedChapters.filter(({ chapter }) => !chapter.isRead)}
onClick={handleAction} onClick={handleAction}
title={t('chapter.action.mark_as_read.add.button.selected')} title={t('chapter.action.mark_as_read.add.button.selected')}
/> />
<SelectionFABActionItem <SelectionFABActionItem
action="mark_as_unread" action="mark_as_unread"
matchingChapters={selectedChapters.filter(({ chapter }) => chapter.read)} matchingChapters={selectedChapters.filter(({ chapter }) => chapter.isRead)}
onClick={handleAction} onClick={handleAction}
title={t('chapter.action.mark_as_read.remove.button.selected')} title={t('chapter.action.mark_as_read.remove.button.selected')}
/> />

View File

@@ -7,8 +7,7 @@
*/ */
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { mutate } from 'swr'; import requestManager from '@/lib/requests/RequestManager.ts';
import requestManager, { RequestManager } from '@/lib/RequestManager';
export const useRefreshManga = (mangaId: string) => { export const useRefreshManga = (mangaId: string) => {
const [fetchingOnline, setFetchingOnline] = useState(false); const [fetchingOnline, setFetchingOnline] = useState(false);
@@ -16,14 +15,8 @@ export const useRefreshManga = (mangaId: string) => {
const handleRefresh = useCallback(async () => { const handleRefresh = useCallback(async () => {
setFetchingOnline(true); setFetchingOnline(true);
await Promise.all([ await Promise.all([
requestManager.getManga(mangaId, true).response.then((res) => { requestManager.getMangaFetch(mangaId, { awaitRefetchQueries: true }).response,
mutate(`${RequestManager.API_VERSION}manga/${mangaId}`, res, { revalidate: false }); requestManager.getMangaChaptersFetch(mangaId, { awaitRefetchQueries: true }).response,
}),
requestManager.getMangaChapters(mangaId, true).response.then((res) =>
mutate(`${RequestManager.API_VERSION}manga/${mangaId}/chapters`, res, {
revalidate: false,
}),
),
]).finally(() => setFetchingOnline(false)); ]).finally(() => setFetchingOnline(false));
}, [mangaId]); }, [mangaId]);

View File

@@ -11,8 +11,8 @@ import {
ChapterListOptions, ChapterListOptions,
ChapterOptionsReducerAction, ChapterOptionsReducerAction,
ChapterSortMode, ChapterSortMode,
IChapter,
NullAndUndefined, NullAndUndefined,
TChapter,
TranslationKey, TranslationKey,
} from '@/typings'; } from '@/typings';
import { useReducerLocalStorage } from '@/util/useLocalStorage'; import { useReducerLocalStorage } from '@/util/useLocalStorage';
@@ -46,7 +46,7 @@ function chapterOptionsReducer(state: ChapterListOptions, actions: ChapterOption
} }
} }
export function unreadFilter(unread: NullAndUndefined<boolean>, { read: isChapterRead }: IChapter) { export function unreadFilter(unread: NullAndUndefined<boolean>, { isRead: isChapterRead }: TChapter) {
switch (unread) { switch (unread) {
case true: case true:
return !isChapterRead; return !isChapterRead;
@@ -57,7 +57,7 @@ export function unreadFilter(unread: NullAndUndefined<boolean>, { read: isChapte
} }
} }
function downloadFilter(downloaded: NullAndUndefined<boolean>, { downloaded: chapterDownload }: IChapter) { function downloadFilter(downloaded: NullAndUndefined<boolean>, { isDownloaded: chapterDownload }: TChapter) {
switch (downloaded) { switch (downloaded) {
case true: case true:
return chapterDownload; return chapterDownload;
@@ -68,7 +68,7 @@ function downloadFilter(downloaded: NullAndUndefined<boolean>, { downloaded: cha
} }
} }
function bookmarkedFilter(bookmarked: NullAndUndefined<boolean>, { bookmarked: chapterBookmarked }: IChapter) { function bookmarkedFilter(bookmarked: NullAndUndefined<boolean>, { isBookmarked: chapterBookmarked }: TChapter) {
switch (bookmarked) { switch (bookmarked) {
case true: case true:
return chapterBookmarked; return chapterBookmarked;
@@ -79,7 +79,7 @@ function bookmarkedFilter(bookmarked: NullAndUndefined<boolean>, { bookmarked: c
} }
} }
export function filterAndSortChapters(chapters: IChapter[], options: ChapterListOptions): IChapter[] { export function filterAndSortChapters(chapters: TChapter[], options: ChapterListOptions): TChapter[] {
const filtered = options.active const filtered = options.active
? chapters.filter( ? chapters.filter(
(chp) => (chp) =>
@@ -88,14 +88,17 @@ export function filterAndSortChapters(chapters: IChapter[], options: ChapterList
bookmarkedFilter(options.bookmarked, chp), bookmarkedFilter(options.bookmarked, chp),
) )
: [...chapters]; : [...chapters];
const Sorted = options.sortBy === 'fetchedAt' ? filtered.sort((a, b) => a.fetchedAt - b.fetchedAt) : filtered; const Sorted =
options.sortBy === 'fetchedAt'
? filtered.sort((a, b) => Number(a.fetchedAt ?? 0) - Number(b.fetchedAt ?? 0))
: filtered;
if (options.reverse) { if (options.reverse) {
Sorted.reverse(); Sorted.reverse();
} }
return Sorted; return Sorted;
} }
export const useChapterOptions = (mangaId: string) => export const useChapterOptions = (mangaId: number) =>
useReducerLocalStorage<ChapterListOptions, ChapterOptionsReducerAction>( useReducerLocalStorage<ChapterListOptions, ChapterOptionsReducerAction>(
chapterOptionsReducer, chapterOptionsReducer,
`${mangaId}filterOptions`, `${mangaId}filterOptions`,

View File

@@ -10,17 +10,18 @@ import { CircularProgress, Box } from '@mui/material';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IDownloadChapter, TranslationKey } from '@/typings'; import { TranslationKey } from '@/typings';
import { DownloadState, DownloadType } from '@/lib/graphql/generated/graphql.ts';
interface DownloadStateIndicatorProps { interface DownloadStateIndicatorProps {
download: IDownloadChapter; download: DownloadType;
} }
const DOWNLOAD_STATE_TO_TRANSLATION_KEY_MAP: { [state in IDownloadChapter['state']]: TranslationKey } = { const DOWNLOAD_STATE_TO_TRANSLATION_KEY_MAP: { [state in DownloadState]: TranslationKey } = {
Downloading: 'download.state.label.downloading', DOWNLOADING: 'download.state.label.downloading',
Error: 'download.state.label.error', ERROR: 'download.state.label.error',
Finished: 'download.state.label.finished', FINISHED: 'download.state.label.finished',
Queued: 'download.state.label.queued', QUEUED: 'download.state.label.queued',
} as const; } as const;
const DownloadStateIndicator: React.FC<DownloadStateIndicatorProps> = ({ download }) => { const DownloadStateIndicator: React.FC<DownloadStateIndicatorProps> = ({ download }) => {

View File

@@ -24,7 +24,7 @@ import ListItemText from '@mui/material/ListItemText';
import ListItemSecondaryAction from '@mui/material/ListItemSecondaryAction'; import ListItemSecondaryAction from '@mui/material/ListItemSecondaryAction';
import Collapse from '@mui/material/Collapse'; import Collapse from '@mui/material/Collapse';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ChapterOffset, IChapter, IManga, IMangaCard, IReaderSettings } from '@/typings'; import { ChapterOffset, IReaderSettings, TChapter, TManga } from '@/typings';
import ReaderSettingsOptions from '@/components/reader/ReaderSettingsOptions'; import ReaderSettingsOptions from '@/components/reader/ReaderSettingsOptions';
const Root = styled('div')(({ theme }) => ({ const Root = styled('div')(({ theme }) => ({
@@ -114,8 +114,8 @@ const OpenDrawerButton = styled(IconButton)(({ theme }) => ({
interface IProps { interface IProps {
settings: IReaderSettings; settings: IReaderSettings;
setSettingValue: (key: keyof IReaderSettings, value: string | boolean) => void; setSettingValue: (key: keyof IReaderSettings, value: string | boolean) => void;
manga: IManga | IMangaCard; manga: TManga;
chapter: IChapter; chapter: TChapter;
curPage: number; curPage: number;
scrollToPage: (page: number) => void; scrollToPage: (page: number) => void;
openNextChapter: (offset: ChapterOffset, setHistory: (nextChapterIndex: number) => void) => Promise<void>; openNextChapter: (offset: ChapterOffset, setHistory: (nextChapterIndex: number) => void) => Promise<void>;
@@ -303,7 +303,7 @@ export default function ReaderNavBar(props: IProps) {
<IconButton <IconButton
title={t('reader.button.previous_chapter')} title={t('reader.button.previous_chapter')}
sx={{ gridArea: 'pre' }} sx={{ gridArea: 'pre' }}
disabled={disableChapterNavButtons || chapter.index <= 1} disabled={disableChapterNavButtons || chapter.sourceOrder <= 1}
onClick={() => onClick={() =>
openNextChapter(ChapterOffset.PREV, (prevChapterIndex) => { openNextChapter(ChapterOffset.PREV, (prevChapterIndex) => {
navigate(`/manga/${manga.id}/chapter/${prevChapterIndex}`, { navigate(`/manga/${manga.id}/chapter/${prevChapterIndex}`, {
@@ -321,11 +321,11 @@ export default function ReaderNavBar(props: IProps) {
<FormControl <FormControl
sx={{ gridArea: 'current' }} sx={{ gridArea: 'current' }}
size="small" size="small"
disabled={disableChapterNavButtons || chapter.index < 1} disabled={disableChapterNavButtons || chapter.sourceOrder < 1}
> >
<Select <Select
MenuProps={MenuProps} MenuProps={MenuProps}
value={chapter.index >= 1 ? chapter.index : ''} value={chapter.sourceOrder >= 1 ? chapter.sourceOrder : ''}
displayEmpty displayEmpty
onChange={({ target: { value: selectedChapter } }) => { onChange={({ target: { value: selectedChapter } }) => {
navigate(`/manga/${manga.id}/chapter/${selectedChapter}`, { navigate(`/manga/${manga.id}/chapter/${selectedChapter}`, {
@@ -337,7 +337,7 @@ export default function ReaderNavBar(props: IProps) {
}); });
}} }}
> >
{Array(Math.max(0, chapter.chapterCount)) {Array(Math.max(0, manga.chapters.totalCount))
.fill(1) .fill(1)
.map((ignoreValue, index) => ( .map((ignoreValue, index) => (
<MenuItem key={`Chapter#${index + 1}`} value={index + 1}>{`${t( <MenuItem key={`Chapter#${index + 1}`} value={index + 1}>{`${t(
@@ -351,8 +351,8 @@ export default function ReaderNavBar(props: IProps) {
sx={{ gridArea: 'next' }} sx={{ gridArea: 'next' }}
disabled={ disabled={
disableChapterNavButtons || disableChapterNavButtons ||
chapter.index < 1 || chapter.sourceOrder < 1 ||
chapter.index >= chapter.chapterCount chapter.sourceOrder >= manga.chapters.totalCount
} }
onClick={() => { onClick={() => {
openNextChapter(ChapterOffset.NEXT, (nextChapterIndex) => openNextChapter(ChapterOffset.NEXT, (nextChapterIndex) =>

View File

@@ -16,7 +16,7 @@ import Checkbox from '@mui/material/Checkbox';
import FormControlLabel from '@mui/material/FormControlLabel'; import FormControlLabel from '@mui/material/FormControlLabel';
import FormGroup from '@mui/material/FormGroup'; import FormGroup from '@mui/material/FormGroup';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import requestManager from '@/lib/RequestManager'; import requestManager from '@/lib/requests/RequestManager.ts';
interface IProps { interface IProps {
open: boolean; open: boolean;
@@ -29,8 +29,10 @@ export default function CategorySelect(props: IProps) {
const { open, setOpen, mangaId } = props; const { open, setOpen, mangaId } = props;
const { data: mangaCategoriesData, mutate } = requestManager.useGetMangaCategories(mangaId); const { data: mangaResult } = requestManager.useGetManga(mangaId);
const { data: categoriesData } = requestManager.useGetCategories(); const { data } = requestManager.useGetCategories();
const categoriesData = data?.categories.nodes;
const [triggerMutate] = requestManager.useUpdateMangaCategories();
const allCategories = useMemo(() => { const allCategories = useMemo(() => {
const cats = [...(categoriesData ?? [])]; // make copy const cats = [...(categoriesData ?? [])]; // make copy
@@ -40,7 +42,7 @@ export default function CategorySelect(props: IProps) {
return cats; return cats;
}, [categoriesData]); }, [categoriesData]);
const selectedIds = mangaCategoriesData?.map((c) => c.id) ?? []; const selectedIds = mangaResult?.manga.categories.nodes.map((c) => c.id) ?? [];
const handleCancel = () => { const handleCancel = () => {
setOpen(false); setOpen(false);
@@ -53,10 +55,18 @@ export default function CategorySelect(props: IProps) {
const handleChange = (event: React.ChangeEvent<HTMLInputElement>, categoryId: number) => { const handleChange = (event: React.ChangeEvent<HTMLInputElement>, categoryId: number) => {
const { checked } = event.target as HTMLInputElement; const { checked } = event.target as HTMLInputElement;
(checked // TODO - update to only update categories when clicking OK - can now be updated in one go with graphql
? requestManager.addMangaToCategory(mangaId, categoryId) triggerMutate({
: requestManager.removeMangaFromCategory(mangaId, categoryId) variables: {
).response.then(() => mutate()); input: {
id: mangaId,
patch: {
addToCategories: checked ? [categoryId] : [],
removeFromCategories: !checked ? [categoryId] : [],
},
},
},
});
}; };
return ( return (

View File

@@ -7,10 +7,10 @@
*/ */
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IMangaCard } from '@/typings';
import MangaGrid, { IMangaGridProps } from '@/components/MangaGrid'; import MangaGrid, { IMangaGridProps } from '@/components/MangaGrid';
import { TPartialManga } from '@/typings.ts';
function filterManga(mangas: IMangaCard[]): IMangaCard[] { function filterManga(mangas: TPartialManga[]): TPartialManga[] {
return mangas; return mangas;
} }

View File

@@ -10,7 +10,7 @@ import FilterListIcon from '@mui/icons-material/FilterList';
import { Button, Stack, Box } from '@mui/material'; import { Button, Stack, Box } from '@mui/material';
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ISourceFilters, IState } from '@/typings'; import { SourceFilters } from '@/typings';
import OptionsPanel from '@/components/molecules/OptionsPanel'; import OptionsPanel from '@/components/molecules/OptionsPanel';
import CheckBoxFilter from '@/components/source/filters/CheckBoxFilter'; import CheckBoxFilter from '@/components/source/filters/CheckBoxFilter';
import HeaderFilter from '@/components/source/filters/HeaderFilter'; import HeaderFilter from '@/components/source/filters/HeaderFilter';
@@ -25,14 +25,14 @@ import SeperatorFilter from '@/components/source/filters/SeparatorFilter';
import StyledFab from '@/components/util/StyledFab'; import StyledFab from '@/components/util/StyledFab';
interface IFilters { interface IFilters {
sourceFilter: ISourceFilters[]; sourceFilter: SourceFilters[];
updateFilterValue: Function; updateFilterValue: Function;
group: number | undefined; group: number | undefined;
update: any; update: any;
} }
interface IFilters1 { interface IFilters1 {
sourceFilter: ISourceFilters[]; sourceFilter: SourceFilters[];
updateFilterValue: Function; updateFilterValue: Function;
resetFilterValue: Function; resetFilterValue: Function;
setTriggerUpdate: Function; setTriggerUpdate: Function;
@@ -42,85 +42,89 @@ interface IFilters1 {
export function Options({ sourceFilter, group, updateFilterValue, update }: IFilters) { export function Options({ sourceFilter, group, updateFilterValue, update }: IFilters) {
return ( return (
<Stack key={`filters ${group}`}> <Stack key={`filters ${group}`}>
{sourceFilter.map((e: ISourceFilters, index) => { {sourceFilter.map((e, index) => {
let checkif = update.find( let checkif = update.find(
(el: { group: number | undefined; position: number }) => (el: { group: number | undefined; position: number }) =>
el.group === group && el.position === index, el.group === group && el.position === index,
); );
checkif = checkif ? checkif.state : checkif; checkif = checkif ? checkif.state : checkif;
switch (e.type) { switch (e.type) {
case 'CheckBox': case 'CheckBoxFilter':
return ( return (
<CheckBoxFilter <CheckBoxFilter
key={`filters ${e.filter.name}`} key={`filters ${e.name}`}
name={e.filter.name} name={e.name}
state={checkif != null ? checkif === 'true' : (e.filter.state as boolean)} state={checkif ?? e.CheckBoxFilterDefault}
position={index} position={index}
group={group} group={group}
updateFilterValue={updateFilterValue} updateFilterValue={updateFilterValue}
update={update} update={update}
/> />
); );
case 'Group': case 'GroupFilter':
return ( return (
<GroupFilter <GroupFilter
key={`filters ${e.filter.name}`} key={`filters ${e.name}`}
name={e.filter.name} name={e.name}
state={e.filter.state as ISourceFilters[]} state={e.filters}
position={index} position={index}
updateFilterValue={updateFilterValue} updateFilterValue={updateFilterValue}
update={update} update={update}
/> />
); );
case 'Header': case 'HeaderFilter':
return <HeaderFilter key={`filters ${e.filter.name}`} name={e.filter.name} />; return <HeaderFilter key={`filters ${e.name}`} name={e.name} />;
case 'Select': case 'SelectFilter':
return ( return (
<SelectFilter <SelectFilter
key={`filters ${e.filter.name}`} key={`filters ${e.name}`}
name={e.filter.name} name={e.name}
values={e.filter.displayValues} values={e.values}
state={checkif != null ? parseInt(checkif, 10) : (e.filter.state as number)} state={checkif != null ? parseInt(checkif, 10) : e.SelectFilterDefault}
selected={e.filter.selected}
position={index} position={index}
group={group} group={group}
updateFilterValue={updateFilterValue} updateFilterValue={updateFilterValue}
update={update} update={update}
/> />
); );
case 'Separator': case 'SeparatorFilter':
return <SeperatorFilter key={`filters ${e.filter.name}`} name={e.filter.name} />; return <SeperatorFilter key={`filters ${e.name}`} name={e.name} />;
case 'Sort': case 'SortFilter':
return ( return (
<SortFilter <SortFilter
key={`filters ${e.filter.name}`} key={`filters ${e.name}`}
name={e.filter.name} name={e.name}
values={e.filter.values} values={e.values}
state={checkif ? JSON.parse(checkif) : { ...(e.filter.state as IState) }} state={
checkif ?? {
ascending: e.SortFilterDefault?.ascending,
index: e.SortFilterDefault?.index,
}
}
position={index} position={index}
group={group} group={group}
updateFilterValue={updateFilterValue} updateFilterValue={updateFilterValue}
update={update} update={update}
/> />
); );
case 'Text': case 'TextFilter':
return ( return (
<TextFilter <TextFilter
key={`filters ${e.filter.name}`} key={`filters ${e.name}`}
name={e.filter.name} name={e.name}
state={checkif ?? (e.filter.state as string)} state={checkif ?? e.TextFilterDefault}
position={index} position={index}
group={group} group={group}
updateFilterValue={updateFilterValue} updateFilterValue={updateFilterValue}
update={update} update={update}
/> />
); );
case 'TriState': case 'TriStateFilter':
return ( return (
<TriStateFilter <TriStateFilter
key={`filters ${e.filter.name}`} key={`filters ${e.name}`}
name={e.filter.name} name={e.name}
state={checkif != null ? parseInt(checkif, 10) : (e.filter.state as number)} state={checkif != null ? checkif : e.TriStateFilterDefault}
position={index} position={index}
group={group} group={group}
updateFilterValue={updateFilterValue} updateFilterValue={updateFilterValue}
@@ -128,7 +132,7 @@ export function Options({ sourceFilter, group, updateFilterValue, update }: IFil
/> />
); );
default: default:
return <Box key={`${e.filter.name}null`} />; throw new Error(`Unknown source filter "${e}"`);
} }
})} })}
</Stack> </Stack>

View File

@@ -27,7 +27,7 @@ const CheckBoxFilter: React.FC<Props> = (props: Props) => {
const upd = update.filter( const upd = update.filter(
(e: { position: number; group: number | undefined }) => !(position === e.position && group === e.group), (e: { position: number; group: number | undefined }) => !(position === e.position && group === e.group),
); );
updateFilterValue([...upd, { position, state: event.target.checked.toString(), group }]); updateFilterValue([...upd, { type: 'checkBoxState', position, state: event.target.checked, group }]);
}; };
if (state !== undefined) { if (state !== undefined) {

View File

@@ -10,11 +10,11 @@ import { ExpandLess, ExpandMore } from '@mui/icons-material';
import { Collapse, ListItemButton, ListItemText, Stack, Box } from '@mui/material'; import { Collapse, ListItemButton, ListItemText, Stack, Box } from '@mui/material';
import React from 'react'; import React from 'react';
// eslint-disable-next-line import/no-cycle // eslint-disable-next-line import/no-cycle
import { ISourceFilters } from '@/typings'; import { ExtractByKeyValue, SourceFilters } from '@/typings';
import { Options } from '@/components/source/SourceOptions'; import { Options } from '@/components/source/SourceOptions';
interface Props { interface Props {
state: ISourceFilters[]; state: ExtractByKeyValue<SourceFilters, '__typename', 'GroupFilter'>['filters'];
name: string; name: string;
position: number; position: number;
updateFilterValue: Function; updateFilterValue: Function;
@@ -36,7 +36,7 @@ const GroupFilter: React.FC<Props> = (props: Props) => {
{/* Container is moved outside 2, so content has to go inside 4 */} {/* Container is moved outside 2, so content has to go inside 4 */}
<Stack sx={{ mx: 4 }}> <Stack sx={{ mx: 4 }}>
<Options <Options
sourceFilter={state} sourceFilter={state as SourceFilters[]}
group={position} group={position}
updateFilterValue={updateFilterValue} updateFilterValue={updateFilterValue}
update={update} update={update}

View File

@@ -16,56 +16,12 @@ interface Props {
values: any; values: any;
name: string; name: string;
state: number; state: number;
selected: Selected | undefined;
position: number; position: number;
updateFilterValue: Function; updateFilterValue: Function;
group: number | undefined; group: number | undefined;
update: any; update: any;
} }
interface Selected {
displayname: string;
value: string;
_value: string;
}
function hasSelect(
values: Selected[],
name: string,
state: number,
position: number,
updateFilterValue: Function,
update: any,
group?: number,
) {
const [val, setval] = React.useState(state);
if (values) {
const handleChange = (event: { target: { name: any; value: any } }) => {
const vall = values.map((e) => e.displayname).indexOf(`${event.target.value}`);
setval(vall);
const upd = update.filter(
(e: { position: number; group: number | undefined }) => !(position === e.position && group === e.group),
);
updateFilterValue([...upd, { position, state: vall.toString(), group }]);
};
const rett = values.map((e: Selected) => (
<MenuItem key={`${name} ${e.displayname}`} value={e.displayname}>
{e.displayname}
</MenuItem>
));
return (
<FormControl sx={{ my: 1 }} variant="standard">
<InputLabel>{name}</InputLabel>
<Select name={name} value={values[val].displayname} label={name} onChange={handleChange}>
{rett}
</Select>
</FormControl>
);
}
return null;
}
function noSelect( function noSelect(
values: string[], values: string[],
name: string, name: string,
@@ -84,7 +40,7 @@ function noSelect(
const upd = update.filter( const upd = update.filter(
(e: { position: number; group: number | undefined }) => !(position === e.position && group === e.group), (e: { position: number; group: number | undefined }) => !(position === e.position && group === e.group),
); );
updateFilterValue([...upd, { position, state: vall.toString(), group }]); updateFilterValue([...upd, { type: 'selectState', position, state: vall, group }]);
}; };
const rett = values.map((value: string) => ( const rett = values.map((value: string) => (
@@ -104,21 +60,7 @@ function noSelect(
return null; return null;
} }
const SelectFilter: React.FC<Props> = ({ const SelectFilter: React.FC<Props> = ({ values, name, state, position, updateFilterValue, update, group }) =>
values, noSelect(values, name, state, position, updateFilterValue, update, group);
name,
state,
selected,
position,
updateFilterValue,
update,
group,
}) => {
if (selected === undefined) {
return noSelect(values, name, state, position, updateFilterValue, update, group);
}
return hasSelect(values, name, state, position, updateFilterValue, update, group);
};
export default SelectFilter; export default SelectFilter;

View File

@@ -9,13 +9,13 @@
import { ExpandLess, ExpandMore } from '@mui/icons-material'; import { ExpandLess, ExpandMore } from '@mui/icons-material';
import { Collapse, ListItemButton, ListItemText, Stack, Box } from '@mui/material'; import { Collapse, ListItemButton, ListItemText, Stack, Box } from '@mui/material';
import React from 'react'; import React from 'react';
import { IState } from '@/typings';
import SortRadioInput from '@/components/atoms/SortRadioInput'; import SortRadioInput from '@/components/atoms/SortRadioInput';
import { SortSelectionInput } from '@/lib/graphql/generated/graphql.ts';
interface Props { interface Props {
values: any; values: any;
name: string; name: string;
state: IState; state: SortSelectionInput;
position: number; position: number;
group: number | undefined; group: number | undefined;
updateFilterValue: Function; updateFilterValue: Function;
@@ -45,7 +45,7 @@ const SortFilter: React.FC<Props> = (props: Props) => {
const upd = update.filter( const upd = update.filter(
(e: { position: number; group: number | undefined }) => !(position === e.position && group === e.group), (e: { position: number; group: number | undefined }) => !(position === e.position && group === e.group),
); );
updateFilterValue([...upd, { position, state: JSON.stringify(tmp), group }]); updateFilterValue([...upd, { type: 'sortState', position, state: tmp, group }]);
}; };
return ( return (

View File

@@ -29,7 +29,7 @@ const TextFilter: React.FC<Props> = (props) => {
const upd = update.filter( const upd = update.filter(
(el: { position: number; group: number | undefined }) => !(position === el.position && group === el.group), (el: { position: number; group: number | undefined }) => !(position === el.position && group === el.group),
); );
updateFilterValue([...upd, { position, state: inputText, group }]); updateFilterValue([...upd, { type: 'textState', position, state: inputText, group }]);
}, [inputText]); }, [inputText]);
if (state !== undefined) { if (state !== undefined) {

View File

@@ -8,9 +8,10 @@
import React from 'react'; import React from 'react';
import ThreeStateCheckboxInput from '@/components/atoms/ThreeStateCheckboxInput'; import ThreeStateCheckboxInput from '@/components/atoms/ThreeStateCheckboxInput';
import { TriState } from '@/lib/graphql/generated/graphql.ts';
interface Props { interface Props {
state: number; state: TriState;
name: string; name: string;
position: number; position: number;
group: number | undefined; group: number | undefined;
@@ -18,9 +19,35 @@ interface Props {
update: any; update: any;
} }
const convertTriStateToNumber = (triState: TriState): number => {
switch (triState) {
case TriState.Ignore:
return 0;
case TriState.Include:
return 1;
case TriState.Exclude:
return 2;
default:
throw new Error(`Unexpected TriState ${triState}`);
}
};
const convertNumberToTriState = (state: number): TriState => {
switch (state) {
case 0:
return TriState.Ignore;
case 1:
return TriState.Include;
case 2:
return TriState.Exclude;
default:
throw new Error(`Unexpected state number ${state}`);
}
};
const TriStateFilter: React.FC<Props> = (props) => { const TriStateFilter: React.FC<Props> = (props) => {
const { state, name, position, group, updateFilterValue, update } = props; const { state, name, position, group, updateFilterValue, update } = props;
const [val, setval] = React.useState<number>(Number(state)); const [val, setval] = React.useState(convertTriStateToNumber(state));
const handleChange = (checked: boolean | null | undefined) => { const handleChange = (checked: boolean | null | undefined) => {
// eslint-disable-next-line no-nested-ternary // eslint-disable-next-line no-nested-ternary
@@ -32,8 +59,9 @@ const TriStateFilter: React.FC<Props> = (props) => {
updateFilterValue([ updateFilterValue([
...upd, ...upd,
{ {
type: 'triState',
position, position,
state: newState.toString(), state: convertNumberToTriState(newState),
group, group,
}, },
]); ]);

View File

@@ -22,22 +22,29 @@ import { EditTextPreferenceProps } from '@/typings';
export default function EditTextPreference(props: EditTextPreferenceProps) { export default function EditTextPreference(props: EditTextPreferenceProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { title, summary, dialogTitle, dialogMessage, currentValue, updateValue } = props; const {
EditTextPreferenceTitle: title,
summary,
dialogTitle,
dialogMessage,
EditTextPreferenceCurrentValue: currentValue,
updateValue,
} = props;
const [internalCurrentValue, setInternalCurrentValue] = useState<string>(currentValue); const [internalCurrentValue, setInternalCurrentValue] = useState<string>(currentValue ?? '');
const [dialogOpen, setDialogOpen] = useState<boolean>(false); const [dialogOpen, setDialogOpen] = useState<boolean>(false);
const handleDialogCancel = () => { const handleDialogCancel = () => {
setDialogOpen(false); setDialogOpen(false);
// reset the dialog // reset the dialog
setInternalCurrentValue(currentValue); setInternalCurrentValue(currentValue ?? '');
}; };
const handleDialogSubmit = () => { const handleDialogSubmit = () => {
setDialogOpen(false); setDialogOpen(false);
updateValue(internalCurrentValue); updateValue('editTextState', internalCurrentValue);
}; };
return ( return (

View File

@@ -85,12 +85,20 @@ function ListDialog(props: IListDialogProps) {
} }
export default function ListPreference(props: ListPreferenceProps) { export default function ListPreference(props: ListPreferenceProps) {
const { title, summary, currentValue, updateValue, entryValues, entries } = props; const {
const [internalCurrentValue, setInternalCurrentValue] = useState<string>(currentValue); ListPreferenceTitle: title,
summary,
ListPreferenceCurrentValue: currentValue,
ListPreferenceDefault: defaultValue,
updateValue,
entryValues,
entries,
} = props;
const [internalCurrentValue, setInternalCurrentValue] = useState(currentValue ?? defaultValue ?? '');
const [dialogOpen, setDialogOpen] = useState<boolean>(false); const [dialogOpen, setDialogOpen] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
setInternalCurrentValue(currentValue); setInternalCurrentValue(currentValue ?? defaultValue ?? '');
}, [currentValue]); }, [currentValue]);
const findEntryOf = (value: string) => { const findEntryOf = (value: string) => {
@@ -104,6 +112,10 @@ export default function ListPreference(props: ListPreferenceProps) {
}; };
const getSummary = () => { const getSummary = () => {
if (currentValue == null) {
return '';
}
if (summary === '%s') { if (summary === '%s') {
return findEntryOf(currentValue); return findEntryOf(currentValue);
} }
@@ -112,7 +124,7 @@ export default function ListPreference(props: ListPreferenceProps) {
const handleDialogClose = (newValue: string | null) => { const handleDialogClose = (newValue: string | null) => {
if (newValue !== null) { if (newValue !== null) {
updateValue(findEntryValueOf(newValue)); updateValue('listState', findEntryValueOf(newValue));
// appear smooth // appear smooth
setInternalCurrentValue(newValue); setInternalCurrentValue(newValue);
@@ -127,7 +139,7 @@ export default function ListPreference(props: ListPreferenceProps) {
<ListItemText primary={title} secondary={getSummary()} /> <ListItemText primary={title} secondary={getSummary()} />
</ListItemButton> </ListItemButton>
<ListDialog <ListDialog
title={title} title={title ?? ''}
open={dialogOpen} open={dialogOpen}
onClose={handleDialogClose} onClose={handleDialogClose}
value={findEntryOf(internalCurrentValue)} value={findEntryOf(internalCurrentValue)}

View File

@@ -99,32 +99,40 @@ function ListDialog(props: IListDialogProps) {
} }
export default function MultiSelectListPreference(props: MultiSelectListPreferenceProps) { export default function MultiSelectListPreference(props: MultiSelectListPreferenceProps) {
const { title, summary, currentValue, updateValue, entryValues, entries } = props; const {
const [internalCurrentValue, setInternalCurrentValue] = useState<string[]>(currentValue); MultiSelectListPreferenceTitle: title,
summary,
MultiSelectListPreferenceCurrentValue: currentValue,
MultiSelectListPreferenceDefault: defaultValue,
updateValue,
entryValues,
entries,
} = props;
const [internalCurrentValue, setInternalCurrentValue] = useState(currentValue ?? defaultValue);
const [dialogOpen, setDialogOpen] = useState<boolean>(false); const [dialogOpen, setDialogOpen] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
setInternalCurrentValue(currentValue); setInternalCurrentValue(currentValue);
}, [currentValue]); }, [currentValue]);
const findEntriesOf = (values: string[]) => const findEntriesOf = (values?: string[] | null) =>
values.map((value) => { values?.map((value) => {
const idx = entryValues.indexOf(value); const idx = entryValues.indexOf(value);
return entries[idx]; return entries[idx];
}); }) ?? [];
const findEntryValuesOf = (values: string[]) => const findEntryValuesOf = (values?: string[] | null) =>
values.map((value) => { values?.map((value) => {
const idx = entries.indexOf(value); const idx = entries.indexOf(value);
return entryValues[idx]; return entryValues[idx];
}); }) ?? [];
const getSummary = () => summary; const getSummary = () => summary;
const handleDialogClose = (newValue: string[] | null) => { const handleDialogClose = (newValue: string[] | null) => {
if (newValue !== null) { if (newValue !== null) {
// console.log(newValue); // console.log(newValue);
updateValue(findEntryValuesOf(newValue)); updateValue('multiSelectState', findEntryValuesOf(newValue));
// appear smooth // appear smooth
setInternalCurrentValue(newValue); setInternalCurrentValue(newValue);
@@ -139,7 +147,7 @@ export default function MultiSelectListPreference(props: MultiSelectListPreferen
<ListItemText primary={title} secondary={getSummary()} /> <ListItemText primary={title} secondary={getSummary()} />
</ListItemButton> </ListItemButton>
<ListDialog <ListDialog
title={title} title={title ?? ''}
open={dialogOpen} open={dialogOpen}
onClose={handleDialogClose} onClose={handleDialogClose}
selectedValues={findEntriesOf(internalCurrentValue)} selectedValues={findEntriesOf(internalCurrentValue)}

View File

@@ -21,23 +21,48 @@ function getTwoStateType(type: 'Checkbox' | 'Switch') {
return Checkbox; return Checkbox;
} }
const getTwoStateValues = (
props: TwoStatePreferenceProps,
): {
title: string;
defaultValue: boolean;
currentValue?: boolean | null | undefined;
} => {
if (props.type === 'CheckBoxPreference') {
return {
title: props.CheckBoxTitle,
defaultValue: props.CheckBoxDefault,
currentValue: props.CheckBoxCheckBoxCurrentValue,
};
}
return {
title: props.SwitchPreferenceTitle,
defaultValue: props.SwitchPreferenceDefault,
currentValue: props.SwitchPreferenceCurrentValue,
};
};
function TwoSatePreference(props: TwoStatePreferenceProps) { function TwoSatePreference(props: TwoStatePreferenceProps) {
const { title, summary, currentValue, updateValue, type } = props; const { title, defaultValue, currentValue, summary, updateValue, twoStateType } = {
const [internalCurrentValue, setInternalCurrentValue] = useState<boolean>(currentValue); ...props,
...getTwoStateValues(props),
};
const [internalCurrentValue, setInternalCurrentValue] = useState(currentValue ?? defaultValue);
useEffect(() => { useEffect(() => {
setInternalCurrentValue(currentValue); setInternalCurrentValue(currentValue ?? defaultValue);
}, [currentValue]); }, [currentValue]);
return ( return (
<ListItem> <ListItem>
<ListItemText primary={title} secondary={summary} /> <ListItemText primary={title} secondary={summary} />
<ListItemSecondaryAction> <ListItemSecondaryAction>
{createElement(getTwoStateType(type), { {createElement(getTwoStateType(twoStateType), {
edge: 'end', edge: 'end',
checked: internalCurrentValue, checked: internalCurrentValue,
onChange: () => { onChange: () => {
updateValue(!currentValue); updateValue(twoStateType === 'Switch' ? 'switchState' : 'checkBoxState', !currentValue);
// appear smooth // appear smooth
setInternalCurrentValue(!currentValue); setInternalCurrentValue(!currentValue);
@@ -49,11 +74,11 @@ function TwoSatePreference(props: TwoStatePreferenceProps) {
} }
export function CheckBoxPreference(props: CheckBoxPreferenceProps) { export function CheckBoxPreference(props: CheckBoxPreferenceProps) {
return <TwoSatePreference {...props} type="Checkbox" />; return <TwoSatePreference {...props} twoStateType="Checkbox" />;
} }
export function SwitchPreferenceCompat(props: SwitchPreferenceCompatProps) { export function SwitchPreferenceCompat(props: SwitchPreferenceCompatProps) {
return <TwoSatePreference {...props} type="Switch" />; return <TwoSatePreference {...props} twoStateType="Switch" />;
} }
export default { CheckBoxPreference, SwitchPreferenceCompat }; export default { CheckBoxPreference, SwitchPreferenceCompat };

View File

@@ -22,7 +22,7 @@ function getRandomErrorFace() {
interface IProps { interface IProps {
message: string; message: string;
messageExtra?: JSX.Element; messageExtra?: JSX.Element | string;
} }
export default function EmptyView({ message, messageExtra }: IProps) { export default function EmptyView({ message, messageExtra }: IProps) {

View File

@@ -268,7 +268,15 @@
"error": { "error": {
"label": { "label": {
"empty": "Your library is empty", "empty": "Your library is empty",
"no_matches": "No manga matches this filter" "no_matches": "No manga matches this filter",
"add_to_library": "Added manga to library!",
"remove_from_library": "Could not add manga to library!"
}
},
"info": {
"label": {
"added_to_library": "Added manga to library!",
"removed_from_library": "Removed manga from library!"
} }
}, },
"option": { "option": {

View File

@@ -1,700 +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 { AxiosInstance, AxiosRequestConfig } from 'axios';
import useSWR, { Middleware, SWRConfiguration, SWRResponse } from 'swr';
import useSWRInfinite, { SWRInfiniteConfiguration, SWRInfiniteResponse } from 'swr/infinite';
import {
BackupValidationResult,
BatchChaptersChange,
IAbout,
ICategory,
IChapter,
IExtension,
IManga,
IMangaChapter,
IncludeInGlobalUpdate,
ISource,
ISourceFilters,
IUpdateStatus,
Metadata,
PaginatedList,
PaginatedMangaList,
SourcePreferences,
SourceSearchResult,
UpdateCheck,
} from '@/typings';
import { HttpMethod as DefaultHttpMethod, IRestClient, RestClient } from '@/lib/RestClient';
import storage from '@/util/localStorage';
enum SWRHttpMethod {
SWR_GET,
SWR_GET_INFINITE,
SWR_POST,
SWR_POST_INFINITE,
}
type HttpMethodType = DefaultHttpMethod | SWRHttpMethod;
const HttpMethod = { ...SWRHttpMethod, ...DefaultHttpMethod };
type RequestOption = { doOnlineFetch?: boolean };
type CustomSWROptions<Data> = {
skipRequest?: boolean;
getEndpoint?: (index: number, previousData: Data | null) => string | null;
disableCache?: boolean;
};
type SWROptions<Data = any, Error = any> = SWRConfiguration<Data, Error> & CustomSWROptions<Data>;
type SWRInfiniteOptions<Data = any, Error = any> = SWRInfiniteConfiguration<Data, Error> & CustomSWROptions<Data>;
type SWRInfiniteResponseLoadInfo = {
isInitialLoad: boolean;
isLoadMore: boolean;
};
type AbortableRequest = { abortRequest: AbortController['abort'] };
export type AbortableAxiosResponse<Data = any> = { response: Promise<Data> } & AbortableRequest;
export type AbortableSWRResponse<Data = any, Error = any> = SWRResponse<Data, Error> & AbortableRequest;
export type AbortableSWRInfiniteResponse<Data = any, Error = any> = SWRInfiniteResponse<Data, Error> &
AbortableRequest &
SWRInfiniteResponseLoadInfo;
const isLoadingMore = (swrResult: SWRInfiniteResponse): boolean => {
const isNextPageMissing = !!swrResult.data && typeof swrResult.data[swrResult.size - 1] === 'undefined';
const isRequestActive = swrResult.isValidating;
// SWR "isLoading" state is only updated for the first load, for every subsequent load it's "false"
return !swrResult.isLoading && swrResult.size > 0 && isNextPageMissing && isRequestActive;
};
const disableSwrInfiniteCache: Middleware = (useSWRNext) => (key, fetcher, config) => {
const swr = useSWRNext(key, fetcher, config) as unknown as SWRInfiniteResponse;
const { size, data, isLoading, isValidating } = swr;
const isActuallyValidating = !isLoading && !isLoadingMore(swr) && isValidating;
return {
...swr,
isLoading: isActuallyValidating ? true : isLoading,
data: isActuallyValidating ? undefined : data,
size: isActuallyValidating ? 1 : size,
} as SWRResponse;
};
const disableSwrCache: Middleware = (useSWRNext) => (key, fetcher, config) => {
const swr = useSWRNext(key, fetcher, config);
const { data, isLoading, isValidating } = swr;
return { ...swr, isLoading: isValidating ? true : isLoading, data: isValidating ? undefined : data };
};
// the following endpoints have not been implemented:
// - PUT /api/v1/manga/{mangaId}/chapter/{chapterIndex} - modify chapter # PATCH endpoint used instead
// - POST /api/v1/backup/import - import backup # "import backup file" endpoint used instead
// - POST /api/v1/backup/validate - validate backup # "validate backup file" endpoint used instead
// - GET /api/v1/backup/export - export backup # no function needed, url gets called via link triggering the download
export class RequestManager {
public static readonly API_VERSION = '/api/v1/';
private readonly restClient: RestClient = new RestClient();
public getClient(): IRestClient {
return this.restClient;
}
public updateClient(config: Partial<AxiosInstance['defaults']>): void {
this.restClient.updateConfig(config);
}
public getBaseUrl(): string {
return this.restClient.getClient().defaults.baseURL!;
}
public getWebSocketBaseUrl(): string {
return this.getBaseUrl().replace('http', 'ws');
}
public getValidWebSocketUrl(path: string, apiVersion = RequestManager.API_VERSION): string {
return `${this.getWebSocketBaseUrl()}${apiVersion}${path}`;
}
public getUpdateWebSocket(): WebSocket {
return new WebSocket(this.getValidWebSocketUrl('update'));
}
public getDownloadWebSocket(): WebSocket {
return new WebSocket(this.getValidWebSocketUrl('downloads'));
}
public getValidUrlFor(endpoint: string, apiVersion: string = RequestManager.API_VERSION): string {
return `${this.getBaseUrl()}${apiVersion}${endpoint}`;
}
public getValidImgUrlFor(imageUrl: string, apiVersion: string = ''): string {
const useCache = storage.getItem('useCache', true);
const useCacheQuery = `?useCache=${useCache}`;
// server provided image urls already contain the api version
return `${this.getValidUrlFor(imageUrl, apiVersion)}${useCacheQuery}`;
}
private useSwr<
Data = any,
ErrorResponse = any,
OptionsSWR extends SWROptions<Data, ErrorResponse> = SWROptions<Data, ErrorResponse>,
>(
url: string,
httpMethod: DefaultHttpMethod,
{
data,
axiosOptions,
swrOptions,
}: {
data?: Data;
axiosOptions?: AxiosRequestConfig;
swrOptions?: OptionsSWR;
} = {},
): SWRResponse<Data, ErrorResponse> {
const { skipRequest, disableCache, ...swrConfig } = swrOptions ?? {};
// in case "null" gets passed as the url, SWR won't do the request
return useSWR(skipRequest ? null : url, {
fetcher: (path: string) => this.restClient.fetcher(path, { data, httpMethod, config: axiosOptions }),
use: disableCache ? [disableSwrCache] : undefined,
...swrConfig,
});
}
public useSwrInfinite<
Data = any,
ErrorResponse = any,
OptionsSWR extends SWRInfiniteOptions<Data, ErrorResponse> = SWRInfiniteOptions<Data, ErrorResponse>,
>(
getEndpoint: Required<CustomSWROptions<Data>>['getEndpoint'],
httpMethod: DefaultHttpMethod,
{
data,
axiosOptions,
swrOptions,
}: { data?: any; axiosOptions?: AxiosRequestConfig; swrOptions?: OptionsSWR } = {},
): SWRInfiniteResponse<Data, ErrorResponse> & SWRInfiniteResponseLoadInfo {
const { skipRequest, disableCache, ...swrConfig } = swrOptions ?? {};
// useSWRInfinite will (by default) revalidate the first page, to check if the other pages have to be revalidated as well
const swrResult = useSWRInfinite<Data, ErrorResponse>(
(index, previousData) => {
const pageEndpoint = getEndpoint(index, previousData);
return pageEndpoint !== null && !skipRequest ? this.getValidUrlFor(pageEndpoint) : null;
},
{
fetcher: (path: string) => this.restClient.fetcher(path, { httpMethod, data, config: axiosOptions }),
use: disableCache ? [disableSwrInfiniteCache] : undefined,
...swrConfig,
},
);
const customSwrResult = {
...swrResult,
isInitialLoad: swrResult.isLoading,
isLoadMore: isLoadingMore(swrResult),
};
customSwrResult.isLoading = customSwrResult.isInitialLoad || customSwrResult.isLoadMore;
return customSwrResult;
}
/**
* Performs the actual server request.
*
* In case {@link HttpMethod.GET_SWR} gets passed, the "useSWR" hook gets called.
* In case {@link HttpMethod.GET_SWR_INFINITE} gets passed, the "useSWRInfinite" hook gets called.
* In that case "getEndpoint" has to be passed, which gets used over "endpoint"
*
* Pass "skipRequest" to make SWR skip sending the request to the server.
* In case "formData" is passed, "data" gets ignored.
*/
private doRequest<
Result extends AbortableAxiosResponse | AbortableSWRResponse | AbortableSWRInfiniteResponse,
OptionsSWR extends SWROptions | SWRInfiniteOptions,
>(
httpMethod: HttpMethodType,
endpoint: string,
{
apiVersion = RequestManager.API_VERSION,
data: dataToSend,
formData,
axiosOptions,
swrOptions,
}: {
apiVersion?: string;
data?: any;
formData?: { [key: string]: any };
axiosOptions?: AxiosRequestConfig;
swrOptions?: OptionsSWR;
} = {},
): Result {
const url = `${apiVersion}${endpoint}`;
let data = dataToSend;
if (formData) {
data = new FormData();
Object.entries(formData).forEach(([key, value]) => {
if (value !== undefined) data.append(key, value); // "append" automatically converts non string or blob values to strings
});
}
const abortController = new AbortController();
const abortRequest = (reason?: any): void => {
if (!abortController.signal.aborted) {
abortController.abort(reason);
}
};
const axiosOptionsWithAbortController = { ...axiosOptions, signal: abortController.signal };
switch (httpMethod) {
case HttpMethod.SWR_GET:
return {
...(this.useSwr(url, HttpMethod.GET, { axiosOptions, swrOptions }) as Result),
abortRequest,
};
case HttpMethod.SWR_GET_INFINITE:
// throw TypeError in case options aren't correctly passed
return {
...(this.useSwrInfinite(swrOptions!.getEndpoint!, HttpMethod.GET, {
axiosOptions: axiosOptionsWithAbortController,
swrOptions,
}) as Result),
abortRequest,
};
case SWRHttpMethod.SWR_POST_INFINITE:
return {
...(this.useSwrInfinite(swrOptions!.getEndpoint!, HttpMethod.POST, {
data,
axiosOptions: axiosOptionsWithAbortController,
swrOptions,
}) as Result),
abortRequest,
};
case HttpMethod.SWR_POST:
return {
...(this.useSwr(url, HttpMethod.POST, {
data,
axiosOptions: axiosOptionsWithAbortController,
swrOptions,
}) as Result),
controller: abortController,
};
default:
return {
response: this.restClient.fetcher(url, {
data,
httpMethod,
config: axiosOptionsWithAbortController,
checkResponseIsJson: false,
}),
abortRequest,
} as Result;
}
}
public useGetGlobalMeta(swrOptions?: SWROptions<Metadata>): AbortableSWRResponse<Metadata> {
return this.doRequest(HttpMethod.SWR_GET, 'meta', { swrOptions });
}
public setGlobalMetadata(key: string, value: any): AbortableAxiosResponse {
return this.doRequest(HttpMethod.PATCH, 'meta', { formData: { key, value } });
}
public useGetAbout(swrOptions?: SWROptions<IAbout>): AbortableSWRResponse<IAbout> {
return this.doRequest(HttpMethod.SWR_GET, 'settings/about', { swrOptions });
}
public useCheckForUpdate(swrOptions?: SWROptions<UpdateCheck[]>): AbortableSWRResponse<UpdateCheck[]> {
return this.doRequest(HttpMethod.SWR_GET, 'settings/check-update', { swrOptions });
}
public useGetExtensionList(swrOptions?: SWROptions<IExtension[]>): AbortableSWRResponse<IExtension[]> {
return this.doRequest(HttpMethod.SWR_GET, 'extension/list', { swrOptions });
}
public installExtension(extension: string | File): AbortableAxiosResponse {
if (typeof extension === 'string') {
return this.doRequest(HttpMethod.GET, `extension/install/${extension}`);
}
return this.doRequest(HttpMethod.POST, `extension/install`, { formData: { file: extension } });
}
public updateExtension(extension: string): AbortableAxiosResponse {
return this.doRequest(HttpMethod.GET, `extension/update/${extension}`);
}
public uninstallExtension(extension: string): AbortableAxiosResponse {
return this.doRequest(HttpMethod.GET, `extension/uninstall/${extension}`);
}
public getExtensionIconUrl(extension: string): string {
return this.getValidImgUrlFor(`extension/icon/${extension}`);
}
public useGetSourceList(swrOptions?: SWROptions<ISource[]>): AbortableSWRResponse<ISource[]> {
return this.doRequest(HttpMethod.SWR_GET, 'source/list', { swrOptions });
}
public useGetSource(sourceId: string, swrOptions?: SWROptions<ISource>): AbortableSWRResponse<ISource> {
return this.doRequest(HttpMethod.SWR_GET, `source/${sourceId}`, { swrOptions });
}
public useGetSourcePopularMangas(
sourceId: string,
initialPages?: number,
swrOptions?: SWRInfiniteOptions<PaginatedMangaList>,
): AbortableSWRInfiniteResponse<PaginatedMangaList> {
return this.doRequest(SWRHttpMethod.SWR_GET_INFINITE, '', {
swrOptions: {
getEndpoint: (page, previousData) =>
previousData?.hasNextPage ?? true ? `source/${sourceId}/popular/${page + 1}` : null,
initialSize: initialPages,
...swrOptions,
} as typeof swrOptions,
});
}
public useGetSourceLatestMangas(
sourceId: string,
initialPages?: number,
swrOptions?: SWRInfiniteOptions<PaginatedMangaList>,
): AbortableSWRInfiniteResponse<PaginatedMangaList> {
return this.doRequest(SWRHttpMethod.SWR_GET_INFINITE, '', {
swrOptions: {
getEndpoint: (page, previousData) =>
previousData?.hasNextPage ?? true ? `source/${sourceId}/latest/${page + 1}` : null,
initialSize: initialPages,
...swrOptions,
} as typeof swrOptions,
});
}
public useGetSourcePreferences(
sourceId: string,
swrOptions?: SWROptions<SourcePreferences[]>,
): AbortableSWRResponse<SourcePreferences[]> {
return this.doRequest(HttpMethod.SWR_GET, `source/${sourceId}/preferences`, { swrOptions });
}
public setSourcePreferences(sourceId: string, position: number, value: string): AbortableAxiosResponse {
return this.doRequest(HttpMethod.POST, `source/${sourceId}/preferences`, { data: { position, value } });
}
public useGetSourceFilters(
sourceId: string,
reset?: boolean,
swrOptions?: SWROptions<ISourceFilters[]>,
): AbortableSWRResponse<ISourceFilters[]> {
return this.doRequest(HttpMethod.SWR_GET, `source/${sourceId}/filters`, { swrOptions });
}
public setSourceFilters(sourceId: string, filters: { position: number; state: string }[]): AbortableAxiosResponse {
return this.doRequest(HttpMethod.POST, `source/${sourceId}/filters`, { data: filters });
}
public resetSourceFilters(sourceId: string): AbortableAxiosResponse {
return this.doRequest(HttpMethod.GET, `source/${sourceId}/filters?reset=true`);
}
public useSourceSearch(
sourceId: string,
searchTerm: string,
initialPages?: number,
swrOptions?: SWRInfiniteOptions<SourceSearchResult>,
): AbortableSWRInfiniteResponse<SourceSearchResult> {
return this.doRequest(HttpMethod.SWR_GET_INFINITE, '', {
swrOptions: {
getEndpoint: (page, previousData) =>
previousData?.hasNextPage ?? true
? `source/${sourceId}/search?searchTerm=${searchTerm}&pageNum=${page + 1}`
: null,
initialSize: initialPages,
...swrOptions,
} as typeof swrOptions,
});
}
public useSourceQuickSearch(
sourceId: string,
searchTerm: string,
filters: { position: number; state: string }[],
initialPages?: number,
swrOptions?: SWRInfiniteOptions<SourceSearchResult>,
): AbortableSWRInfiniteResponse<SourceSearchResult> {
return this.doRequest(HttpMethod.SWR_POST_INFINITE, '', {
data: { searchTerm, filter: filters },
swrOptions: {
getEndpoint: (page, previousData) =>
previousData?.hasNextPage ?? true
? `source/${sourceId}/quick-search?searchTerm=${searchTerm}&pageNum=${page + 1}`
: null,
initialSize: initialPages,
...swrOptions,
} as typeof swrOptions,
});
}
public useGetManga(
mangaId: number | string,
{ doOnlineFetch, ...swrOptions }: SWROptions<IManga> & RequestOption = {},
): AbortableSWRResponse<IManga> {
const onlineFetch = doOnlineFetch ? '?onlineFetch=true' : '';
return this.doRequest(HttpMethod.SWR_GET, `manga/${mangaId}${onlineFetch}`, {
swrOptions,
});
}
public getManga(mangaId: number | string, doOnlineFetch?: boolean): AbortableAxiosResponse<IManga> {
const onlineFetch = doOnlineFetch ? '?onlineFetch=true' : '';
return this.doRequest(HttpMethod.GET, `manga/${mangaId}${onlineFetch}`);
}
public useGetFullManga(
mangaId: number | string,
{ doOnlineFetch, ...swrOptions }: SWROptions<IManga> & RequestOption = {},
): AbortableSWRResponse<IManga> {
const onlineFetch = doOnlineFetch ? '?onlineFetch=true' : '';
return this.doRequest(HttpMethod.SWR_GET, `manga/${mangaId}/full${onlineFetch}`, {
swrOptions,
});
}
public getMangaThumbnailUrl(mangaId: number): string {
return this.getValidImgUrlFor(`manga/${mangaId}/thumbnail`);
}
public useGetMangaCategories(
mangaId: number,
swrOptions?: SWROptions<ICategory[]>,
): AbortableSWRResponse<ICategory[]> {
return this.doRequest(HttpMethod.SWR_GET, `manga/${mangaId}/category`, { swrOptions });
}
public addMangaToCategory(mangaId: number, categoryId: number): AbortableAxiosResponse {
return this.doRequest(HttpMethod.GET, `manga/${mangaId}/category/${categoryId}`);
}
public removeMangaFromCategory(mangaId: number, categoryId: number): AbortableAxiosResponse {
return this.doRequest(HttpMethod.DELETE, `manga/${mangaId}/category/${categoryId}`);
}
public addMangaToLibrary(mangaId: number | string): AbortableAxiosResponse {
return this.doRequest(HttpMethod.GET, `manga/${mangaId}/library`);
}
public removeMangaFromLibrary(mangaId: number | string): AbortableAxiosResponse {
return this.doRequest(HttpMethod.DELETE, `manga/${mangaId}/library`);
}
public setMangaMeta(mangaId: number, key: string, value: any): AbortableAxiosResponse {
return this.doRequest(HttpMethod.PATCH, `manga/${mangaId}/meta`, { formData: { key, value } });
}
public useGetMangaChapters(
mangaId: number | string,
{ doOnlineFetch, ...swrOptions }: SWROptions<IChapter[]> & RequestOption = {},
): AbortableSWRResponse<IChapter[]> {
const onlineFetch = doOnlineFetch ? '?onlineFetch=true' : '';
return this.doRequest(HttpMethod.SWR_GET, `manga/${mangaId}/chapters${onlineFetch}`, {
swrOptions,
});
}
public getMangaChapters(mangaId: number | string, doOnlineFetch?: boolean): AbortableAxiosResponse<IChapter[]> {
const onlineFetch = doOnlineFetch ? '?onlineFetch=true' : '';
return this.doRequest(HttpMethod.GET, `manga/${mangaId}/chapters${onlineFetch}`);
}
public updateMangaChapters(
mangaId: number | string,
{
chapterIds,
chapterIndexes,
change,
}: (
| { chapterIds?: number[]; chapterIndexes: number[] }
| { chapterIds: number[]; chapterIndexes?: number[] }
) & { change: BatchChaptersChange },
): AbortableAxiosResponse {
return this.doRequest(HttpMethod.POST, `manga/${mangaId}/chapter/batch`, {
data: {
chapterIds,
chapterIndexes,
change,
},
});
}
public useGetChapter(
mangaId: number | string,
chapterIndex: number | string,
swrOptions?: SWROptions<IChapter>,
): AbortableSWRResponse<IChapter> {
return this.doRequest(HttpMethod.SWR_GET, `manga/${mangaId}/chapter/${chapterIndex}`, {
swrOptions,
});
}
public getChapter(mangaId: number | string, chapterIndex: number | string): AbortableAxiosResponse<IChapter> {
return this.doRequest(HttpMethod.GET, `manga/${mangaId}/chapter/${chapterIndex}`);
}
public deleteDownloadedChapter(mangaId: number | string, chapterIndex: number | string): AbortableAxiosResponse {
return this.doRequest(HttpMethod.DELETE, `manga/${mangaId}/chapter/${chapterIndex}`);
}
public updateChapter(
mangaId: number | string,
chapterIndex: number | string,
change: { read?: boolean; bookmarked?: boolean; markPrevRead?: boolean; lastPageRead?: number } = {},
): AbortableAxiosResponse {
return this.doRequest(HttpMethod.PATCH, `manga/${mangaId}/chapter/${chapterIndex}`, { formData: change });
}
public setChapterMeta(
mangaId: number | string,
chapterIndex: number | string,
key: string,
value: any,
): AbortableAxiosResponse {
return this.doRequest(HttpMethod.PATCH, `manga/${mangaId}/chapter/${chapterIndex}/meta`, {
formData: { key, value },
});
}
public getChapterPageUrl(mangaId: number | string, chapterIndex: number | string, page: number): string {
return this.getValidImgUrlFor(
`manga/${mangaId}/chapter/${chapterIndex}/page/${page}`,
RequestManager.API_VERSION,
);
}
public updateChapters(chapterIds: number[], change: BatchChaptersChange): AbortableAxiosResponse {
return this.doRequest(HttpMethod.POST, `chapter/batch`, { data: { chapterIds, change } });
}
public useGetCategories(swrOptions?: SWROptions<ICategory[]>): AbortableSWRResponse<ICategory[]> {
return this.doRequest(HttpMethod.SWR_GET, `category`, { swrOptions });
}
public createCategory(name: string): AbortableAxiosResponse {
return this.doRequest(HttpMethod.POST, `category`, { formData: { name } });
}
public reorderCategory(currentPosition: number, newPosition: number): AbortableAxiosResponse {
return this.doRequest(HttpMethod.PATCH, `category/reorder`, {
formData: { from: currentPosition, to: newPosition },
});
}
public useGetCategoryMangas(categoryId: number, swrOptions?: SWROptions<IManga[]>): AbortableSWRResponse<IManga[]> {
return this.doRequest(HttpMethod.SWR_GET, `category/${categoryId}`, { swrOptions });
}
public deleteCategory(categoryId: number): AbortableAxiosResponse {
return this.doRequest(HttpMethod.DELETE, `category/${categoryId}`);
}
public updateCategory(
categoryId: number,
change: { name?: string; default?: boolean; includeInUpdate?: IncludeInGlobalUpdate } = {},
): AbortableAxiosResponse {
return this.doRequest(HttpMethod.PATCH, `category/${categoryId}`, { formData: change });
}
public setCategoryMeta(categoryId: number, key: string, value: any): AbortableAxiosResponse {
return this.doRequest(HttpMethod.PATCH, `category/${categoryId}/meta`, { formData: { key, value } });
}
public restoreBackupFile(file: File): AbortableAxiosResponse {
return this.doRequest(HttpMethod.POST, 'backup/import/file', { formData: { 'backup.proto.gz': file } });
}
public useValidateBackupFile(
file: File,
swrOptions?: SWROptions<BackupValidationResult>,
): AbortableSWRResponse<BackupValidationResult> {
return this.doRequest(HttpMethod.SWR_POST, 'backup/validate/file', {
formData: { 'backup.proto.gz': file },
swrOptions,
});
}
public getExportBackupUrl(): string {
return this.getValidUrlFor('backup/export/file');
}
public startDownloads(): AbortableAxiosResponse {
return this.doRequest(HttpMethod.GET, 'downloads/start');
}
public stopDownloads(): AbortableAxiosResponse {
return this.doRequest(HttpMethod.GET, 'downloads/stop');
}
public clearDownloads(): AbortableAxiosResponse {
return this.doRequest(HttpMethod.GET, 'downloads/clear');
}
public addChapterToDownloadQueue(mangaId: number | string, chapterIndex: number | string): AbortableAxiosResponse {
return this.doRequest(HttpMethod.GET, `download/${mangaId}/chapter/${chapterIndex}`);
}
public removeChapterFromDownloadQueue(
mangaId: number | string,
chapterIndex: number | string,
): AbortableAxiosResponse {
return this.doRequest(HttpMethod.DELETE, `download/${mangaId}/chapter/${chapterIndex}`);
}
public reorderChapterInDownloadQueue(
mangaId: number | string,
chapterIndex: number | string,
position: number,
): AbortableAxiosResponse {
return this.doRequest(HttpMethod.PATCH, `download/${mangaId}/chapter/${chapterIndex}/reorder/${position}`);
}
public addChaptersToDownloadQueue(chapterIds: number[]): AbortableAxiosResponse {
return this.doRequest(HttpMethod.POST, 'download/batch', { data: { chapterIds } });
}
public removeChaptersFromDownloadQueue(chapterIds: number[]): AbortableAxiosResponse {
return this.doRequest(HttpMethod.DELETE, 'download/batch', { data: { chapterIds } });
}
public useGetRecentlyUpdatedChapters(
initialPages?: number,
swrOptions?: SWRInfiniteOptions<PaginatedList<IMangaChapter>>,
): AbortableSWRInfiniteResponse<PaginatedList<IMangaChapter>> {
return this.doRequest(HttpMethod.SWR_GET_INFINITE, '', {
swrOptions: {
getEndpoint: (page, previousData) =>
previousData?.hasNextPage ?? true ? `update/recentChapters/${page}` : null,
initialSize: initialPages,
...swrOptions,
} as typeof swrOptions,
});
}
public startGlobalUpdate(categoryId?: number): AbortableAxiosResponse {
return this.doRequest(HttpMethod.POST, 'update/fetch', { formData: { categoryId } });
}
public resetGlobalUpdate(): AbortableAxiosResponse {
return this.doRequest(HttpMethod.POST, 'update/reset');
}
public useGetGlobalUpdateSummary(swrOptions?: SWROptions<IUpdateStatus>): AbortableSWRResponse<IUpdateStatus> {
return this.doRequest(HttpMethod.SWR_GET, 'update/summary', { swrOptions });
}
}
const requestManager = new RequestManager();
export default requestManager;

View File

@@ -0,0 +1,439 @@
/*
* 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 gql from 'graphql-tag';
export const PAGE_INFO = gql`
fragment PAGE_INFO on PageInfo {
endCursor
hasNextPage
hasPreviousPage
startCursor
}
`;
export const GLOBAL_METADATA = gql`
fragment GLOBAL_METADATA on GlobalMetaType {
key
value
}
`;
export const FULL_CATEGORY_FIELDS = gql`
fragment FULL_CATEGORY_FIELDS on CategoryType {
default
id
includeInUpdate
name
order
meta {
key
value
}
mangas {
totalCount
}
}
`;
export const PARTIAL_SOURCE_FIELDS = gql`
fragment PARTIAL_SOURCE_FIELDS on SourceType {
displayName
iconUrl
id
isConfigurable
isNsfw
lang
name
supportsLatest
}
`;
export const FULL_SOURCE_FIELDS = gql`
${PARTIAL_SOURCE_FIELDS}
fragment FULL_SOURCE_FIELDS on SourceType {
...PARTIAL_SOURCE_FIELDS
preferences {
... on CheckBoxPreference {
type: __typename
CheckBoxCheckBoxCurrentValue: currentValue
summary
CheckBoxDefault: default
key
CheckBoxTitle: title
}
... on EditTextPreference {
type: __typename
EditTextPreferenceCurrentValue: currentValue
EditTextPreferenceDefault: default
EditTextPreferenceTitle: title
text
summary
key
dialogTitle
dialogMessage
}
... on SwitchPreference {
type: __typename
SwitchPreferenceCurrentValue: currentValue
summary
key
SwitchPreferenceDefault: default
SwitchPreferenceTitle: title
}
... on MultiSelectListPreference {
type: __typename
dialogMessage
dialogTitle
MultiSelectListPreferenceTitle: title
summary
key
entryValues
entries
MultiSelectListPreferenceDefault: default
MultiSelectListPreferenceCurrentValue: currentValue
}
... on ListPreference {
type: __typename
ListPreferenceCurrentValue: currentValue
ListPreferenceDefault: default
ListPreferenceTitle: title
summary
key
entryValues
entries
}
}
filters {
... on TriStateFilter {
type: __typename
name
TriStateFilterDefault: default
}
... on CheckBoxFilter {
type: __typename
CheckBoxFilterDefault: default
name
}
... on TextFilter {
type: __typename
name
TextFilterDefault: default
}
... on SortFilter {
type: __typename
values
name
SortFilterDefault: default {
ascending
index
}
}
... on SeparatorFilter {
type: __typename
name
}
... on SelectFilter {
type: __typename
values
name
SelectFilterDefault: default
}
... on HeaderFilter {
type: __typename
name
}
... on GroupFilter {
type: __typename
name
filters {
... on CheckBoxFilter {
type: __typename
CheckBoxFilterDefault: default
name
}
... on HeaderFilter {
type: __typename
name
}
... on SelectFilter {
type: __typename
SelectFilterDefault: default
name
values
}
... on TriStateFilter {
type: __typename
TriStateFilterDefault: default
name
}
... on TextFilter {
type: __typename
TextFilterDefault: default
name
}
... on SortFilter {
type: __typename
SorSortFilterDefault: default {
ascending
index
}
name
values
}
... on SeparatorFilter {
type: __typename
name
}
}
}
}
}
`;
export const BASE_MANGA_FIELDS = gql`
${PARTIAL_SOURCE_FIELDS}
fragment BASE_MANGA_FIELDS on MangaType {
artist
author
chaptersLastFetchedAt
description
genre
id
inLibrary
inLibraryAt
initialized
lastFetchedAt
meta {
key
value
}
realUrl
source {
...PARTIAL_SOURCE_FIELDS
}
status
thumbnailUrl
title
url
}
`;
export const PARTIAL_MANGA_FIELDS = gql`
${BASE_MANGA_FIELDS}
${FULL_CATEGORY_FIELDS}
${PARTIAL_SOURCE_FIELDS}
fragment PARTIAL_MANGA_FIELDS on MangaType {
...BASE_MANGA_FIELDS
unreadCount
downloadCount
categories {
nodes {
...FULL_CATEGORY_FIELDS
}
totalCount
}
chapters {
totalCount
}
}
`;
export const FULL_CHAPTER_FIELDS = gql`
${PARTIAL_MANGA_FIELDS}
fragment FULL_CHAPTER_FIELDS on ChapterType {
chapterNumber
fetchedAt
id
isBookmarked
isDownloaded
isRead
lastPageRead
lastReadAt
manga {
...PARTIAL_MANGA_FIELDS
}
meta {
key
value
}
name
pageCount
realUrl
scanlator
sourceOrder
uploadDate
url
}
`;
export const FULL_MANGA_FIELDS = gql`
${PARTIAL_MANGA_FIELDS}
${FULL_CHAPTER_FIELDS}
fragment FULL_MANGA_FIELDS on MangaType {
...PARTIAL_MANGA_FIELDS
lastReadChapter {
...FULL_CHAPTER_FIELDS
}
}
`;
export const FULL_EXTENSION_FIELDS = gql`
fragment FULL_EXTENSION_FIELDS on ExtensionType {
apkName
hasUpdate
iconUrl
isInstalled
isNsfw
isObsolete
lang
name
pkgName
versionCode
versionName
}
`;
export const FULL_DOWNLOAD_STATUS = gql`
${FULL_CHAPTER_FIELDS}
fragment FULL_DOWNLOAD_STATUS on DownloadStatus {
queue {
chapter {
...FULL_CHAPTER_FIELDS
}
progress
state
tries
}
state
}
`;
export const PARTIAL_UPDATER_STATUS = gql`
fragment PARTIAL_UPDATER_STATUS on UpdateStatus {
isRunning
}
`;
export const FULL_UPDATER_STATUS = gql`
${FULL_MANGA_FIELDS}
${PARTIAL_UPDATER_STATUS}
${FULL_CATEGORY_FIELDS}
fragment FULL_UPDATER_STATUS on UpdateStatus {
...PARTIAL_UPDATER_STATUS
completeJobs {
mangas {
nodes {
...FULL_MANGA_FIELDS
}
totalCount
}
}
failedJobs {
mangas {
nodes {
...FULL_MANGA_FIELDS
}
totalCount
}
}
pendingJobs {
mangas {
nodes {
...FULL_MANGA_FIELDS
}
totalCount
}
}
runningJobs {
mangas {
nodes {
...FULL_MANGA_FIELDS
}
totalCount
}
}
skippedJobs {
mangas {
nodes {
...FULL_MANGA_FIELDS
}
totalCount
}
}
updatingCategories {
categories {
nodes {
...FULL_CATEGORY_FIELDS
}
totalCount
}
}
skippedCategories {
categories {
nodes {
...FULL_CATEGORY_FIELDS
}
totalCount
}
}
}
`;
export const WEBUI_UPDATE_INFO = gql`
fragment WEBUI_UPDATE_INFO on WebUIUpdateInfo {
channel
tag
updateAvailable
}
`;
export const WEBUI_UPDATE_STATUS = gql`
${WEBUI_UPDATE_INFO}
fragment WEBUI_UPDATE_STATUS on WebUIUpdateStatus {
info {
...WEBUI_UPDATE_INFO
}
progress
state
}
`;
export const SERVER_SETTINGS = gql`
fragment SERVER_SETTINGS on SettingsType {
autoDownloadNewChapters
backupInterval
backupPath
backupTTL
backupTime
basicAuthEnabled
basicAuthPassword
basicAuthUsername
debugLogsEnabled
downloadAsCbz
downloadsPath
electronPath
excludeCompleted
excludeNotStarted
excludeUnreadChapters
globalUpdateInterval
initialOpenInBrowserEnabled
ip
localSourcePath
maxSourcesInParallel
port
socksProxyEnabled
socksProxyHost
socksProxyPort
systemTrayEnabled
webUIChannel
webUIFlavor
webUIInterface
webUIUpdateCheckInterval
}
`;

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,7 @@
schema: schema.json # download the schema from the server and place it in "src/lib/graphql/"
documents: [
'src/lib/graphql/queries/**',
'src/lib/graphql/mutations/**',
'src/lib/graphql/subscriptions/**',
'src/lib/graphql/Fragments.ts',
]

View File

@@ -0,0 +1,31 @@
/*
* 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 gql from 'graphql-tag';
export const CREATE_BACKUP = gql`
mutation CREATE_BACKUP($input: CreateBackupInput!) {
createBackup(input: $input) {
clientMutationId
url
}
}
`;
export const RESTORE_BACKUP = gql`
mutation RESTORE_BACKUP($input: RestoreBackupInput!) {
restoreBackup(input: $input) {
clientMutationId
status {
mangaProgress
state
totalManga
}
}
}
`;

View File

@@ -0,0 +1,105 @@
/*
* 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 gql from 'graphql-tag';
import { FULL_CATEGORY_FIELDS } from '@/lib/graphql/Fragments';
export const CREATE_CATEGORY = gql`
${FULL_CATEGORY_FIELDS}
mutation CREATE_CATEGORY($input: CreateCategoryInput!) {
createCategory(input: $input) {
clientMutationId
category {
...FULL_CATEGORY_FIELDS
}
}
}
`;
export const DELETE_CATEGORY = gql`
${FULL_CATEGORY_FIELDS}
mutation DELETE_CATEGORY($input: DeleteCategoryInput!) {
deleteCategory(input: $input) {
clientMutationId
category {
...FULL_CATEGORY_FIELDS
}
}
}
`;
export const DELETE_CATEGORY_METADATA = gql`
${FULL_CATEGORY_FIELDS}
mutation DELETE_CATEGORY_METADATA($input: DeleteCategoryMetaInput!) {
deleteCategoryMeta(input: $input) {
clientMutationId
meta {
key
value
category {
...FULL_CATEGORY_FIELDS
}
}
category {
...FULL_CATEGORY_FIELDS
}
}
}
`;
export const SET_CATEGORY_METADATA = gql`
${FULL_CATEGORY_FIELDS}
mutation SET_CATEGORY_METADATA($input: SetCategoryMetaInput!) {
setCategoryMeta(input: $input) {
clientMutationId
meta {
key
value
category {
...FULL_CATEGORY_FIELDS
}
}
}
}
`;
export const UPDATE_CATEGORY = gql`
${FULL_CATEGORY_FIELDS}
mutation UPDATE_CATEGORY($input: UpdateCategoryInput!) {
updateCategory(input: $input) {
clientMutationId
category {
...FULL_CATEGORY_FIELDS
}
}
}
`;
export const UPDATE_CATEGORIES = gql`
${FULL_CATEGORY_FIELDS}
mutation UPDATE_CATEGORIES($input: UpdateCategoriesInput!) {
updateCategories(input: $input) {
clientMutationId
categories {
...FULL_CATEGORY_FIELDS
}
}
}
`;
export const UPDATE_CATEGORY_ORDER = gql`
${FULL_CATEGORY_FIELDS}
mutation UPDATE_CATEGORY_ORDER($input: UpdateCategoryOrderInput!) {
updateCategoryOrder(input: $input) {
clientMutationId
categories {
...FULL_CATEGORY_FIELDS
}
}
}
`;

View File

@@ -0,0 +1,96 @@
/*
* 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 gql from 'graphql-tag';
import { FULL_CHAPTER_FIELDS } from '@/lib/graphql/Fragments';
export const DELETE_CHAPTER_METADATA = gql`
${FULL_CHAPTER_FIELDS}
mutation DELETE_CHAPTER_METADATA($input: DeleteChapterMetaInput!) {
deleteChapterMeta(input: $input) {
clientMutationId
meta {
key
value
chapter {
...FULL_CHAPTER_FIELDS
}
}
chapter {
...FULL_CHAPTER_FIELDS
}
}
}
`;
// makes the server fetch and return the pages of a chapter
export const GET_CHAPTER_PAGES_FETCH = gql`
${FULL_CHAPTER_FIELDS}
mutation GET_CHAPTER_PAGES_FETCH($input: FetchChapterPagesInput!) {
fetchChapterPages(input: $input) {
clientMutationId
chapter {
...FULL_CHAPTER_FIELDS
}
pages
}
}
`;
// makes the server fetch and return the chapters of the manga
export const GET_MANGA_CHAPTERS_FETCH = gql`
${FULL_CHAPTER_FIELDS}
mutation GET_MANGA_CHAPTERS_FETCH($input: FetchChaptersInput!) {
fetchChapters(input: $input) {
clientMutationId
chapters {
...FULL_CHAPTER_FIELDS
}
}
}
`;
export const SET_CHAPTER_METADATA = gql`
${FULL_CHAPTER_FIELDS}
mutation SET_CHAPTER_METADATA($input: SetChapterMetaInput!) {
setChapterMeta(input: $input) {
clientMutationId
meta {
key
value
chapter {
...FULL_CHAPTER_FIELDS
}
}
}
}
`;
export const UPDATE_CHAPTER = gql`
${FULL_CHAPTER_FIELDS}
mutation UPDATE_CHAPTER($input: UpdateChapterInput!) {
updateChapter(input: $input) {
clientMutationId
chapter {
...FULL_CHAPTER_FIELDS
}
}
}
`;
export const UPDATE_CHAPTERS = gql`
${FULL_CHAPTER_FIELDS}
mutation UPDATE_CHAPTERS($input: UpdateChaptersInput!) {
updateChapters(input: $input) {
clientMutationId
chapters {
...FULL_CHAPTER_FIELDS
}
}
}
`;

View File

@@ -0,0 +1,130 @@
/*
* 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 gql from 'graphql-tag';
import { FULL_CHAPTER_FIELDS, FULL_DOWNLOAD_STATUS } from '@/lib/graphql/Fragments';
export const CLEAR_DOWNLOADER = gql`
${FULL_DOWNLOAD_STATUS}
mutation CLEAR_DOWNLOADER($input: ClearDownloaderInput = {}) {
clearDownloader(input: $input) {
clientMutationId
downloadStatus {
...FULL_DOWNLOAD_STATUS
}
}
}
`;
export const DELETE_DOWNLOADED_CHAPTER = gql`
${FULL_CHAPTER_FIELDS}
mutation DELETE_DOWNLOADED_CHAPTER($input: DeleteDownloadedChapterInput!) {
deleteDownloadedChapter(input: $input) {
clientMutationId
chapters {
...FULL_CHAPTER_FIELDS
}
}
}
`;
export const DELETE_DOWNLOADED_CHAPTERS = gql`
${FULL_CHAPTER_FIELDS}
mutation DELETE_DOWNLOADED_CHAPTERS($input: DeleteDownloadedChaptersInput!) {
deleteDownloadedChapters(input: $input) {
clientMutationId
chapters {
...FULL_CHAPTER_FIELDS
}
}
}
`;
export const DEQUEUE_CHAPTER_DOWNLOAD = gql`
${FULL_DOWNLOAD_STATUS}
mutation DEQUEUE_CHAPTER_DOWNLOAD($input: DequeueChapterDownloadInput!) {
dequeueChapterDownload(input: $input) {
clientMutationId
downloadStatus {
...FULL_DOWNLOAD_STATUS
}
}
}
`;
export const DEQUEUE_CHAPTER_DOWNLOADS = gql`
${FULL_DOWNLOAD_STATUS}
mutation DEQUEUE_CHAPTER_DOWNLOADS($input: DequeueChapterDownloadsInput!) {
dequeueChapterDownloads(input: $input) {
clientMutationId
downloadStatus {
...FULL_DOWNLOAD_STATUS
}
}
}
`;
export const ENQUEUE_CHAPTER_DOWNLOAD = gql`
${FULL_DOWNLOAD_STATUS}
mutation ENQUEUE_CHAPTER_DOWNLOAD($input: EnqueueChapterDownloadInput!) {
enqueueChapterDownload(input: $input) {
clientMutationId
downloadStatus {
...FULL_DOWNLOAD_STATUS
}
}
}
`;
export const ENQUEUE_CHAPTER_DOWNLOADS = gql`
${FULL_DOWNLOAD_STATUS}
mutation ENQUEUE_CHAPTER_DOWNLOADS($input: EnqueueChapterDownloadsInput!) {
enqueueChapterDownloads(input: $input) {
clientMutationId
downloadStatus {
...FULL_DOWNLOAD_STATUS
}
}
}
`;
export const REORDER_CHAPTER_DOWNLOAD = gql`
${FULL_DOWNLOAD_STATUS}
mutation REORDER_CHAPTER_DOWNLOAD($input: ReorderChapterDownloadInput!) {
reorderChapterDownload(input: $input) {
clientMutationId
downloadStatus {
...FULL_DOWNLOAD_STATUS
}
}
}
`;
export const START_DOWNLOADER = gql`
${FULL_DOWNLOAD_STATUS}
mutation START_DOWNLOADER($input: StartDownloaderInput = {}) {
startDownloader(input: $input) {
clientMutationId
downloadStatus {
...FULL_DOWNLOAD_STATUS
}
}
}
`;
export const STOP_DOWNLOADER = gql`
${FULL_DOWNLOAD_STATUS}
mutation STOP_DOWNLOADER($input: StopDownloaderInput = {}) {
stopDownloader(input: $input) {
clientMutationId
downloadStatus {
...FULL_DOWNLOAD_STATUS
}
}
}
`;

View File

@@ -0,0 +1,59 @@
/*
* 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 gql from 'graphql-tag';
import { FULL_EXTENSION_FIELDS } from '@/lib/graphql/Fragments';
// makes the server fetch and return the latest extensions
export const GET_EXTENSIONS_FETCH = gql`
${FULL_EXTENSION_FIELDS}
mutation GET_EXTENSIONS_FETCH($input: FetchExtensionsInput = {}) {
fetchExtensions(input: $input) {
clientMutationId
extensions {
...FULL_EXTENSION_FIELDS
}
}
}
`;
export const UPDATE_EXTENSION = gql`
${FULL_EXTENSION_FIELDS}
mutation UPDATE_EXTENSION($input: UpdateExtensionInput!) {
updateExtension(input: $input) {
clientMutationId
extension {
...FULL_EXTENSION_FIELDS
}
}
}
`;
export const UPDATE_EXTENSIONS = gql`
${FULL_EXTENSION_FIELDS}
mutation UPDATE_EXTENSIONS($input: UpdateExtensionsInput!) {
updateExtensions(input: $input) {
clientMutationId
extensions {
...FULL_EXTENSION_FIELDS
}
}
}
`;
export const INSTALL_EXTERNAL_EXTENSION = gql`
${FULL_EXTENSION_FIELDS}
mutation INSTALL_EXTERNAL_EXTENSION($file: Upload!) {
installExternalExtension(input: { extensionFile: $file }) {
extension {
...FULL_EXTENSION_FIELDS
}
clientMutationId
}
}
`;

View File

@@ -0,0 +1,34 @@
/*
* 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 gql from 'graphql-tag';
import { GLOBAL_METADATA } from '@/lib/graphql/Fragments';
export const DELETE_GLOBAL_METADATA = gql`
${GLOBAL_METADATA}
mutation DELETE_GLOBAL_METADATA($input: DeleteGlobalMetaInput!) {
deleteGlobalMeta(input: $input) {
clientMutationId
meta {
...GLOBAL_METADATA
}
}
}
`;
export const SET_GLOBAL_METADATA = gql`
${GLOBAL_METADATA}
mutation SET_GLOBAL_METADATA($input: SetGlobalMetaInput!) {
setGlobalMeta(input: $input) {
clientMutationId
meta {
...GLOBAL_METADATA
}
}
}
`;

View File

@@ -0,0 +1,106 @@
/*
* 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 gql from 'graphql-tag';
import { FULL_MANGA_FIELDS } from '@/lib/graphql/Fragments';
export const DELETE_MANGA_METADATA = gql`
${FULL_MANGA_FIELDS}
mutation DELETE_MANGA_METADATA($input: DeleteMangaMetaInput!) {
deleteMangaMeta(input: $input) {
clientMutationId
meta {
key
value
manga {
...FULL_MANGA_FIELDS
}
}
manga {
...FULL_MANGA_FIELDS
}
}
}
`;
// makes the server fetch and return the manga
export const GET_MANGA_FETCH = gql`
${FULL_MANGA_FIELDS}
mutation GET_MANGA_FETCH($input: FetchMangaInput!) {
fetchManga(input: $input) {
clientMutationId
manga {
...FULL_MANGA_FIELDS
}
}
}
`;
export const SET_MANGA_METADATA = gql`
${FULL_MANGA_FIELDS}
mutation SET_MANGA_METADATA($input: SetMangaMetaInput!) {
setMangaMeta(input: $input) {
clientMutationId
meta {
key
value
manga {
...FULL_MANGA_FIELDS
}
}
}
}
`;
export const UPDATE_MANGA = gql`
${FULL_MANGA_FIELDS}
mutation UPDATE_MANGA($input: UpdateMangaInput!) {
updateManga(input: $input) {
clientMutationId
manga {
...FULL_MANGA_FIELDS
}
}
}
`;
export const UPDATE_MANGA_CATEGORIES = gql`
${FULL_MANGA_FIELDS}
mutation UPDATE_MANGA_CATEGORIES($input: UpdateMangaCategoriesInput!) {
updateMangaCategories(input: $input) {
clientMutationId
manga {
...FULL_MANGA_FIELDS
}
}
}
`;
export const UPDATE_MANGAS = gql`
${FULL_MANGA_FIELDS}
mutation UPDATE_MANGAS($input: UpdateMangasInput!) {
updateMangas(input: $input) {
clientMutationId
mangas {
...FULL_MANGA_FIELDS
}
}
}
`;
export const UPDATE_MANGAS_CATEGORIES = gql`
${FULL_MANGA_FIELDS}
mutation UPDATE_MANGAS_CATEGORIES($input: UpdateMangasCategoriesInput!) {
updateMangasCategories(input: $input) {
clientMutationId
mangas {
...FULL_MANGA_FIELDS
}
}
}
`;

View File

@@ -0,0 +1,23 @@
/*
* 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 gql from 'graphql-tag';
import { WEBUI_UPDATE_STATUS } from '@/lib/graphql/Fragments';
// eslint-disable-next-line import/prefer-default-export
export const UPDATE_WEBUI = gql`
${WEBUI_UPDATE_STATUS}
mutation UPDATE_WEBUI($input: WebUIUpdateInput = {}) {
updateWebUI(input: $input) {
clientMutationId
updateStatus {
...WEBUI_UPDATE_STATUS
}
}
}
`;

View File

@@ -0,0 +1,34 @@
/*
* 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 gql from 'graphql-tag';
import { SERVER_SETTINGS } from '@/lib/graphql/Fragments';
export const RESET_SERVER_SETTINGS = gql`
${SERVER_SETTINGS}
mutation RESET_SERVER_SETTINGS($input: ResetSettingsInput!) {
resetSettings(input: $input) {
clientMutationId
settings {
...SERVER_SETTINGS
}
}
}
`;
export const UPDATE_SERVER_SETTINGS = gql`
${SERVER_SETTINGS}
mutation UPDATE_SERVER_SETTINGS($input: SetSettingsInput!) {
setSettings(input: $input) {
clientMutationId
settings {
...SERVER_SETTINGS
}
}
}
`;

View File

@@ -0,0 +1,35 @@
/*
* 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 gql from 'graphql-tag';
import { BASE_MANGA_FIELDS, FULL_SOURCE_FIELDS } from '@/lib/graphql/Fragments';
export const GET_SOURCE_MANGAS_FETCH = gql`
${BASE_MANGA_FIELDS}
mutation GET_SOURCE_MANGAS_FETCH($input: FetchSourceMangaInput!) {
fetchSourceManga(input: $input) {
clientMutationId
hasNextPage
mangas {
...BASE_MANGA_FIELDS
}
}
}
`;
export const UPDATE_SOURCE_PREFERENCES = gql`
${FULL_SOURCE_FIELDS}
mutation UPDATE_SOURCE_PREFERENCES($input: UpdateSourcePreferenceInput!) {
updateSourcePreference(input: $input) {
clientMutationId
source {
...FULL_SOURCE_FIELDS
}
}
}
`;

View File

@@ -0,0 +1,42 @@
/*
* 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 gql from 'graphql-tag';
import { PARTIAL_UPDATER_STATUS } from '@/lib/graphql/Fragments';
export const UPDATE_CATEGORY_MANGAS = gql`
${PARTIAL_UPDATER_STATUS}
mutation UPDATE_CATEGORY_MANGAS($input: UpdateCategoryMangaInput!) {
updateCategoryManga(input: $input) {
clientMutationId
updateStatus {
...PARTIAL_UPDATER_STATUS
}
}
}
`;
export const UPDATE_LIBRARY_MANGAS = gql`
${PARTIAL_UPDATER_STATUS}
mutation UPDATE_LIBRARY_MANGAS($input: UpdateLibraryMangaInput = {}) {
updateLibraryManga(input: $input) {
clientMutationId
updateStatus {
...PARTIAL_UPDATER_STATUS
}
}
}
`;
export const STOP_UPDATER = gql`
mutation STOP_UPDATER($input: UpdateStopInput = {}) {
updateStop(input: $input) {
clientMutationId
}
}
`;

View File

@@ -0,0 +1,30 @@
/*
* 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 gql from 'graphql-tag';
export const VALIDATE_BACKUP = gql`
query VALIDATE_BACKUP($input: ValidateBackupInput!) {
validateBackup(input: $input) {
missingSources {
id
name
}
}
}
`;
export const GET_RESTORE_STATUS = gql`
query GET_RESTORE_STATUS {
restoreStatus {
mangaProgress
state
totalManga
}
}
`;

View File

@@ -0,0 +1,73 @@
/*
* 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 gql from 'graphql-tag';
import { FULL_CATEGORY_FIELDS, FULL_MANGA_FIELDS, PAGE_INFO } from '@/lib/graphql/Fragments';
export const GET_CATEGORIES = gql`
${FULL_CATEGORY_FIELDS}
${PAGE_INFO}
query GET_CATEGORIES(
$after: Cursor
$before: Cursor
$condition: CategoryConditionInput
$filter: CategoryFilterInput
$first: Int
$last: Int
$offset: Int
$orderBy: CategoryOrderBy
$orderByType: SortOrder
) {
categories(
after: $after
before: $before
condition: $condition
filter: $filter
first: $first
last: $last
offset: $offset
orderBy: $orderBy
orderByType: $orderByType
) {
nodes {
...FULL_CATEGORY_FIELDS
}
pageInfo {
...PAGE_INFO
}
totalCount
}
}
`;
export const GET_CATEGORY = gql`
${FULL_CATEGORY_FIELDS}
query GET_CATEGORY($id: Int!) {
category(id: $id) {
...FULL_CATEGORY_FIELDS
}
}
`;
export const GET_CATEGORY_MANGAS = gql`
${FULL_MANGA_FIELDS}
${PAGE_INFO}
query GET_CATEGORY_MANGAS($id: Int!) {
category(id: $id) {
mangas {
nodes {
...FULL_MANGA_FIELDS
}
pageInfo {
...PAGE_INFO
}
totalCount
}
}
}
`;

View File

@@ -0,0 +1,57 @@
/*
* 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 gql from 'graphql-tag';
import { FULL_CHAPTER_FIELDS, PAGE_INFO } from '@/lib/graphql/Fragments';
// returns the current chapter from the database
export const GET_CHAPTER = gql`
${FULL_CHAPTER_FIELDS}
query GET_CHAPTER($id: Int!) {
chapter(id: $id) {
...FULL_CHAPTER_FIELDS
}
}
`;
// returns the current chapters from the database
export const GET_CHAPTERS = gql`
${FULL_CHAPTER_FIELDS}
${PAGE_INFO}
query GET_CHAPTERS(
$after: Cursor
$before: Cursor
$condition: ChapterConditionInput
$filter: ChapterFilterInput
$first: Int
$last: Int
$offset: Int
$orderBy: ChapterOrderBy
$orderByType: SortOrder
) {
chapters(
after: $after
before: $before
condition: $condition
filter: $filter
first: $first
last: $last
offset: $offset
orderBy: $orderBy
orderByType: $orderByType
) {
nodes {
...FULL_CHAPTER_FIELDS
}
pageInfo {
...PAGE_INFO
}
totalCount
}
}
`;

View File

@@ -0,0 +1,20 @@
/*
* 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 gql from 'graphql-tag';
import { FULL_DOWNLOAD_STATUS } from '@/lib/graphql/Fragments';
// eslint-disable-next-line import/prefer-default-export
export const GET_DOWNLOAD_STATUS = gql`
${FULL_DOWNLOAD_STATUS}
query GET_DOWNLOAD_STATUS {
downloadStatus {
...FULL_DOWNLOAD_STATUS
}
}
`;

View File

@@ -0,0 +1,57 @@
/*
* 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 gql from 'graphql-tag';
import { FULL_EXTENSION_FIELDS, PAGE_INFO } from '@/lib/graphql/Fragments';
// returns the current extension from the database
export const GET_EXTENSION = gql`
${FULL_EXTENSION_FIELDS}
query GET_EXTENSION($pkgName: String!) {
extension(pkgName: $pkgName) {
...FULL_EXTENSION_FIELDS
}
}
`;
// returns the current extensions from the database
export const GET_EXTENSIONS = gql`
${FULL_EXTENSION_FIELDS}
${PAGE_INFO}
query GET_EXTENSIONS(
$after: Cursor
$before: Cursor
$condition: ExtensionConditionInput
$filter: ExtensionFilterInput
$first: Int
$last: Int
$offset: Int
$orderBy: ExtensionOrderBy
$orderByType: SortOrder
) {
extensions(
after: $after
before: $before
condition: $condition
filter: $filter
first: $first
last: $last
offset: $offset
orderBy: $orderBy
orderByType: $orderByType
) {
nodes {
...FULL_EXTENSION_FIELDS
}
pageInfo {
...PAGE_INFO
}
totalCount
}
}
`;

View File

@@ -0,0 +1,55 @@
/*
* 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 gql from 'graphql-tag';
import { GLOBAL_METADATA, PAGE_INFO } from '@/lib/graphql/Fragments.ts';
export const GET_GLOBAL_METADATA = gql`
${GLOBAL_METADATA}
query GET_GLOBAL_METADATA($key: String!) {
meta(key: $key) {
...GLOBAL_METADATA
}
}
`;
export const GET_GLOBAL_METADATAS = gql`
${GLOBAL_METADATA}
${PAGE_INFO}
query GET_GLOBAL_METADATAS(
$after: Cursor
$before: Cursor
$condition: MetaConditionInput
$filter: MetaFilterInput
$first: Int
$last: Int
$offset: Int
$orderBy: MetaOrderBy
$orderByType: SortOrder
) {
metas(
after: $after
before: $before
condition: $condition
filter: $filter
first: $first
last: $last
offset: $offset
orderBy: $orderBy
orderByType: $orderByType
) {
nodes {
...GLOBAL_METADATA
}
pageInfo {
...PAGE_INFO
}
totalCount
}
}
`;

View File

@@ -0,0 +1,59 @@
/*
* 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 gql from 'graphql-tag';
import { FULL_CHAPTER_FIELDS, FULL_MANGA_FIELDS, PAGE_INFO } from '@/lib/graphql/Fragments';
// returns the current manga from the database
export const GET_MANGA = gql`
${FULL_MANGA_FIELDS}
${FULL_CHAPTER_FIELDS}
query GET_MANGA($id: Int!) {
manga(id: $id) {
...FULL_MANGA_FIELDS
}
}
`;
// returns the current manga from the database
export const GET_MANGAS = gql`
${FULL_MANGA_FIELDS}
${FULL_CHAPTER_FIELDS}
${PAGE_INFO}
query GET_MANGAS(
$after: Cursor
$before: Cursor
$condition: MangaConditionInput
$filter: MangaFilterInput
$first: Int
$last: Int
$offset: Int
$orderBy: MangaOrderBy
$orderByType: SortOrder
) {
mangas(
after: $after
before: $before
condition: $condition
filter: $filter
first: $first
last: $last
offset: $offset
orderBy: $orderBy
orderByType: $orderByType
) {
nodes {
...FULL_MANGA_FIELDS
}
pageInfo {
...PAGE_INFO
}
totalCount
}
}
`;

View File

@@ -0,0 +1,52 @@
/*
* 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 gql from 'graphql-tag';
import { WEBUI_UPDATE_INFO, WEBUI_UPDATE_STATUS } from '@/lib/graphql/Fragments';
export const GET_ABOUT = gql`
query GET_ABOUT {
about {
buildTime
buildType
discord
github
name
revision
version
}
}
`;
export const CHECK_FOR_SERVER_UPDATES = gql`
query CHECK_FOR_SERVER_UPDATES {
checkForServerUpdates {
channel
tag
url
}
}
`;
export const CHECK_FOR_WEBUI_UPDATE = gql`
${WEBUI_UPDATE_INFO}
query CHECK_FOR_WEBUI_UPDATE {
checkForWebUIUpdate {
...WEBUI_UPDATE_INFO
}
}
`;
export const GET_WEBUI_UPDATE_STATUS = gql`
${WEBUI_UPDATE_STATUS}
query GET_WEBUI_UPDATE_STATUS {
getWebUIUpdateStatus {
...WEBUI_UPDATE_STATUS
}
}
`;

View File

@@ -0,0 +1,20 @@
/*
* 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 gql from 'graphql-tag';
import { SERVER_SETTINGS } from '@/lib/graphql/Fragments';
// eslint-disable-next-line import/prefer-default-export
export const GET_SERVER_SETTINGS = gql`
${SERVER_SETTINGS}
query GET_SERVER_SETTINGS {
settings {
...SERVER_SETTINGS
}
}
`;

View File

@@ -0,0 +1,30 @@
/*
* 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 gql from 'graphql-tag';
import { FULL_SOURCE_FIELDS, PARTIAL_SOURCE_FIELDS } from '@/lib/graphql/Fragments';
export const GET_SOURCE = gql`
${FULL_SOURCE_FIELDS}
query GET_SOURCE($id: LongString!) {
source(id: $id) {
...FULL_SOURCE_FIELDS
}
}
`;
export const GET_SOURCES = gql`
${PARTIAL_SOURCE_FIELDS}
query GET_SOURCES {
sources {
nodes {
...PARTIAL_SOURCE_FIELDS
}
}
}
`;

View File

@@ -0,0 +1,28 @@
/*
* 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 gql from 'graphql-tag';
import { FULL_UPDATER_STATUS } from '@/lib/graphql/Fragments';
// eslint-disable-next-line import/prefer-default-export
export const GET_UPDATE_STATUS = gql`
${FULL_UPDATER_STATUS}
query GET_UPDATE_STATUS {
updateStatus {
...FULL_UPDATER_STATUS
}
}
`;
export const GET_LAST_UPDATE_TIMESTAMP = gql`
query GET_LAST_UPDATE_TIMESTAMP {
lastUpdateTimestamp {
timestamp
}
}
`;

View File

@@ -0,0 +1,20 @@
/*
* 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 gql from 'graphql-tag';
import { FULL_DOWNLOAD_STATUS } from '@/lib/graphql/Fragments';
// eslint-disable-next-line import/prefer-default-export
export const DOWNLOAD_STATUS_SUBSCRIPTION = gql`
${FULL_DOWNLOAD_STATUS}
subscription DOWNLOAD_STATUS_SUBSCRIPTION {
downloadChanged {
...FULL_DOWNLOAD_STATUS
}
}
`;

View File

@@ -0,0 +1,20 @@
/*
* 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 gql from 'graphql-tag';
import { WEBUI_UPDATE_STATUS } from '@/lib/graphql/Fragments';
// eslint-disable-next-line import/prefer-default-export
export const WEBUI_UPDATE_SUBSCRIPTION = gql`
${WEBUI_UPDATE_STATUS}
subscription WEBUI_UPDATE_SUBSCRIPTION {
webUIUpdateStatusChange {
...WEBUI_UPDATE_STATUS
}
}
`;

View File

@@ -0,0 +1,20 @@
/*
* 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 gql from 'graphql-tag';
import { FULL_UPDATER_STATUS } from '@/lib/graphql/Fragments';
// eslint-disable-next-line import/prefer-default-export
export const UPDATER_SUBSCRIPTION = gql`
${FULL_UPDATER_STATUS}
subscription UPDATER_SUBSCRIPTION {
updateStatusChanged {
...FULL_UPDATER_STATUS
}
}
`;

View File

@@ -0,0 +1,40 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
// eslint-disable-next-line import/prefer-default-export
export class CustomCache {
private keyToResponseMap = new Map<string, unknown>();
private keyToFetchTimestampMap = new Map<string, number>();
public readonly createKeyFn = (endpoint: string, data: unknown): string => `${endpoint}_${JSON.stringify(data)}`;
constructor(createKeyFn?: (endpoint: string, data: unknown) => string) {
this.createKeyFn = createKeyFn ?? this.createKeyFn;
}
public getKeyFor(key: string, data: unknown): string {
return this.createKeyFn(key, data);
}
public cacheResponse(endpoint: string, data: unknown, response: unknown) {
const createdKey = this.getKeyFor(endpoint, data);
this.keyToFetchTimestampMap.set(createdKey, Date.now());
this.keyToResponseMap.set(createdKey, response);
}
public getFetchTimestampFor(endpoint: string, data: unknown): number | undefined {
const key = this.getKeyFor(endpoint, data);
return this.keyToFetchTimestampMap.get(key);
}
public getResponseFor<Response = any>(endpoint: string, data: unknown): Response | undefined {
const key = this.getKeyFor(endpoint, data);
return this.keyToResponseMap.get(key) as Response;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
/*
* 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 storage from '@/util/localStorage.tsx';
// eslint-disable-next-line import/prefer-default-export
export abstract class BaseClient<Client, ClientConfig, Fetcher> {
protected client!: Client;
public abstract readonly fetcher: Fetcher;
constructor() {
this.createClient();
}
public getBaseUrl(): string {
const { hostname, port, protocol } = window.location;
// if port is 3000 it's probably running from webpack development server
const inferredPort = port === '3000' ? '4567' : port;
return storage.getItem('serverBaseURL', `${protocol}//${hostname}:${inferredPort}`);
}
protected abstract createClient(): void;
public abstract updateConfig(config: Partial<ClientConfig>): void;
}

View File

@@ -0,0 +1,109 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import {
ApolloClient,
ApolloClientOptions,
InMemoryCache,
NormalizedCacheObject,
Reference,
split,
} from '@apollo/client';
import { createUploadLink } from 'apollo-upload-client';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { Client, createClient } from 'graphql-ws';
import { getMainDefinition } from '@apollo/client/utilities';
import { BaseClient } from '@/lib/requests/client/BaseClient.ts';
import { StrictTypedTypePolicies } from '@/lib/graphql/generated/apollo-helpers.ts';
/* eslint-disable no-underscore-dangle */
const typePolicies: StrictTypedTypePolicies = {
GlobalMetaType: { keyFields: ['key'] },
ExtensionType: { keyFields: ['apkName'] },
AboutPayload: { keyFields: [] },
Query: {
fields: {
chapters: {
keyArgs: ['condition', 'filter', 'orderBy', 'orderByType'],
merge(existing, incoming) {
const merged = {
...existing,
...incoming,
nodes: existing?.nodes ?? [],
};
const isRefetch = incoming.nodes.some(
(incomingChapter) =>
existing?.nodes.some(
(existingChapter) =>
(existingChapter as unknown as Reference).__ref ===
(incomingChapter as unknown as Reference).__ref,
),
);
if (!isRefetch) {
merged.nodes = [...(existing?.nodes ?? []), ...incoming.nodes];
}
return merged;
},
},
},
},
};
/* eslint-enable no-underscore-dangle */
// eslint-disable-next-line import/prefer-default-export
export class GraphQLClient extends BaseClient<
ApolloClient<NormalizedCacheObject>,
ApolloClientOptions<NormalizedCacheObject>,
null
> {
readonly fetcher = null;
public declare client: ApolloClient<NormalizedCacheObject>;
private wsClient!: Client;
public override getBaseUrl(): string {
return `${super.getBaseUrl()}/api/graphql`;
}
private createUploadLink() {
return createUploadLink({ uri: () => this.getBaseUrl() });
}
private createWSLink() {
return new GraphQLWsLink(this.wsClient);
}
private createLink() {
return split(
({ query }) => {
const definition = getMainDefinition(query);
return definition.kind === 'OperationDefinition' && definition.operation === 'subscription';
},
this.createWSLink(),
this.createUploadLink(),
);
}
protected createClient() {
this.wsClient = createClient({
url: () => this.getBaseUrl().replace(/http(|s)/g, 'ws'),
keepAlive: 20000,
});
this.client = new ApolloClient({
cache: new InMemoryCache({
typePolicies,
}),
connectToDevTools: true,
link: this.createLink(),
});
}
public override updateConfig() {}
}

View File

@@ -7,7 +7,7 @@
*/ */
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import storage from '@/util/localStorage'; import { BaseClient } from '@/lib/requests/client/BaseClient.ts';
export enum HttpMethod { export enum HttpMethod {
GET = 'get', GET = 'get',
@@ -28,13 +28,10 @@ export interface IRestClient {
patch<Data = any, Response = SimpleRestResponse<Data>>(url: string, data?: any): Promise<Response>; patch<Data = any, Response = SimpleRestResponse<Data>>(url: string, data?: any): Promise<Response>;
} }
export class RestClient implements IRestClient { export class RestClient
protected client!: AxiosInstance; extends BaseClient<AxiosInstance, AxiosInstance['defaults'], <Data = any>(url: string, data: any) => Promise<Data>>
implements IRestClient
constructor() { {
this.createClient();
}
public readonly fetcher = async <Data = any>( public readonly fetcher = async <Data = any>(
url: string, url: string,
{ {
@@ -75,12 +72,8 @@ export class RestClient implements IRestClient {
return result.data; return result.data;
}; };
private createClient(): void { protected override createClient(): void {
const { hostname, port, protocol } = window.location; const baseURL = this.getBaseUrl();
// if port is 3000 it's probably running from webpack development server
const inferredPort = port === '3000' ? '4567' : port;
const baseURL = storage.getItem('serverBaseURL', `${protocol}//${hostname}:${inferredPort}`);
this.client = axios.create({ this.client = axios.create({
// baseURL must not have trailing slash // baseURL must not have trailing slash

View File

@@ -18,31 +18,27 @@ import { DragDropContext, Draggable } from 'react-beautiful-dnd';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IChapter, IQueue } from '@/typings'; import requestManager from '@/lib/requests/RequestManager.ts';
import requestManager from '@/lib/RequestManager';
import StrictModeDroppable from '@/lib/StrictModeDroppable'; import StrictModeDroppable from '@/lib/StrictModeDroppable';
import makeToast from '@/components/util/Toast'; import makeToast from '@/components/util/Toast';
import { NavbarToolbar } from '@/components/navbar/DefaultNavBar'; import { NavbarToolbar } from '@/components/navbar/DefaultNavBar';
import DownloadStateIndicator from '@/components/molecules/DownloadStateIndicator'; import DownloadStateIndicator from '@/components/molecules/DownloadStateIndicator';
import useSubscription from '@/components/library/useSubscription';
import EmptyView from '@/components/util/EmptyView'; import EmptyView from '@/components/util/EmptyView';
import NavbarContext from '@/components/context/NavbarContext'; import NavbarContext from '@/components/context/NavbarContext';
import { DownloadType } from '@/lib/graphql/generated/graphql.ts';
const initialQueue = { import { TChapter } from '@/typings.ts';
status: 'Stopped',
queue: [],
} as IQueue;
const DownloadQueue: React.FC = () => { const DownloadQueue: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: queueState } = useSubscription<IQueue>('downloads'); const { data: downloaderData } = requestManager.useDownloadSubscription();
const { queue, status } = queueState ?? initialQueue; const queue = (downloaderData?.downloadChanged.queue as DownloadType[]) ?? [];
const status = downloaderData?.downloadChanged.state ?? 'STARTED';
const { setTitle, setAction } = useContext(NavbarContext); const { setTitle, setAction } = useContext(NavbarContext);
const toggleQueueStatus = () => { const toggleQueueStatus = () => {
if (status === 'Stopped') { if (status === 'STOPPED') {
requestManager.startDownloads(); requestManager.startDownloads();
} else { } else {
requestManager.stopDownloads(); requestManager.stopDownloads();
@@ -60,8 +56,8 @@ const DownloadQueue: React.FC = () => {
return <EmptyView message={t('download.queue.label.no_downloads')} />; return <EmptyView message={t('download.queue.label.no_downloads')} />;
} }
const handleDelete = async (chapter: IChapter) => { const handleDelete = async (chapter: TChapter) => {
const isRunning = status === 'Started'; const isRunning = status === 'STARTED';
try { try {
if (isRunning) { if (isRunning) {
@@ -71,10 +67,10 @@ const DownloadQueue: React.FC = () => {
await Promise.all([ await Promise.all([
// remove from download queue // remove from download queue
requestManager.removeChapterFromDownloadQueue(chapter.mangaId, chapter.index).response, requestManager.removeChapterFromDownloadQueue(chapter.id).response,
// delete partial download, should be handle server side? // delete partial download, should be handle server side?
// bug: The folder and the last image downloaded are not deleted // bug: The folder and the last image downloaded are not deleted
requestManager.deleteDownloadedChapter(chapter.mangaId, chapter.index).response, requestManager.deleteDownloadedChapter(chapter.id).response,
]); ]);
} catch (error) { } catch (error) {
makeToast(t('download.queue.error.label.failed_to_remove'), 'error'); makeToast(t('download.queue.error.label.failed_to_remove'), 'error');
@@ -91,7 +87,7 @@ const DownloadQueue: React.FC = () => {
<> <>
<NavbarToolbar> <NavbarToolbar>
<IconButton onClick={toggleQueueStatus} size="large"> <IconButton onClick={toggleQueueStatus} size="large">
{status === 'Stopped' ? <PlayArrowIcon /> : <PauseIcon />} {status === 'STOPPED' ? <PlayArrowIcon /> : <PauseIcon />}
</IconButton> </IconButton>
</NavbarToolbar> </NavbarToolbar>
<DragDropContext onDragEnd={onDragEnd}> <DragDropContext onDragEnd={onDragEnd}>
@@ -100,8 +96,8 @@ const DownloadQueue: React.FC = () => {
<Box ref={droppableProvided.innerRef} sx={{ pt: 1 }}> <Box ref={droppableProvided.innerRef} sx={{ pt: 1 }}>
{queue.map((item, index) => ( {queue.map((item, index) => (
<Draggable <Draggable
key={`${item.mangaId}-${item.chapterIndex}`} key={`${item.chapter.manga.id}-${item.chapter.sourceOrder}`}
draggableId={`${item.mangaId}-${item.chapterIndex}`} draggableId={`${item.chapter.manga.id}-${item.chapter.sourceOrder}`}
index={index} index={index}
> >
{(draggableProvided, snapshot) => ( {(draggableProvided, snapshot) => (
@@ -129,7 +125,7 @@ const DownloadQueue: React.FC = () => {
<DragHandle /> <DragHandle />
</IconButton> </IconButton>
<Stack sx={{ flex: 1, ml: 1 }} direction="column"> <Stack sx={{ flex: 1, ml: 1 }} direction="column">
<Typography variant="h6">{item.manga.title}</Typography> <Typography variant="h6">{item.chapter.manga.title}</Typography>
<Typography variant="caption" display="block" gutterBottom> <Typography variant="caption" display="block" gutterBottom>
{item.chapter.name} {item.chapter.name}
</Typography> </Typography>

View File

@@ -14,8 +14,7 @@ import { StringParam, useQueryParam } from 'use-query-params';
import { Virtuoso } from 'react-virtuoso'; import { Virtuoso } from 'react-virtuoso';
import { Typography, useMediaQuery, useTheme } from '@mui/material'; import { Typography, useMediaQuery, useTheme } from '@mui/material';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IExtension } from '@/typings'; import requestManager from '@/lib/requests/RequestManager.ts';
import requestManager from '@/lib/RequestManager';
import { extensionDefaultLangs, DefaultLanguage, langSortCmp } from '@/util/language'; import { extensionDefaultLangs, DefaultLanguage, langSortCmp } from '@/util/language';
import useLocalStorage from '@/util/useLocalStorage'; import useLocalStorage from '@/util/useLocalStorage';
import { import {
@@ -31,11 +30,12 @@ import { makeToaster } from '@/components/util/Toast';
import LangSelect from '@/components/navbar/action/LangSelect'; import LangSelect from '@/components/navbar/action/LangSelect';
import NavbarContext from '@/components/context/NavbarContext'; import NavbarContext from '@/components/context/NavbarContext';
import ExtensionCard from '@/components/ExtensionCard'; import ExtensionCard from '@/components/ExtensionCard';
import { PartialExtension } from '@/typings.ts';
const LANGUAGE = 0; const LANGUAGE = 0;
const EXTENSIONS = 1; const EXTENSIONS = 1;
function getExtensionsInfo(extensions: IExtension[]): { function getExtensionsInfo(extensions: PartialExtension[]): {
allLangs: string[]; allLangs: string[];
groupedExtensions: GroupedExtensionsResult; groupedExtensions: GroupedExtensionsResult;
} { } {
@@ -55,12 +55,12 @@ function getExtensionsInfo(extensions: IExtension[]): {
allLangs.push(extension.lang); allLangs.push(extension.lang);
} }
} }
if (extension.installed) { if (extension.isInstalled) {
if (extension.hasUpdate) { if (extension.hasUpdate) {
sortedExtensions[ExtensionState.UPDATE_PENDING].push(extension); sortedExtensions[ExtensionState.UPDATE_PENDING].push(extension);
return; return;
} }
if (extension.obsolete) { if (extension.isObsolete) {
sortedExtensions[ExtensionState.OBSOLETE].push(extension); sortedExtensions[ExtensionState.OBSOLETE].push(extension);
return; return;
} }
@@ -100,7 +100,17 @@ export default function MangaExtensions() {
const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const [query] = useQueryParam('query', StringParam); const [query] = useQueryParam('query', StringParam);
const { data: allExtensions, mutate, isLoading } = requestManager.useGetExtensionList(); const [extensionsTimestamp, setExtensionsTimestamp] = useLocalStorage('extensionsTimestamp', 0);
const [fetchExtensions, { loading: isFetching }] = requestManager.useExtensionListFetch();
const { data, loading: isLoading } = requestManager.useGetExtensionList();
const allExtensions = data?.extensions.nodes;
useEffect(() => {
const updateExtensionsList = Date.now() - extensionsTimestamp >= 1000 * 60; // update list in case it's older than 1 minute
if (updateExtensionsList) {
fetchExtensions().catch(() => setExtensionsTimestamp(Date.now()));
}
}, []);
const filteredExtensions = useMemo( const filteredExtensions = useMemo(
() => () =>
@@ -122,7 +132,7 @@ export default function MangaExtensions() {
[shownLangs, groupedExtensions], [shownLangs, groupedExtensions],
); );
const flatRenderItems: (IExtension | string)[] = filteredGroupedExtensions.flat(2); const flatRenderItems: (PartialExtension | string)[] = filteredGroupedExtensions.flat(2);
const [toasts, makeToast] = makeToaster(useState<React.ReactElement[]>([])); const [toasts, makeToast] = makeToaster(useState<React.ReactElement[]>([]));
@@ -134,10 +144,9 @@ export default function MangaExtensions() {
makeToast(t('extension.label.installing_file'), 'info'); makeToast(t('extension.label.installing_file'), 'info');
requestManager requestManager
.installExtension(file) .installExternalExtension(file)
.response.then(() => { .response.then(() => {
makeToast(t('extension.label.installed_successfully'), 'success'); makeToast(t('extension.label.installed_successfully'), 'success');
mutate();
}) })
.catch(() => makeToast(t('extension.label.installation_failed'), 'error')); .catch(() => makeToast(t('extension.label.installation_failed'), 'error'));
} else { } else {
@@ -156,7 +165,7 @@ export default function MangaExtensions() {
<LangSelect shownLangs={shownLangs} setShownLangs={setShownLangs} allLangs={allLangs} /> <LangSelect shownLangs={shownLangs} setShownLangs={setShownLangs} allLangs={allLangs} />
</>, </>,
); );
}, [t, shownLangs]); }, [t, shownLangs, allLangs]);
useEffect(() => { useEffect(() => {
const dropHandler = async (e: Event) => { const dropHandler = async (e: Event) => {
@@ -178,7 +187,7 @@ export default function MangaExtensions() {
}; };
}, []); }, []);
if (isLoading) { if (isLoading || isFetching) {
return <LoadingPlaceholder />; return <LoadingPlaceholder />;
} }
@@ -220,17 +229,9 @@ export default function MangaExtensions() {
</Typography> </Typography>
); );
} }
const item = flatRenderItems[index] as IExtension; const item = flatRenderItems[index] as PartialExtension;
return ( return <ExtensionCard key={item.apkName} extension={item} />;
<ExtensionCard
key={item.apkName}
extension={item}
notifyInstall={() => {
mutate();
}}
/>
);
}} }}
/> />
</> </>

View File

@@ -7,10 +7,10 @@
*/ */
import { Chip, Tab, Tabs, styled, Box } from '@mui/material'; import { Chip, Tab, Tabs, styled, Box } from '@mui/material';
import React, { useContext, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useContext, useEffect, useMemo } from 'react';
import { useQueryParam, NumberParam } from 'use-query-params'; import { useQueryParam, NumberParam } from 'use-query-params';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import requestManager from '@/lib/RequestManager'; import requestManager from '@/lib/requests/RequestManager.ts';
import NavbarContext from '@/components/context/NavbarContext'; import NavbarContext from '@/components/context/NavbarContext';
import EmptyView from '@/components/util/EmptyView'; import EmptyView from '@/components/util/EmptyView';
import LoadingPlaceholder from '@/components/util/LoadingPlaceholder'; import LoadingPlaceholder from '@/components/util/LoadingPlaceholder';
@@ -62,20 +62,34 @@ export default function Library() {
const { t } = useTranslation(); const { t } = useTranslation();
const { options } = useLibraryOptionsContext(); const { options } = useLibraryOptionsContext();
const [lastLibraryUpdate, setLastLibraryUpdate] = useState(Date.now()); const {
const { data: tabsData, error: tabsError, isLoading: areCategoriesLoading } = requestManager.useGetCategories(); data: categoriesResponse,
error: tabsError,
loading: areCategoriesLoading,
refetch,
} = requestManager.useGetCategories();
const tabsData = categoriesResponse?.categories.nodes.filter(
(category) => category.id !== 0 || (category.id === 0 && category.mangas.totalCount),
);
const tabs = tabsData ?? []; const tabs = tabsData ?? [];
const librarySize = useMemo(() => tabs.map((tab) => tab.size).reduce((prev, curr) => prev + curr, 0), [tabs]); const librarySize = useMemo(
() => tabs.map((tab) => tab.mangas.totalCount).reduce((prev, curr) => prev + curr, 0),
[tabs],
);
const [tabSearchParam, setTabSearchParam] = useQueryParam('tab', NumberParam); const [tabSearchParam, setTabSearchParam] = useQueryParam('tab', NumberParam);
const activeTab = tabs.find((tab) => tab.order === tabSearchParam) ?? tabs[0]; const activeTab = tabs.find((tab) => tab.order === tabSearchParam) ?? tabs[0];
const { const {
data: mangaData, data: categoryMangaResponse,
error: mangaError, error: mangaError,
isLoading: mangaLoading, loading: mangaLoading,
} = requestManager.useGetCategoryMangas(activeTab?.id, { skipRequest: !activeTab }); } = requestManager.useGetCategoryMangas(activeTab?.id, { skip: !activeTab, nextFetchPolicy: 'cache-only' });
const mangas = mangaData ?? []; const mangas = categoryMangaResponse?.mangas.nodes ?? [];
const handleFinishedUpdate = useCallback(() => {
refetch();
}, [refetch]);
const { setTitle, setAction } = useContext(NavbarContext); const { setTitle, setAction } = useContext(NavbarContext);
useEffect(() => { useEffect(() => {
@@ -91,7 +105,7 @@ export default function Library() {
<> <>
<AppbarSearch /> <AppbarSearch />
<LibraryToolbarMenu /> <LibraryToolbarMenu />
<UpdateChecker handleFinishedUpdate={setLastLibraryUpdate} /> <UpdateChecker handleFinishedUpdate={handleFinishedUpdate} />
</>, </>,
); );
return () => { return () => {
@@ -101,14 +115,14 @@ export default function Library() {
}, [t, librarySize, areCategoriesLoading, options]); }, [t, librarySize, areCategoriesLoading, options]);
const handleTabChange = (newTab: number) => { const handleTabChange = (newTab: number) => {
setTabSearchParam(newTab === 0 ? undefined : newTab); setTabSearchParam(newTab);
}; };
if (tabsError != null) { if (tabsError != null) {
return ( return (
<EmptyView <EmptyView
message={t('category.error.label.request_failure')} message={t('category.error.label.request_failure')}
messageExtra={tabsError?.message ?? tabsError} messageExtra={tabsError.message ?? tabsError}
/> />
); );
} }
@@ -125,7 +139,6 @@ export default function Library() {
return ( return (
<LibraryMangaGrid <LibraryMangaGrid
mangas={mangas} mangas={mangas}
lastLibraryUpdate={lastLibraryUpdate}
message={t('library.error.label.empty')} message={t('library.error.label.empty')}
isLoading={activeTab != null && mangaLoading} isLoading={activeTab != null && mangaLoading}
/> />
@@ -154,7 +167,7 @@ export default function Library() {
label={ label={
<TitleWithSizeTag> <TitleWithSizeTag>
{tab.name} {tab.name}
{options.showTabSize ? <TitleSizeTag label={tab.size} /> : null} {options.showTabSize ? <TitleSizeTag label={tab.mangas.totalCount} /> : null}
</TitleWithSizeTag> </TitleWithSizeTag>
} }
value={tab.order} value={tab.order}
@@ -167,12 +180,11 @@ export default function Library() {
(mangaError ? ( (mangaError ? (
<EmptyView <EmptyView
message={t('manga.error.label.request_failure')} message={t('manga.error.label.request_failure')}
messageExtra={mangaError?.message ?? mangaError} messageExtra={mangaError.message ?? mangaError}
/> />
) : ( ) : (
<LibraryMangaGrid <LibraryMangaGrid
mangas={mangas} mangas={mangas}
lastLibraryUpdate={lastLibraryUpdate}
message={t('library.error.label.empty')} message={t('library.error.label.empty')}
isLoading={mangaLoading} isLoading={mangaLoading}
/> />

View File

@@ -11,7 +11,8 @@ import { CircularProgress, IconButton, Stack, Tooltip, Box } from '@mui/material
import React, { useContext, useEffect, useRef } from 'react'; import React, { useContext, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import requestManager from '@/lib/RequestManager'; import { isNetworkRequestInFlight } from '@apollo/client/core/networkStatus';
import requestManager from '@/lib/requests/RequestManager.ts';
import NavbarContext, { useSetDefaultBackTo } from '@/components/context/NavbarContext'; import NavbarContext, { useSetDefaultBackTo } from '@/components/context/NavbarContext';
import ChapterList from '@/components/manga/ChapterList'; import ChapterList from '@/components/manga/ChapterList';
import { useRefreshManga } from '@/components/manga/hooks'; import { useRefreshManga } from '@/components/manga/hooks';
@@ -20,7 +21,7 @@ import MangaToolbarMenu from '@/components/manga/MangaToolbarMenu';
import EmptyView from '@/components/util/EmptyView'; import EmptyView from '@/components/util/EmptyView';
import LoadingPlaceholder from '@/components/util/LoadingPlaceholder'; import LoadingPlaceholder from '@/components/util/LoadingPlaceholder';
const AUTOFETCH_AGE = 60 * 60 * 24; // 24 hours const AUTOFETCH_AGE = 1000 * 60 * 60 * 24; // 24 hours
const Manga: React.FC = () => { const Manga: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -29,21 +30,26 @@ const Manga: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const autofetchedRef = useRef(false); const autofetchedRef = useRef(false);
const { data: manga, error, isLoading, isValidating, mutate } = requestManager.useGetManga(id); const { data, error, loading: isLoading, networkStatus, refetch } = requestManager.useGetManga(id);
const isValidating = isNetworkRequestInFlight(networkStatus);
const manga = data?.manga;
const [refresh, { loading: refreshing }] = useRefreshManga(id); const [refresh, { loading: refreshing }] = useRefreshManga(id);
useSetDefaultBackTo('library'); useSetDefaultBackTo('library');
useEffect(() => { useEffect(() => {
// Automatically fetch manga from source if data is older then 24 hours // Automatically fetch manga from source if data is older then 24 hours OR manga is not initialized yet
// Automatic fetch is done only once, to prevent issues when server does // Automatic fetch is done only once, to prevent issues when server does
// not update age for some reason (ie. error on source side) // not update age for some reason (ie. error on source side)
if (manga == null) return; if (manga == null) return;
if (
manga.inLibrary && const isOutdated =
(manga.age > AUTOFETCH_AGE || manga.chaptersAge > AUTOFETCH_AGE) && Date.now() - Number(manga.lastFetchedAt) * 1000 > AUTOFETCH_AGE ||
autofetchedRef.current === false Date.now() - Number(manga.chaptersLastFetchedAt) * 1000 > AUTOFETCH_AGE;
) { const refetchBecauseOutdated = manga.inLibrary && isOutdated;
const doFetch = !autofetchedRef.current && (refetchBecauseOutdated || !manga.initialized);
if (doFetch) {
autofetchedRef.current = true; autofetchedRef.current = true;
refresh(); refresh();
} }
@@ -67,7 +73,7 @@ const Manga: React.FC = () => {
</> </>
} }
> >
<IconButton onClick={() => mutate()}> <IconButton onClick={() => refetch()}>
<Warning color="error" /> <Warning color="error" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
@@ -80,7 +86,7 @@ const Manga: React.FC = () => {
{manga && <MangaToolbarMenu manga={manga} onRefresh={refresh} refreshing={refreshing} />} {manga && <MangaToolbarMenu manga={manga} onRefresh={refresh} refreshing={refreshing} />}
</Stack>, </Stack>,
); );
}, [t, error, isValidating, refreshing, mutate, manga, refresh]); }, [t, error, isValidating, refreshing, manga, refresh]);
if (error && !manga) { if (error && !manga) {
return <EmptyView message={t('manga.error.label.request_failure')} messageExtra={error.message ?? error} />; return <EmptyView message={t('manga.error.label.request_failure')} messageExtra={error.message ?? error} />;
@@ -90,7 +96,7 @@ const Manga: React.FC = () => {
{isLoading && <LoadingPlaceholder />} {isLoading && <LoadingPlaceholder />}
{manga && <MangaDetails manga={manga} />} {manga && <MangaDetails manga={manga} />}
<ChapterList mangaId={id} /> {manga && <ChapterList manga={manga} isRefreshing={refreshing} />}
</Box> </Box>
); );
}; };

View File

@@ -7,12 +7,12 @@
*/ */
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import { useCallback, useContext, useEffect, useState } from 'react'; import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { Box } from '@mui/material'; import { Box } from '@mui/material';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ChapterOffset, IChapter, IManga, IMangaCard, IReaderSettings, ReaderType, TranslationKey } from '@/typings'; import { ChapterOffset, IReaderSettings, ReaderType, TChapter, TManga, TranslationKey } from '@/typings';
import requestManager from '@/lib/RequestManager'; import requestManager from '@/lib/requests/RequestManager.ts';
import { import {
checkAndHandleMissingStoredReaderSettings, checkAndHandleMissingStoredReaderSettings,
getReaderSettingsFor, getReaderSettingsFor,
@@ -28,10 +28,10 @@ import ReaderNavBar from '@/components/navbar/ReaderNavBar';
import NavbarContext from '@/components/context/NavbarContext'; import NavbarContext from '@/components/context/NavbarContext';
import makeToast from '@/components/util/Toast'; import makeToast from '@/components/util/Toast';
const isDupChapter = async (chapterIndex: number, currentChapter: IChapter) => { const isDupChapter = async (chapterIndex: number, currentChapter: TChapter) => {
const nextChapter = await requestManager.getChapter(currentChapter.mangaId, chapterIndex).response; const nextChapter = await requestManager.getChapter(currentChapter.manga.id, chapterIndex).response;
return nextChapter.chapterNumber === currentChapter.chapterNumber; return nextChapter.data.chapter.chapterNumber === currentChapter.chapterNumber;
}; };
/** /**
@@ -41,7 +41,7 @@ const isDupChapter = async (chapterIndex: number, currentChapter: IChapter) => {
*/ */
const getOffsetChapter = async ( const getOffsetChapter = async (
chapterIndex: number, chapterIndex: number,
currentChapter: IChapter, currentChapter: TChapter,
skipDupChapters: boolean, skipDupChapters: boolean,
offset: ChapterOffset, offset: ChapterOffset,
): Promise<number> => { ): Promise<number> => {
@@ -81,11 +81,11 @@ const getReaderComponent = (readerType: ReaderType) => {
const range = (n: number) => Array.from({ length: n }, (value, key) => key); const range = (n: number) => Array.from({ length: n }, (value, key) => key);
const initialChapter = { const initialChapter = {
pageCount: -1, pageCount: -1,
index: -1, sourceOrder: -1,
chapterCount: 0, chapterCount: 0,
lastPageRead: 0, lastPageRead: 0,
name: 'Loading...', name: 'Loading...',
}; } as unknown as TChapter;
export default function Reader() { export default function Reader() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -93,22 +93,58 @@ export default function Reader() {
const location = useLocation(); const location = useLocation();
const { chapterIndex, mangaId } = useParams<{ chapterIndex: string; mangaId: string }>(); const { chapterIndex, mangaId } = useParams<{ chapterIndex: string; mangaId: string }>();
const {
data: manga = { const initialManga = useMemo(
id: +mangaId, () =>
title: '', ({
thumbnailUrl: '', id: +mangaId,
genre: [], title: '',
inLibraryAt: 0, thumbnailUrl: '',
lastReadAt: 0, genre: [],
} as IMangaCard | IManga, inLibraryAt: 0,
isLoading: isMangaLoading, lastReadAt: 0,
} = requestManager.useGetManga(mangaId); chapters: { totalCount: 0 },
const { data: chapter = initialChapter, isLoading: isChapterLoading } = requestManager.useGetChapter( }) as unknown as TManga,
mangaId, [mangaId],
chapterIndex,
{ disableCache: true, revalidateOnFocus: false },
); );
const { data, loading: isMangaLoading } = requestManager.useGetManga(mangaId);
const loadedChapter = useRef<TChapter | null>(null);
const isChapterLoaded =
Number(mangaId) === loadedChapter.current?.manga.id &&
Number(chapterIndex) === loadedChapter.current?.sourceOrder &&
loadedChapter.current?.pageCount !== -1;
const manga = data?.manga ?? initialManga;
const { data: chapterData, loading: isChapterLoading } = requestManager.useGetMangaChapter(mangaId, chapterIndex, {
skip: isChapterLoaded,
});
const getLoadedChapter = () => {
const isAChapterLoaded = loadedChapter.current;
const isSameAsLoadedChapter = isAChapterLoaded && isChapterLoaded;
if (isSameAsLoadedChapter) {
return loadedChapter.current;
}
if (chapterData?.chapter) {
return chapterData.chapter;
}
return null;
};
loadedChapter.current = getLoadedChapter();
const chapter = loadedChapter.current ?? initialChapter;
const [fetchPages, { loading: areChapterPagesLoading }] = requestManager.useGetChapterPagesFetch(chapter.id);
useEffect(() => {
if (!isChapterLoading && chapter.pageCount === -1) {
fetchPages();
}
}, [chapter.id]);
const isLoading = isChapterLoading || areChapterPagesLoading || chapter.pageCount === -1;
const [wasLastPageReadSet, setWasLastPageReadSet] = useState(false); const [wasLastPageReadSet, setWasLastPageReadSet] = useState(false);
const [curPage, setCurPage] = useState<number>(0); const [curPage, setCurPage] = useState<number>(0);
const [pageToScrollTo, setPageToScrollTo] = useState<number | undefined>(undefined); const [pageToScrollTo, setPageToScrollTo] = useState<number | undefined>(undefined);
@@ -130,12 +166,7 @@ export default function Reader() {
setRetrievingNextChapter(true); setRetrievingNextChapter(true);
try { try {
setHistory( setHistory(
await getOffsetChapter( await getOffsetChapter(chapter.sourceOrder + offset, chapter, settings.skipDupChapters, offset),
chapter.index + offset,
chapter as IChapter,
settings.skipDupChapters,
offset,
),
); );
} catch (error) { } catch (error) {
const offsetToTranslationKeyMap: { [chapterOffset in ChapterOffset]: TranslationKey } = { const offsetToTranslationKeyMap: { [chapterOffset in ChapterOffset]: TranslationKey } = {
@@ -152,7 +183,7 @@ export default function Reader() {
); );
useEffect(() => { useEffect(() => {
if (isChapterLoading || !chapter) { if (isLoading || !chapter) {
return; return;
} }
@@ -161,13 +192,13 @@ export default function Reader() {
// last page, also probably read = true, we will load the first page. // last page, also probably read = true, we will load the first page.
setCurPage(0); setCurPage(0);
} else setCurPage(chapter.lastPageRead); } else setCurPage(chapter.lastPageRead);
}, [chapter, isChapterLoading]); }, [chapter, isLoading]);
useEffect(() => { useEffect(() => {
if (!manga?.title || (chapter as IChapter)?.name === t('global.label.loading')) { if (!manga?.title || chapter.name === t('global.label.loading')) {
setTitle(t('reader.title')); setTitle(t('reader.title'));
} else { } else {
setTitle(`${manga.title}: ${(chapter as IChapter).name}`); setTitle(`${manga.title}: ${chapter.name}`);
} }
}, [t, manga, chapter]); }, [t, manga, chapter]);
@@ -187,7 +218,7 @@ export default function Reader() {
settings={settings} settings={settings}
setSettingValue={setSettingValue} setSettingValue={setSettingValue}
manga={manga} manga={manga}
chapter={chapter as IChapter} chapter={chapter}
curPage={curPage} curPage={curPage}
scrollToPage={setPageToScrollTo} scrollToPage={setPageToScrollTo}
openNextChapter={openNextChapter} openNextChapter={openNextChapter}
@@ -206,20 +237,23 @@ export default function Reader() {
} }
// do not mutate the chapter, this will cause the page to jump around due to always scrolling to the last read page // do not mutate the chapter, this will cause the page to jump around due to always scrolling to the last read page
if (curPage !== -1) { const updateLastPageRead = curPage !== -1;
requestManager.updateChapter(manga.id, chapter.index, { lastPageRead: curPage }); const updateIsRead = curPage === chapter.pageCount - 1;
} const updateChapter = updateLastPageRead || updateIsRead;
if (curPage === chapter.pageCount - 1) { if (updateChapter) {
requestManager.updateChapter(manga.id, chapter.index, { read: true }); requestManager.updateChapter(chapter.id, {
lastPageRead: updateLastPageRead ? curPage : undefined,
isRead: updateIsRead ? true : undefined,
});
} }
}, [curPage]); }, [curPage]);
const nextChapter = useCallback(() => { const nextChapter = useCallback(() => {
if (chapter.index < chapter.chapterCount) { if (chapter.sourceOrder < manga.chapters.totalCount) {
requestManager.updateChapter(manga.id, chapter.index, { requestManager.updateChapter(chapter.id, {
lastPageRead: chapter.pageCount - 1, lastPageRead: chapter.pageCount - 1,
read: true, isRead: true,
}); });
openNextChapter(ChapterOffset.NEXT, (nextChapterIndex) => openNextChapter(ChapterOffset.NEXT, (nextChapterIndex) =>
@@ -229,10 +263,10 @@ export default function Reader() {
}), }),
); );
} }
}, [chapter.index, chapter.chapterCount, chapter.pageCount, manga.id, settings.skipDupChapters]); }, [chapter.sourceOrder, manga.chapters.totalCount, chapter.pageCount, manga.id, settings.skipDupChapters]);
const prevChapter = useCallback(() => { const prevChapter = useCallback(() => {
if (chapter.index > 1) { if (chapter.sourceOrder > 1) {
openNextChapter(ChapterOffset.PREV, (prevChapterIndex) => openNextChapter(ChapterOffset.PREV, (prevChapterIndex) =>
navigate(`/manga/${manga.id}/chapter/${prevChapterIndex}`, { navigate(`/manga/${manga.id}/chapter/${prevChapterIndex}`, {
replace: true, replace: true,
@@ -240,7 +274,7 @@ export default function Reader() {
}), }),
); );
} }
}, [chapter.index, manga.id, settings.skipDupChapters]); }, [chapter.sourceOrder, manga.id, settings.skipDupChapters]);
// return spinner while chpater data is loading // return spinner while chpater data is loading
if (chapter.pageCount === -1) { if (chapter.pageCount === -1) {
@@ -277,6 +311,7 @@ export default function Reader() {
> >
<PageNumber settings={settings} curPage={curPage} pageCount={chapter.pageCount} /> <PageNumber settings={settings} curPage={curPage} pageCount={chapter.pageCount} />
<ReaderComponent <ReaderComponent
key={chapter.id}
pages={pages} pages={pages}
pageCount={chapter.pageCount} pageCount={chapter.pageCount}
setCurPage={setCurPage} setCurPage={setCurPage}

View File

@@ -12,7 +12,7 @@ import { Link } from 'react-router-dom';
import { StringParam, useQueryParam } from 'use-query-params'; import { StringParam, useQueryParam } from 'use-query-params';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ISource } from '@/typings'; import { ISource } from '@/typings';
import requestManager from '@/lib/RequestManager'; import requestManager from '@/lib/requests/RequestManager.ts';
import useLocalStorage from '@/util/useLocalStorage'; import useLocalStorage from '@/util/useLocalStorage';
import { langSortCmp, sourceDefualtLangs, sourceForcedDefaultLangs } from '@/util/language'; import { langSortCmp, sourceDefualtLangs, sourceForcedDefaultLangs } from '@/util/language';
import { translateExtensionLanguage } from '@/screens/util/Extensions'; import { translateExtensionLanguage } from '@/screens/util/Extensions';
@@ -96,16 +96,13 @@ const SourceSearchPreview = React.memo(
emptyQuery: boolean; emptyQuery: boolean;
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const skipRequest = !searchString;
const { id, displayName, lang } = source; const { id, displayName, lang } = source;
const { const [, results] = requestManager.useSourceSearch(id, searchString ?? '', undefined, 1, {
data: searchResult, skipRequest: !searchString,
isLoading, });
error, const { data: searchResult, isLoading, error, abortRequest } = results[0]!;
abortRequest, const mangas = searchResult?.fetchSourceManga.mangas ?? [];
} = requestManager.useSourceQuickSearch(id, searchString ?? '', [], 1, { skipRequest });
const mangas = !isLoading ? searchResult?.[0]?.mangaList ?? [] : [];
const noMangasFound = !isLoading && !mangas.length; const noMangasFound = !isLoading && !mangas.length;
useEffect(() => { useEffect(() => {
@@ -169,7 +166,8 @@ const SearchAll: React.FC = () => {
const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownSourceLangs', sourceDefualtLangs()); const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownSourceLangs', sourceDefualtLangs());
const [showNsfw] = useLocalStorage<boolean>('showNsfw', true); const [showNsfw] = useLocalStorage<boolean>('showNsfw', true);
const { data: sources = [] } = requestManager.useGetSourceList(); const { data } = requestManager.useGetSourceList();
const sources = data?.sources.nodes ?? [];
const [sourceToLoadingStateMap, setSourceToLoadingStateMap] = useState<SourceToLoadingStateMap>(new Map()); const [sourceToLoadingStateMap, setSourceToLoadingStateMap] = useState<SourceToLoadingStateMap>(new Map());
const debouncedSourceToLoadingStateMap = useDebounce(sourceToLoadingStateMap, 500); const debouncedSourceToLoadingStateMap = useDebounce(sourceToLoadingStateMap, 500);

View File

@@ -35,7 +35,7 @@ import ViewModuleIcon from '@mui/icons-material/ViewModule';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import LanguageIcon from '@mui/icons-material/Language'; import LanguageIcon from '@mui/icons-material/Language';
import CollectionsOutlinedBookmarkIcon from '@mui/icons-material/CollectionsBookmarkOutlined'; import CollectionsOutlinedBookmarkIcon from '@mui/icons-material/CollectionsBookmarkOutlined';
import requestManager from '@/lib/RequestManager'; import requestManager from '@/lib/requests/RequestManager.ts';
import { langCodeToName } from '@/util/language'; import { langCodeToName } from '@/util/language';
import useLocalStorage from '@/util/useLocalStorage'; import useLocalStorage from '@/util/useLocalStorage';
import ListItemLink from '@/components/util/ListItemLink'; import ListItemLink from '@/components/util/ListItemLink';

View File

@@ -10,19 +10,20 @@ import { createElement, useContext, useEffect } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import List from '@mui/material/List'; import List from '@mui/material/List';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import requestManager from '@/lib/RequestManager'; import requestManager from '@/lib/requests/RequestManager.ts';
import cloneObject from '@/util/cloneObject'; import cloneObject from '@/util/cloneObject';
import NavbarContext from '@/components/context/NavbarContext'; import NavbarContext from '@/components/context/NavbarContext';
import { SwitchPreferenceCompat, CheckBoxPreference } from '@/components/sourceConfiguration/TwoStatePreference'; import { SwitchPreferenceCompat, CheckBoxPreference } from '@/components/sourceConfiguration/TwoStatePreference';
import ListPreference from '@/components/sourceConfiguration/ListPreference'; import ListPreference from '@/components/sourceConfiguration/ListPreference';
import EditTextPreference from '@/components/sourceConfiguration/EditTextPreference'; import EditTextPreference from '@/components/sourceConfiguration/EditTextPreference';
import MultiSelectListPreference from '@/components/sourceConfiguration/MultiSelectListPreference'; import MultiSelectListPreference from '@/components/sourceConfiguration/MultiSelectListPreference';
import { PreferenceProps } from '@/typings.ts';
function getPrefComponent(type: string) { function getPrefComponent(type: string) {
switch (type) { switch (type) {
case 'CheckBoxPreference': case 'CheckBoxPreference':
return CheckBoxPreference; return CheckBoxPreference;
case 'SwitchPreferenceCompat': case 'SwitchPreference':
return SwitchPreferenceCompat; return SwitchPreferenceCompat;
case 'ListPreference': case 'ListPreference':
return ListPreference; return ListPreference;
@@ -31,7 +32,7 @@ function getPrefComponent(type: string) {
case 'MultiSelectListPreference': case 'MultiSelectListPreference':
return MultiSelectListPreference; return MultiSelectListPreference;
default: default:
return CheckBoxPreference; throw new Error(`Unexpected preference type "${type}"`);
} }
} }
@@ -45,33 +46,26 @@ export default function SourceConfigure() {
}, [t]); }, [t]);
const { sourceId } = useParams<{ sourceId: string }>(); const { sourceId } = useParams<{ sourceId: string }>();
const { data: sourcePreferences = [], mutate } = requestManager.useGetSourcePreferences(sourceId); const { data } = requestManager.useGetSource(sourceId);
const sourcePreferences = data?.source.preferences ?? [];
const convertToString = (position: number, value: any): string => { const updateValue =
switch (sourcePreferences[position].props.defaultValueType) { (position: number): PreferenceProps['updateValue'] =>
case 'Set<String>': (type, value) => {
return JSON.stringify(value); requestManager.setSourcePreferences(sourceId, { position, [type]: value });
default: };
return value.toString();
}
};
const updateValue = (position: number) => (value: any) => {
requestManager
.setSourcePreferences(sourceId, position, convertToString(position, value))
.response.then(() => mutate());
};
return ( return (
<List sx={{ padding: 0 }}> <List sx={{ padding: 0 }}>
{sourcePreferences.map((it, index) => { {sourcePreferences.map((it, index) => {
const props = cloneObject(it.props); const props = cloneObject(it);
props.updateValue = updateValue(index);
props.key = index;
// TypeScript is dumb in detecting extra props // TypeScript is dumb in detecting extra props
// @ts-ignore // @ts-ignore
return createElement(getPrefComponent(it.type), props); return createElement(getPrefComponent(it.type), {
...props,
updateValue: updateValue(index),
});
})} })}
</List> </List>
); );

View File

@@ -17,8 +17,8 @@ import { Box, Button, styled, useTheme, useMediaQuery } from '@mui/material';
import FavoriteIcon from '@mui/icons-material/Favorite'; import FavoriteIcon from '@mui/icons-material/Favorite';
import NewReleasesIcon from '@mui/icons-material/NewReleases'; import NewReleasesIcon from '@mui/icons-material/NewReleases';
import FilterListIcon from '@mui/icons-material/FilterList'; import FilterListIcon from '@mui/icons-material/FilterList';
import { IManga, PaginatedMangaList, TranslationKey } from '@/typings'; import { TPartialManga, TranslationKey } from '@/typings';
import requestManager, { AbortableSWRInfiniteResponse } from '@/lib/RequestManager'; import requestManager, { AbortableApolloUseMutationPaginatedResponse } from '@/lib/requests/RequestManager.ts';
import { useDebounce } from '@/components/manga/hooks'; import { useDebounce } from '@/components/manga/hooks';
import { useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext'; import { useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
import SourceGridLayout from '@/components/source/GridLayouts'; import SourceGridLayout from '@/components/source/GridLayouts';
@@ -26,6 +26,10 @@ import AppbarSearch from '@/components/util/AppbarSearch';
import SourceOptions from '@/components/source/SourceOptions'; import SourceOptions from '@/components/source/SourceOptions';
import NavbarContext from '@/components/context/NavbarContext'; import NavbarContext from '@/components/context/NavbarContext';
import SourceMangaGrid from '@/components/source/SourceMangaGrid'; import SourceMangaGrid from '@/components/source/SourceMangaGrid';
import {
GetSourceMangasFetchMutation,
GetSourceMangasFetchMutationVariables,
} from '@/lib/graphql/generated/graphql.ts';
const ContentTypeMenu = styled('div')(({ theme }) => ({ const ContentTypeMenu = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
@@ -69,6 +73,7 @@ export enum SourceContentType {
} }
interface IPos { interface IPos {
type: 'selectState' | 'textState' | 'checkBoxState' | 'triState' | 'sortState';
position: number; position: number;
state: any; state: any;
group?: number; group?: number;
@@ -81,15 +86,8 @@ const SOURCE_CONTENT_TYPE_TO_ERROR_MSG_KEY: { [contentType in SourceContentType]
[SourceContentType.SEARCH]: 'manga.error.label.no_mangas_found', [SourceContentType.SEARCH]: 'manga.error.label.no_mangas_found',
}; };
type SourceMangaResponse = Omit<AbortableSWRInfiniteResponse<PaginatedMangaList>, 'data'> & { const getUniqueMangas = (mangas: TPartialManga[]): TPartialManga[] => {
data: { const uniqueMangas: TPartialManga[] = [];
items: IManga[];
hasNextPage: boolean;
};
};
const getUniqueMangas = (mangas: IManga[]): IManga[] => {
const uniqueMangas: IManga[] = [];
mangas.forEach((manga) => { mangas.forEach((manga) => {
const isDuplicate = uniqueMangas.some((uniqueManga) => uniqueManga.id === manga.id); const isDuplicate = uniqueMangas.some((uniqueManga) => uniqueManga.id === manga.id);
@@ -106,9 +104,18 @@ const useSourceManga = (
contentType: SourceContentType, contentType: SourceContentType,
searchTerm: string | null | undefined, searchTerm: string | null | undefined,
filters: IPos[], filters: IPos[],
initialPages = 1, initialPages: number,
): SourceMangaResponse => { ): [
let result: AbortableSWRInfiniteResponse<PaginatedMangaList>; AbortableApolloUseMutationPaginatedResponse<GetSourceMangasFetchMutation, GetSourceMangasFetchMutationVariables>[0],
AbortableApolloUseMutationPaginatedResponse<
GetSourceMangasFetchMutation,
GetSourceMangasFetchMutationVariables
>[1][number],
] => {
let result: AbortableApolloUseMutationPaginatedResponse<
GetSourceMangasFetchMutation,
GetSourceMangasFetchMutationVariables
>;
switch (contentType) { switch (contentType) {
case SourceContentType.POPULAR: case SourceContentType.POPULAR:
result = requestManager.useGetSourcePopularMangas(sourceId, initialPages); result = requestManager.useGetSourcePopularMangas(sourceId, initialPages);
@@ -117,12 +124,12 @@ const useSourceManga = (
result = requestManager.useGetSourceLatestMangas(sourceId, initialPages); result = requestManager.useGetSourceLatestMangas(sourceId, initialPages);
break; break;
case SourceContentType.SEARCH: case SourceContentType.SEARCH:
result = requestManager.useSourceQuickSearch(sourceId, searchTerm ?? '', [], initialPages); result = requestManager.useSourceSearch(sourceId, searchTerm ?? '', undefined, initialPages);
break; break;
case SourceContentType.FILTER: case SourceContentType.FILTER:
result = requestManager.useSourceQuickSearch( result = requestManager.useSourceSearch(
sourceId, sourceId,
'', undefined,
filters.map((filter) => { filters.map((filter) => {
const { position, state, group } = filter; const { position, state, group } = filter;
@@ -130,32 +137,58 @@ const useSourceManga = (
if (isPartOfGroup) { if (isPartOfGroup) {
return { return {
position: group, position: group,
state: JSON.stringify({ groupChange: {
position, position,
state, [filter.type]: state,
}), },
}; };
} }
return filter; return {
position,
[filter.type]: state,
};
}), }),
initialPages, initialPages,
{ disableCache: true },
); );
break; break;
default: default:
throw new Error(`Unknown ContentType "${contentType}"`); throw new Error(`Unknown ContentType "${contentType}"`);
} }
const pages = result.data; const pages = result[1]!;
const { hasNextPage } = pages?.[pages.length - 1] ?? { hasNextPage: false }; const lastLoadedPageIndex = pages.findLastIndex((page) => !!page.data?.fetchSourceManga);
const lastLoadedPage = pages[lastLoadedPageIndex];
const items = useMemo( const items = useMemo(
() => (pages ?? []).map((page) => page.mangaList).reduce((prevList, list) => [...prevList, ...list], []), () =>
(pages ?? [])
.map((page) => page.data?.fetchSourceManga.mangas ?? [])
.reduce((prevList, list) => [...prevList, ...list], []),
[pages], [pages],
); );
const uniqueItems = useMemo(() => getUniqueMangas(items), [items]); const uniqueItems = useMemo(() => getUniqueMangas(items), [items]);
return { ...result, data: { items: uniqueItems, hasNextPage } }; if (!uniqueItems.length) {
return [result[0] as any, result[1][result[1].length - 1]];
}
return [
result[0],
{
...pages[pages.length - 1],
data: {
...lastLoadedPage!.data,
fetchSourceManga: {
...lastLoadedPage!.data!.fetchSourceManga,
hasNextPage:
pages.length > lastLoadedPageIndex + 1
? false
: lastLoadedPage!.data!.fetchSourceManga.hasNextPage,
mangas: uniqueItems,
},
},
},
];
}; };
export default function SourceMangas() { export default function SourceMangas() {
@@ -167,29 +200,35 @@ export default function SourceMangas() {
const { sourceId } = useParams<{ sourceId: string }>(); const { sourceId } = useParams<{ sourceId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { contentType: currentLocationContentType = SourceContentType.POPULAR } = const {
contentType: currentLocationContentType = SourceContentType.POPULAR,
filtersToApply: currentLocationFiltersToApply = [],
} =
useLocation<{ useLocation<{
contentType: SourceContentType; contentType: SourceContentType;
filtersToApply: IPos[];
}>().state ?? {}; }>().state ?? {};
const { options } = useLibraryOptionsContext(); const { options } = useLibraryOptionsContext();
const [query] = useQueryParam('query', StringParam); const [query] = useQueryParam('query', StringParam);
const [dialogFiltersToApply, setDialogFiltersToApply] = useState<IPos[]>([]); const [dialogFiltersToApply, setDialogFiltersToApply] = useState<IPos[]>(currentLocationFiltersToApply);
const [filtersToApply, setFiltersToApply] = useState<IPos[]>([]); const [filtersToApply, setFiltersToApply] = useState<IPos[]>(currentLocationFiltersToApply);
const searchTerm = useDebounce(query, 1000); const searchTerm = useDebounce(query, 1000);
const [resetScrollPosition, setResetScrollPosition] = useState(false); const [resetScrollPosition, setResetScrollPosition] = useState(false);
const [contentType, setContentType] = useState(currentLocationContentType); const [contentType, setContentType] = useState(currentLocationContentType);
const { const [loadPage, { data, isLoading, size: lastPageNum, abortRequest }] = useSourceManga(
data: { items: mangas, hasNextPage } = { items: [], hasNextPage: false }, sourceId,
isLoading, contentType,
size: lastPageNum, searchTerm,
setSize: setPages, filtersToApply,
mutate: refreshData, isLargeScreen ? 2 : 1,
abortRequest, );
} = useSourceManga(sourceId, contentType, searchTerm, filtersToApply, isLargeScreen ? 2 : 1); const mangas = data?.fetchSourceManga.mangas ?? [];
const { data: filters = [], mutate: mutateFilters } = requestManager.useGetSourceFilters(sourceId); const hasNextPage = data?.fetchSourceManga.hasNextPage ?? false;
const { data: source } = requestManager.useGetSource(sourceId);
const [triggerDataRefresh, setTriggerDataRefresh] = useState(false); const { data: sourceData } = requestManager.useGetSource(sourceId);
const source = sourceData?.source;
const filters = source?.filters ?? [];
const message = !isLoading ? t(SOURCE_CONTENT_TYPE_TO_ERROR_MSG_KEY[contentType]) : undefined; const message = !isLoading ? t(SOURCE_CONTENT_TYPE_TO_ERROR_MSG_KEY[contentType]) : undefined;
const isLocalSource = sourceId === '0'; const isLocalSource = sourceId === '0';
@@ -214,6 +253,15 @@ export default function SourceMangas() {
[setContentType], [setContentType],
); );
const updateLocationFilters = useCallback(
(updatedFilters: IPos[]) => {
if (contentType === SourceContentType.FILTER) {
navigate('', { replace: true, state: { contentType, filtersToApply: updatedFilters } });
}
},
[contentType],
);
const isSearchTermAvailable = searchTerm && query?.length; const isSearchTermAvailable = searchTerm && query?.length;
const setSearchContentType = isSearchTermAvailable && contentType !== SourceContentType.SEARCH; const setSearchContentType = isSearchTermAvailable && contentType !== SourceContentType.SEARCH;
if (setSearchContentType) { if (setSearchContentType) {
@@ -230,21 +278,15 @@ export default function SourceMangas() {
return; return;
} }
setPages(lastPageNum + 1); loadPage(lastPageNum + 1);
}, [setPages, lastPageNum, hasNextPage]); }, [lastPageNum, hasNextPage, contentType]);
const resetFilters = useCallback(async () => { const resetFilters = useCallback(async () => {
setDialogFiltersToApply([]); setDialogFiltersToApply([]);
setFiltersToApply([]); setFiltersToApply([]);
try { updateLocationFilters([]);
// required since previous implementation used to set the filters on server side (server caches them), thus, it has to be made sure that they are reset setResetScrollPosition(true);
await requestManager.resetSourceFilters(sourceId); }, [sourceId, contentType]);
mutateFilters();
} catch (error) {
// ignore
}
setTriggerDataRefresh(true);
}, [sourceId]);
useEffect( useEffect(
() => () => { () => () => {
@@ -261,15 +303,6 @@ export default function SourceMangas() {
[searchTerm, contentType], [searchTerm, contentType],
); );
useEffect(() => {
if (!triggerDataRefresh) {
return;
}
refreshData();
setTriggerDataRefresh(false);
}, [triggerDataRefresh]);
useEffect(() => { useEffect(() => {
setTitle(source?.displayName ?? t('source.title')); setTitle(source?.displayName ?? t('source.title'));
setAction( setAction(
@@ -326,6 +359,7 @@ export default function SourceMangas() {
</ContentTypeButton> </ContentTypeButton>
</ContentTypeMenu> </ContentTypeMenu>
<SourceMangaGrid <SourceMangaGrid
key={contentType}
mangas={mangas} mangas={mangas}
hasNextPage={hasNextPage} hasNextPage={hasNextPage}
loadMore={loadMore} loadMore={loadMore}
@@ -340,7 +374,7 @@ export default function SourceMangas() {
updateFilterValue={setDialogFiltersToApply} updateFilterValue={setDialogFiltersToApply}
setTriggerUpdate={() => { setTriggerUpdate={() => {
setFiltersToApply(dialogFiltersToApply); setFiltersToApply(dialogFiltersToApply);
setTriggerDataRefresh(true); updateLocationFilters(dialogFiltersToApply);
}} }}
resetFilterValue={resetFilters} resetFilterValue={resetFilters}
update={dialogFiltersToApply} update={dialogFiltersToApply}

View File

@@ -12,7 +12,7 @@ import TravelExploreIcon from '@mui/icons-material/TravelExplore';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ISource } from '@/typings'; import { ISource } from '@/typings';
import requestManager from '@/lib/RequestManager'; import requestManager from '@/lib/requests/RequestManager.ts';
import useLocalStorage from '@/util/useLocalStorage'; import useLocalStorage from '@/util/useLocalStorage';
import { sourceDefualtLangs, sourceForcedDefaultLangs, langSortCmp } from '@/util/language'; import { sourceDefualtLangs, sourceForcedDefaultLangs, langSortCmp } from '@/util/language';
import { translateExtensionLanguage } from '@/screens/util/Extensions'; import { translateExtensionLanguage } from '@/screens/util/Extensions';
@@ -53,7 +53,8 @@ export default function Sources() {
const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownSourceLangs', sourceDefualtLangs()); const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownSourceLangs', sourceDefualtLangs());
const [showNsfw] = useLocalStorage<boolean>('showNsfw', true); const [showNsfw] = useLocalStorage<boolean>('showNsfw', true);
const { data: sources, isLoading } = requestManager.useGetSourceList(); const { data, loading: isLoading } = requestManager.useGetSourceList();
const sources = data?.sources.nodes;
const navigate = useNavigate(); const navigate = useNavigate();

View File

@@ -13,17 +13,18 @@ import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent'; import CardContent from '@mui/material/CardContent';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useContext, useEffect, useMemo } from 'react';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { t as translate } from 'i18next'; import { t as translate } from 'i18next';
import { GroupedVirtuoso } from 'react-virtuoso'; import { GroupedVirtuoso } from 'react-virtuoso';
import { IChapter, IMangaChapter, IQueue } from '@/typings'; import requestManager from '@/lib/requests/RequestManager.ts';
import requestManager from '@/lib/RequestManager';
import LoadingPlaceholder from '@/components/util/LoadingPlaceholder'; import LoadingPlaceholder from '@/components/util/LoadingPlaceholder';
import EmptyView from '@/components/util/EmptyView'; import EmptyView from '@/components/util/EmptyView';
import DownloadStateIndicator from '@/components/molecules/DownloadStateIndicator'; import DownloadStateIndicator from '@/components/molecules/DownloadStateIndicator';
import NavbarContext from '@/components/context/NavbarContext'; import NavbarContext from '@/components/context/NavbarContext';
import { DownloadType } from '@/lib/graphql/generated/graphql.ts';
import { TChapter } from '@/typings.ts';
const StyledGroupedVirtuoso = styled(GroupedVirtuoso)(({ theme }) => ({ const StyledGroupedVirtuoso = styled(GroupedVirtuoso)(({ theme }) => ({
// 64px header // 64px header
@@ -81,58 +82,41 @@ function getDateString(date: Date) {
return date.toLocaleDateString(); return date.toLocaleDateString();
} }
const groupByDate = (updates: IMangaChapter[]): [date: string, items: number][] => { const groupByDate = (updates: TChapter[]): [date: string, items: number][] => {
if (!updates.length) { if (!updates.length) {
return []; return [];
} }
const dateToItemMap = new Map<string, number>(); const dateToItemMap = new Map<string, number>();
updates.forEach((item) => { updates.forEach((item) => {
const date = getDateString(epochToDate(item.chapter.fetchedAt)); const date = getDateString(epochToDate(Number(item.fetchedAt)));
dateToItemMap.set(date, (dateToItemMap.get(date) ?? 0) + 1); dateToItemMap.set(date, (dateToItemMap.get(date) ?? 0) + 1);
}); });
return [...dateToItemMap.entries()]; return [...dateToItemMap.entries()];
}; };
const initialQueue = {
status: 'Stopped',
queue: [],
} as IQueue;
const Updates: React.FC = () => { const Updates: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const location = useLocation(); const location = useLocation();
const { setTitle, setAction } = useContext(NavbarContext); const { setTitle, setAction } = useContext(NavbarContext);
const { const {
data: pages = [{ hasNextPage: false, page: [] }], data: chapterUpdateData,
isLoading, loading: isLoading,
size: loadedPages, fetchMore,
setSize: setPages, } = requestManager.useGetRecentlyUpdatedChapters(undefined, {
} = requestManager.useGetRecentlyUpdatedChapters(); fetchPolicy: 'cache-and-network',
const { hasNextPage } = pages[pages.length - 1]; notifyOnNetworkStatusChange: true,
const updateEntries = useMemo( omitAbortSignal: true,
() => pages.map((page) => page.page).reduce((lastPageChapters, chapters) => [...lastPageChapters, ...chapters]), });
[pages], const hasNextPage = !!chapterUpdateData?.chapters.pageInfo.hasNextPage;
); const endCursor = chapterUpdateData?.chapters.pageInfo.endCursor;
const updateEntries = chapterUpdateData?.chapters.nodes ?? [];
const groupedUpdates = useMemo(() => groupByDate(updateEntries), [updateEntries]); const groupedUpdates = useMemo(() => groupByDate(updateEntries), [updateEntries]);
const groupCounts: number[] = useMemo(() => groupedUpdates.map((group) => group[1]), [groupedUpdates]); const groupCounts: number[] = useMemo(() => groupedUpdates.map((group) => group[1]), [groupedUpdates]);
const { data: downloaderData } = requestManager.useDownloadSubscription();
const [, setWsClient] = useState<WebSocket>(); const queue = (downloaderData?.downloadChanged.queue as DownloadType[]) ?? [];
const [{ queue }, setQueueState] = useState<IQueue>(initialQueue);
useEffect(() => {
const wsc = requestManager.getDownloadWebSocket();
wsc.onmessage = (e) => {
const data = JSON.parse(e.data) as IQueue;
setQueueState(data);
};
setWsClient(wsc);
return () => wsc.close();
}, []);
useEffect(() => { useEffect(() => {
setTitle(t('updates.title')); setTitle(t('updates.title'));
@@ -140,13 +124,16 @@ const Updates: React.FC = () => {
setAction(null); setAction(null);
}, [t]); }, [t]);
const downloadForChapter = (chapter: IChapter) => { const downloadForChapter = (chapter: TChapter) => {
const { index, mangaId } = chapter; const {
return queue.find((q) => index === q.chapterIndex && mangaId === q.mangaId); sourceOrder,
manga: { id: mangaId },
} = chapter;
return queue.find((q) => sourceOrder === q.chapter.sourceOrder && mangaId === q.chapter.manga.id);
}; };
const downloadChapter = (chapter: IChapter) => { const downloadChapter = (chapter: TChapter) => {
requestManager.addChapterToDownloadQueue(chapter.mangaId, chapter.index); requestManager.addChapterToDownloadQueue(chapter.id);
}; };
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
@@ -154,8 +141,8 @@ const Updates: React.FC = () => {
return; return;
} }
setPages(loadedPages + 1); fetchMore({ variables: { offset: updateEntries.length } });
}, [hasNextPage, loadedPages]); }, [hasNextPage, endCursor]);
if (!isLoading && updateEntries.length === 0) { if (!isLoading && updateEntries.length === 0) {
return <EmptyView message={t('updates.error.label.no_updates_available')} />; return <EmptyView message={t('updates.error.label.no_updates_available')} />;
@@ -179,7 +166,8 @@ const Updates: React.FC = () => {
</StyledGroupHeader> </StyledGroupHeader>
)} )}
itemContent={(index) => { itemContent={(index) => {
const { chapter, manga } = updateEntries[index]; const chapter = updateEntries[index];
const { manga } = chapter;
const download = downloadForChapter(chapter); const download = downloadForChapter(chapter);
return ( return (
@@ -187,7 +175,7 @@ const Updates: React.FC = () => {
<Card> <Card>
<CardActionArea <CardActionArea
component={Link} component={Link}
to={`/manga/${chapter.mangaId}/chapter/${chapter.index}`} to={`/manga/${chapter.manga.id}/chapter/${chapter.sourceOrder}`}
state={location.state} state={location.state}
> >
<CardContent <CardContent
@@ -208,7 +196,7 @@ const Updates: React.FC = () => {
marginRight: 2, marginRight: 2,
imageRendering: 'pixelated', imageRendering: 'pixelated',
}} }}
src={requestManager.getValidImgUrlFor(manga.thumbnailUrl)} src={requestManager.getValidImgUrlFor(manga.thumbnailUrl ?? '')}
/> />
<Box sx={{ display: 'flex', flexDirection: 'column' }}> <Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="h5" component="h2"> <Typography variant="h5" component="h2">
@@ -220,7 +208,7 @@ const Updates: React.FC = () => {
</Box> </Box>
</Box> </Box>
{download && <DownloadStateIndicator download={download} />} {download && <DownloadStateIndicator download={download} />}
{download == null && !chapter.downloaded && ( {download == null && !chapter.isDownloaded && (
<IconButton <IconButton
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();

View File

@@ -11,7 +11,7 @@ import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem'; import ListItem from '@mui/material/ListItem';
import ListItemText from '@mui/material/ListItemText'; import ListItemText from '@mui/material/ListItemText';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import requestManager from '@/lib/RequestManager'; import requestManager from '@/lib/requests/RequestManager.ts';
import ListItemLink from '@/components/util/ListItemLink'; import ListItemLink from '@/components/util/ListItemLink';
import NavbarContext, { useSetDefaultBackTo } from '@/components/context/NavbarContext'; import NavbarContext, { useSetDefaultBackTo } from '@/components/context/NavbarContext';
import LoadingPlaceholder from '@/components/util/LoadingPlaceholder'; import LoadingPlaceholder from '@/components/util/LoadingPlaceholder';
@@ -25,7 +25,8 @@ export default function About() {
setAction(null); setAction(null);
}, [t]); }, [t]);
const { data: about } = requestManager.useGetAbout(); const { data } = requestManager.useGetAbout();
const about = data?.about;
useSetDefaultBackTo('settings'); useSetDefaultBackTo('settings');

View File

@@ -12,7 +12,7 @@ import ListItemText from '@mui/material/ListItemText';
import { fromEvent } from 'file-selector'; import { fromEvent } from 'file-selector';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ListItemButton } from '@mui/material'; import { ListItemButton } from '@mui/material';
import requestManager from '@/lib/RequestManager'; import requestManager from '@/lib/requests/RequestManager.ts';
import makeToast from '@/components/util/Toast'; import makeToast from '@/components/util/Toast';
import ListItemLink from '@/components/util/ListItemLink'; import ListItemLink from '@/components/util/ListItemLink';
import NavbarContext, { useSetDefaultBackTo } from '@/components/context/NavbarContext'; import NavbarContext, { useSetDefaultBackTo } from '@/components/context/NavbarContext';

View File

@@ -24,11 +24,11 @@ import DialogTitle from '@mui/material/DialogTitle';
import Checkbox from '@mui/material/Checkbox'; import Checkbox from '@mui/material/Checkbox';
import FormControlLabel from '@mui/material/FormControlLabel'; import FormControlLabel from '@mui/material/FormControlLabel';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ICategory } from '@/typings'; import requestManager from '@/lib/requests/RequestManager.ts';
import requestManager from '@/lib/RequestManager';
import StrictModeDroppable from '@/lib/StrictModeDroppable'; import StrictModeDroppable from '@/lib/StrictModeDroppable';
import { DEFAULT_FULL_FAB_HEIGHT } from '@/components/util/StyledFab'; import { DEFAULT_FULL_FAB_HEIGHT } from '@/components/util/StyledFab';
import NavbarContext, { useSetDefaultBackTo } from '@/components/context/NavbarContext'; import NavbarContext, { useSetDefaultBackTo } from '@/components/context/NavbarContext';
import { TCategory } from '@/typings.ts';
const getItemStyle = ( const getItemStyle = (
isDragging: boolean, isDragging: boolean,
@@ -52,9 +52,9 @@ export default function Categories() {
setAction(null); setAction(null);
}, [t]); }, [t]);
const { data, mutate } = requestManager.useGetCategories(); const { data } = requestManager.useGetCategories();
const categories = useMemo(() => { const categories = useMemo(() => {
const res = [...(data ?? [])]; const res = [...(data?.categories.nodes ?? [])];
if (res.length > 0 && res[0].name === 'Default') { if (res.length > 0 && res[0].name === 'Default') {
res.shift(); res.shift();
} }
@@ -65,17 +65,17 @@ export default function Categories() {
const [dialogOpen, setDialogOpen] = useState<boolean>(false); const [dialogOpen, setDialogOpen] = useState<boolean>(false);
const [dialogName, setDialogName] = useState<string>(''); const [dialogName, setDialogName] = useState<string>('');
const [dialogDefault, setDialogDefault] = useState<boolean>(false); const [dialogDefault, setDialogDefault] = useState<boolean>(false);
const [reorderCategory, { reset: revertReorder }] = requestManager.useReorderCategory();
const theme = useTheme(); const theme = useTheme();
useSetDefaultBackTo('settings'); useSetDefaultBackTo('settings');
const categoryReorder = (list: ICategory[], from: number, to: number) => { const categoryReorder = (list: TCategory[], from: number, to: number) => {
const newData = [...list]; const reorderedCategory = list[from];
const [removed] = newData.splice(from, 1);
newData.splice(to, 0, removed);
mutate(newData, { revalidate: false });
requestManager.reorderCategory(from + 1, to + 1).response.finally(() => mutate()); reorderCategory({ variables: { input: { id: reorderedCategory.id, position: to + 1 } } }).catch(() =>
revertReorder(),
);
}; };
const onDragEnd = (result: DropResult) => { const onDragEnd = (result: DropResult) => {
@@ -113,18 +113,16 @@ export default function Categories() {
setDialogOpen(false); setDialogOpen(false);
if (categoryToEdit === -1) { if (categoryToEdit === -1) {
requestManager.createCategory(dialogName).response.finally(() => mutate()); requestManager.createCategory({ name: dialogName, default: dialogDefault });
} else { } else {
const category = categories[categoryToEdit]; const category = categories[categoryToEdit];
requestManager requestManager.updateCategory(category.id, { name: dialogName, default: dialogDefault });
.updateCategory(category.id, { name: dialogName, default: dialogDefault })
.response.finally(() => mutate());
} }
}; };
const deleteCategory = (index: number) => { const deleteCategory = (index: number) => {
const category = categories[index]; const category = categories[index];
requestManager.deleteCategory(category.id).response.finally(() => mutate()); requestManager.deleteCategory(category.id);
}; };
return ( return (

View File

@@ -11,7 +11,7 @@ import { Box } from '@mui/material';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IReaderSettings } from '@/typings'; import { IReaderSettings } from '@/typings';
import { requestUpdateServerMetadata } from '@/util/metadata'; import { convertToGqlMeta, requestUpdateServerMetadata } from '@/util/metadata';
import { import {
checkAndHandleMissingStoredReaderSettings, checkAndHandleMissingStoredReaderSettings,
getDefaultSettings, getDefaultSettings,
@@ -34,7 +34,7 @@ export default function DefaultReaderSettings() {
useSetDefaultBackTo('settings'); useSetDefaultBackTo('settings');
const setSettingValue = (key: keyof IReaderSettings, value: string | boolean) => { const setSettingValue = (key: keyof IReaderSettings, value: string | boolean) => {
requestUpdateServerMetadata(metadata ?? {}, [[key, value]]).catch(() => requestUpdateServerMetadata(convertToGqlMeta(metadata)! ?? {}, [[key, value]]).catch(() =>
makeToast(t('reader.settings.error.label.failed_to_save_settings'), 'warning'), makeToast(t('reader.settings.error.label.failed_to_save_settings'), 'warning'),
); );
}; };
@@ -54,7 +54,11 @@ export default function DefaultReaderSettings() {
); );
} }
checkAndHandleMissingStoredReaderSettings({ meta: metadata }, 'server', getDefaultSettings()).catch(() => {}); checkAndHandleMissingStoredReaderSettings(
{ meta: convertToGqlMeta(metadata)! },
'server',
getDefaultSettings(),
).catch(() => {});
return ( return (
<ReaderSettingsOptions <ReaderSettingsOptions

View File

@@ -20,12 +20,13 @@ import DialogTitle from '@mui/material/DialogTitle';
import { styled } from '@mui/material'; import { styled } from '@mui/material';
import { t as translate } from 'i18next'; import { t as translate } from 'i18next';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ICategory, IncludeInGlobalUpdate } from '@/typings'; import requestManager from '@/lib/requests/RequestManager.ts';
import requestManager from '@/lib/RequestManager';
import makeToast from '@/components/util/Toast'; import makeToast from '@/components/util/Toast';
import ThreeStateCheckboxInput from '@/components/atoms/ThreeStateCheckboxInput'; import ThreeStateCheckboxInput from '@/components/atoms/ThreeStateCheckboxInput';
import NavbarContext, { useSetDefaultBackTo } from '@/components/context/NavbarContext'; import NavbarContext, { useSetDefaultBackTo } from '@/components/context/NavbarContext';
import SearchSettings from '@/screens/settings/SearchSettings'; import SearchSettings from '@/screens/settings/SearchSettings';
import { IncludeInUpdate } from '@/lib/graphql/generated/graphql.ts';
import { TCategory } from '@/typings.ts';
const CategoriesDiv = styled('div')({ const CategoriesDiv = styled('div')({
display: 'flex', display: 'flex',
@@ -34,16 +35,35 @@ const CategoriesDiv = styled('div')({
overflow: 'auto', overflow: 'auto',
}); });
const includeInUpdateStatusToBoolean = (status: IncludeInGlobalUpdate) => { const booleanToIncludeInStatus = (status: boolean | null | undefined): IncludeInUpdate => {
if (status === IncludeInGlobalUpdate.UNSET) { switch (status) {
return null; case false:
return IncludeInUpdate.Exclude;
case true:
return IncludeInUpdate.Include;
case null:
case undefined:
return IncludeInUpdate.Unset;
default:
throw new Error(`booleanToIncludeInStatus: unexpected IncludeInUpdate status "${status}"`);
} }
};
return !!status; const includeInUpdateStatusToBoolean = (status: IncludeInUpdate): boolean | null => {
switch (status) {
case IncludeInUpdate.Exclude:
return false;
case IncludeInUpdate.Include:
return true;
case IncludeInUpdate.Unset:
return null;
default:
throw new Error(`includeInUpdateStatusToBoolean: unexpected IncludeInUpdate status "${status}"`);
}
}; };
const getCategoryUpdateInfo = ( const getCategoryUpdateInfo = (
categories: ICategory[], categories: TCategory[],
areIncluded: boolean, areIncluded: boolean,
unsetCategories: number, unsetCategories: number,
allCategories: number, allCategories: number,
@@ -80,20 +100,25 @@ export default function LibrarySettings() {
useSetDefaultBackTo('settings'); useSetDefaultBackTo('settings');
const { data: categories = [], error: requestError, mutate } = requestManager.useGetCategories(); const { data, error: requestError } = requestManager.useGetCategories();
const [dialogCategories, setDialogCategories] = useState<ICategory[]>(categories); const categories = data?.categories.nodes;
const [dialogCategories, setDialogCategories] = useState<TCategory[]>(categories ?? []);
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (!categories) {
return;
}
setDialogCategories(categories); setDialogCategories(categories);
}, [categories]); }, [categories]);
const unsetCategories: ICategory[] = const unsetCategories: TCategory[] =
categories?.filter((category) => category.includeInUpdate === IncludeInGlobalUpdate.UNSET) ?? []; categories?.filter((category) => category.includeInUpdate === IncludeInUpdate.Unset) ?? [];
const excludedCategories: ICategory[] = const excludedCategories: TCategory[] =
categories?.filter((category) => category.includeInUpdate === IncludeInGlobalUpdate.EXCLUDE) ?? []; categories?.filter((category) => category.includeInUpdate === IncludeInUpdate.Exclude) ?? [];
const includedCategories: ICategory[] = const includedCategories: TCategory[] =
categories?.filter((category) => category.includeInUpdate === IncludeInGlobalUpdate.INCLUDE) ?? []; categories?.filter((category) => category.includeInUpdate === IncludeInUpdate.Include) ?? [];
const excludedCategoriesText = getCategoryUpdateInfo( const excludedCategoriesText = getCategoryUpdateInfo(
excludedCategories, excludedCategories,
false, false,
@@ -109,12 +134,12 @@ export default function LibrarySettings() {
requestError, requestError,
); );
const updateCategory = (category: ICategory) => const updateCategory = (category: TCategory) =>
requestManager.updateCategory(category.id, { includeInUpdate: category.includeInUpdate }).response; requestManager.updateCategory(category.id, { includeInUpdate: category.includeInUpdate }).response;
const updateCategories = async () => { const updateCategories = async () => {
const categoriesToUpdate = dialogCategories.filter((category) => { const categoriesToUpdate = dialogCategories.filter((category) => {
const currentCategory = categories.find((currCategory) => currCategory.id === category.id); const currentCategory = categories?.find((currCategory) => currCategory.id === category.id);
if (!currentCategory) { if (!currentCategory) {
return false; return false;
@@ -127,10 +152,11 @@ export default function LibrarySettings() {
try { try {
await Promise.all(categoriesToUpdate.map((category) => updateCategory(category))); await Promise.all(categoriesToUpdate.map((category) => updateCategory(category)));
mutate([...dialogCategories], { revalidate: false }); // TODO - update cache immediately
// mutate(categoriesEndpoint, [...dialogCategories], { revalidate: false });
} catch (error) { } catch (error) {
makeToast(t('global.error.label.failed_to_save_changes'), 'error'); makeToast(t('global.error.label.failed_to_save_changes'), 'error');
mutate([...categories]); // mutate(categoriesEndpoint, [...categories]);
} }
}; };
@@ -192,13 +218,12 @@ export default function LibrarySettings() {
label={category.name} label={category.name}
checked={includeInUpdateStatusToBoolean(category.includeInUpdate)} checked={includeInUpdateStatusToBoolean(category.includeInUpdate)}
onChange={(checked) => { onChange={(checked) => {
const newIncludeState: IncludeInGlobalUpdate = const newIncludeState = booleanToIncludeInStatus(checked);
checked == null ? IncludeInGlobalUpdate.UNSET : Number(checked);
const categoryIndex = dialogCategories.findIndex( const categoryIndex = dialogCategories.findIndex(
(category_) => category_ === category, (category_) => category_ === category,
); );
const updatedDialogCategories: ICategory[] = [ const updatedDialogCategories: TCategory[] = [
...dialogCategories.slice(0, categoryIndex), ...dialogCategories.slice(0, categoryIndex),
{ {
...category, ...category,

View File

@@ -12,7 +12,7 @@ import ListItemIcon from '@mui/material/ListItemIcon';
import SearchIcon from '@mui/icons-material/Search'; import SearchIcon from '@mui/icons-material/Search';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { SearchMetadataKeys } from '@/typings'; import { SearchMetadataKeys } from '@/typings';
import { requestUpdateServerMetadata } from '@/util/metadata'; import { convertToGqlMeta, requestUpdateServerMetadata } from '@/util/metadata';
import { useSearchSettings } from '@/util/searchSettings'; import { useSearchSettings } from '@/util/searchSettings';
import makeToast from '@/components/util/Toast'; import makeToast from '@/components/util/Toast';
import { useSetDefaultBackTo } from '@/components/context/NavbarContext'; import { useSetDefaultBackTo } from '@/components/context/NavbarContext';
@@ -24,7 +24,7 @@ export default function SearchSettings() {
useSetDefaultBackTo('settings'); useSetDefaultBackTo('settings');
const setSettingValue = (key: SearchMetadataKeys, value: boolean) => { const setSettingValue = (key: SearchMetadataKeys, value: boolean) => {
requestUpdateServerMetadata(metadata ?? {}, [[key, value]]).catch(() => requestUpdateServerMetadata(convertToGqlMeta(metadata)! ?? {}, [[key, value]]).catch(() =>
makeToast(t('search.error.label.failed_to_save_settings'), 'warning'), makeToast(t('search.error.label.failed_to_save_settings'), 'warning'),
); );
}; };

View File

@@ -7,7 +7,7 @@
*/ */
import { t } from 'i18next'; import { t } from 'i18next';
import { IExtension, TranslationKey } from '@/typings'; import { PartialExtension, TranslationKey } from '@/typings';
import { DefaultLanguage, langCodeToName } from '@/util/language'; import { DefaultLanguage, langCodeToName } from '@/util/language';
export enum ExtensionState { export enum ExtensionState {
@@ -16,16 +16,16 @@ export enum ExtensionState {
OBSOLETE = 'OBSOLETE', OBSOLETE = 'OBSOLETE',
} }
export type GroupedExtensionsResult<KEY extends string = string> = [KEY, IExtension[]][]; export type GroupedExtensionsResult<KEY extends string = string> = [KEY, PartialExtension[]][];
export type GroupedByExtensionState = { export type GroupedByExtensionState = {
[state in ExtensionState]: IExtension[]; [state in ExtensionState]: PartialExtension[];
}; };
export type GroupedByLanguage = { export type GroupedByLanguage = {
[language in DefaultLanguage]: IExtension[]; [language in DefaultLanguage]: PartialExtension[];
} & { } & {
[language: string]: IExtension[]; [language: string]: PartialExtension[];
}; };
export type GroupedExtensions = GroupedByExtensionState & GroupedByLanguage; export type GroupedExtensions = GroupedByExtensionState & GroupedByLanguage;

View File

@@ -10,6 +10,31 @@ import { OverridableComponent } from '@mui/material/OverridableComponent';
import { SvgIconTypeMap } from '@mui/material/SvgIcon/SvgIcon'; import { SvgIconTypeMap } from '@mui/material/SvgIcon/SvgIcon';
import { ParseKeys } from 'i18next'; import { ParseKeys } from 'i18next';
import { Location } from 'react-router-dom'; import { Location } from 'react-router-dom';
import {
GetCategoryQuery,
GetChapterQuery,
GetExtensionQuery,
GetMangaQuery,
GetSourceQuery,
MetaType,
SourcePreferenceChangeInput,
} from '@/lib/graphql/generated/graphql.ts';
export type ExtractByKeyValue<T, Key extends keyof T, Value extends T[Key]> = T extends
| Record<Key, Value>
| Partial<Record<Key, Value>>
? T
: never;
export type RecursivePartial<T> = {
[P in keyof T]?: T[P] extends (infer U)[]
? RecursivePartial<U>[]
: T[P] extends object | undefined
? RecursivePartial<T[P]>
: T[P];
};
export type OptionalProperty<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
type GenericLocation<State = any> = Omit<Location, 'state'> & { state?: State }; type GenericLocation<State = any> = Omit<Location, 'state'> & { state?: State };
@@ -21,19 +46,7 @@ declare module 'react-router-dom' {
export type TranslationKey = ParseKeys; export type TranslationKey = ParseKeys;
export interface IExtension { export type PartialExtension = GetExtensionQuery['extension'];
name: string;
pkgName: string;
versionName: string;
versionCode: number;
lang: string;
isNsfw: boolean;
apkName: string;
iconUrl: string;
installed: boolean;
hasUpdate: boolean;
obsolete: boolean;
}
export interface ISource { export interface ISource {
id: string; id: string;
@@ -46,29 +59,7 @@ export interface ISource {
displayName: string; displayName: string;
} }
export interface ISourceFilters { export type SourceFilters = GetSourceQuery['source']['filters'][number];
type: string;
filter: ISourceFilter;
}
export interface ISourceFilter {
name: string;
state: number | string | boolean | ISourceFilters[] | IState;
values?: string[];
displayValues?: string[];
selected?: ISelected;
}
export interface ISelected {
displayname: string;
value: string;
_value: string;
}
export interface IState {
ascending: boolean;
index: number;
}
export interface IMetadataMigration { export interface IMetadataMigration {
appKeyPrefix?: { oldPrefix: string; newPrefix: string }; appKeyPrefix?: { oldPrefix: string; newPrefix: string };
@@ -88,6 +79,8 @@ export type Metadata<Keys extends string = string, Values = string> = {
[key in Keys]: Values; [key in Keys]: Values;
}; };
export type GqlMetaHolder = { meta?: MetaType[] };
export type MetadataHolder<Keys extends string = string, Values = string> = { export type MetadataHolder<Keys extends string = string, Values = string> = {
meta?: Metadata<Keys, Values>; meta?: Metadata<Keys, Values>;
}; };
@@ -115,6 +108,10 @@ export interface IMangaCard {
lastReadAt: number; lastReadAt: number;
} }
export type TManga = GetMangaQuery['manga'];
export type TPartialManga = OptionalProperty<TManga, 'unreadCount' | 'downloadCount' | 'categories' | 'chapters'>;
export interface IManga { export interface IManga {
id: number; id: number;
sourceId: string; sourceId: string;
@@ -176,12 +173,7 @@ export interface IMangaChapter {
chapter: IChapter; chapter: IChapter;
} }
export interface IPartialChapter { export type TChapter = GetChapterQuery['chapter'];
pageCount: number;
index: number;
chapterCount: number;
lastPageRead: number;
}
export enum IncludeInGlobalUpdate { export enum IncludeInGlobalUpdate {
EXCLUDE = 0, EXCLUDE = 0,
@@ -189,6 +181,8 @@ export enum IncludeInGlobalUpdate {
UNSET = -1, UNSET = -1,
} }
export type TCategory = GetCategoryQuery['category'];
export interface ICategory { export interface ICategory {
id: number; id: number;
order: number; order: number;
@@ -247,22 +241,12 @@ export interface IReaderProps {
curPage: number; curPage: number;
initialPage: number; initialPage: number;
settings: IReaderSettings; settings: IReaderSettings;
manga: IMangaCard | IManga; manga: TManga;
chapter: IChapter | IPartialChapter; chapter: TChapter;
nextChapter: () => void; nextChapter: () => void;
prevChapter: () => void; prevChapter: () => void;
} }
export interface IAbout {
name: string;
version: string;
revision: string;
buildType: 'Stable' | 'Preview';
buildTime: number;
github: string;
discord: string;
}
export interface IDownloadChapter { export interface IDownloadChapter {
chapterIndex: number; chapterIndex: number;
mangaId: number; mangaId: number;
@@ -286,47 +270,34 @@ export interface IUpdateStatus {
}; };
} }
export type SourcePreferences = GetSourceQuery['source']['preferences'][number];
export interface PreferenceProps { export interface PreferenceProps {
key: string; updateValue: <Key extends keyof Omit<SourcePreferenceChangeInput, 'position'>>(
title: string; type: Key,
summary: string; value: SourcePreferenceChangeInput[Key],
defaultValue: any; ) => void;
currentValue: any; }
defaultValueType: string;
export type TwoStatePreferenceProps = (CheckBoxPreferenceProps | SwitchPreferenceCompatProps) & {
// intetnal props // intetnal props
updateValue: any; twoStateType: 'Switch' | 'Checkbox';
} };
export interface TwoStatePreferenceProps extends PreferenceProps { export type CheckBoxPreferenceProps = PreferenceProps &
// intetnal props ExtractByKeyValue<SourcePreferences, '__typename', 'CheckBoxPreference'>;
type: 'Switch' | 'Checkbox';
}
export interface CheckBoxPreferenceProps extends PreferenceProps {} export type SwitchPreferenceCompatProps = PreferenceProps &
ExtractByKeyValue<SourcePreferences, '__typename', 'SwitchPreference'>;
export interface SwitchPreferenceCompatProps extends PreferenceProps {} export type ListPreferenceProps = PreferenceProps &
ExtractByKeyValue<SourcePreferences, '__typename', 'ListPreference'>;
export interface ListPreferenceProps extends PreferenceProps { export type MultiSelectListPreferenceProps = PreferenceProps &
entries: string[]; ExtractByKeyValue<SourcePreferences, '__typename', 'MultiSelectListPreference'>;
entryValues: string[];
}
export interface MultiSelectListPreferenceProps extends PreferenceProps { export type EditTextPreferenceProps = PreferenceProps &
entries: string[]; ExtractByKeyValue<SourcePreferences, '__typename', 'EditTextPreference'>;
entryValues: string[];
}
export interface EditTextPreferenceProps extends PreferenceProps {
dialogTitle: string;
dialogMessage: string;
text: string;
}
export interface SourcePreferences {
type: string;
props: any;
}
export interface NavbarItem { export interface NavbarItem {
path: string; path: string;
@@ -389,13 +360,6 @@ export interface LibraryOptions {
showTabSize: boolean; showTabSize: boolean;
} }
export interface BatchChaptersChange {
delete?: boolean;
isRead?: boolean;
isBookmarked?: boolean;
lastPageRead?: number;
}
export type UpdateCheck = { export type UpdateCheck = {
channel: 'Stable' | 'Preview'; channel: 'Stable' | 'Preview';
tag: string; tag: string;

View File

@@ -6,20 +6,20 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import { mutate } from 'swr';
import { import {
AllowedMetadataValueTypes, AllowedMetadataValueTypes,
AppMetadataKeys, AppMetadataKeys,
ICategory, GqlMetaHolder,
IManga,
IMangaCard,
IMangaChapter,
IMetadataMigration, IMetadataMigration,
Metadata, Metadata,
MetadataHolder, MetadataHolder,
MetadataKeyValuePair, MetadataKeyValuePair,
TCategory,
TChapter,
TManga,
} from '@/typings'; } from '@/typings';
import requestManager, { RequestManager } from '@/lib/RequestManager'; import requestManager from '@/lib/requests/RequestManager.ts';
import { MetaType } from '@/lib/graphql/generated/graphql.ts';
const APP_METADATA_KEY_PREFIX = 'webUI_'; const APP_METADATA_KEY_PREFIX = 'webUI_';
@@ -117,6 +117,27 @@ const convertValueFromMetadata = <T extends AllowedMetadataValueTypes = AllowedM
return value as T; return value as T;
}; };
export const convertFromGqlMeta = (gqlMetadata?: MetaType[]): Metadata | undefined => {
if (!gqlMetadata) {
return undefined;
}
const metadata: Metadata = {};
gqlMetadata.forEach(({ key, value }) => {
metadata[key] = value;
});
return metadata;
};
export const convertToGqlMeta = (metadata?: Metadata): MetaType[] | undefined => {
if (!metadata) {
return undefined;
}
return Object.entries(metadata).map(([key, value]) => ({ key, value }));
};
const getAppMetadataFrom = (meta: Metadata, appPrefix: string = APP_METADATA_KEY_PREFIX): Metadata => { const getAppMetadataFrom = (meta: Metadata, appPrefix: string = APP_METADATA_KEY_PREFIX): Metadata => {
const appMetadata: Metadata = {}; const appMetadata: Metadata = {};
@@ -276,6 +297,8 @@ export const getMetadataFrom = <METADATA extends Partial<Metadata<AppMetadataKey
return appMetadata; return appMetadata;
}; };
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const wrapMetadataWithMetaKey = (wrap: boolean, metadata: Metadata): MetadataHolder => { const wrapMetadataWithMetaKey = (wrap: boolean, metadata: Metadata): MetadataHolder => {
if (wrap) { if (wrap) {
return { return {
@@ -293,72 +316,54 @@ const wrapMetadataWithMetaKey = (wrap: boolean, metadata: Metadata): MetadataHol
type MetadataHolderType = 'manga' | 'chapter' | 'category' | 'global'; type MetadataHolderType = 'manga' | 'chapter' | 'category' | 'global';
export const requestUpdateMetadataValue = async ( export const requestUpdateMetadataValue = async (
metadataHolder: MetadataHolder, metadataHolder: GqlMetaHolder,
holderType: MetadataHolderType, holderType: MetadataHolderType,
key: AppMetadataKeys, key: AppMetadataKeys,
value: AllowedMetadataValueTypes, value: AllowedMetadataValueTypes,
): Promise<void> => { ): Promise<void> => {
const metadataKey = getMetadataKey(key); const metadataKey = getMetadataKey(key);
const mutatedMetadata = {
...metadataHolder.meta,
[metadataKey]: `${value}`,
};
let endpoint: string;
switch (holderType) { switch (holderType) {
case 'category': case 'category':
endpoint = `category/${(metadataHolder as ICategory).id}/meta`; await requestManager.setCategoryMeta((metadataHolder as TCategory).id, metadataKey, value).response;
await requestManager.setCategoryMeta((metadataHolder as ICategory).id, metadataKey, value).response;
break; break;
case 'chapter': case 'chapter':
// eslint-disable-next-line no-case-declarations await requestManager.setChapterMeta((metadataHolder as TChapter).id, metadataKey, value).response;
const { manga, chapter } = metadataHolder as IMangaChapter;
endpoint = `manga/${manga.id}/chapter/${chapter.index}/meta`;
await requestManager.setChapterMeta(manga.id, chapter.index, metadataKey, value).response;
break; break;
case 'global': case 'global':
endpoint = 'meta';
await requestManager.setGlobalMetadata(metadataKey, value).response; await requestManager.setGlobalMetadata(metadataKey, value).response;
break; break;
case 'manga': case 'manga':
endpoint = `manga/${(metadataHolder as IManga).id}/meta`; await requestManager.setMangaMeta((metadataHolder as TManga).id, metadataKey, value).response;
await requestManager.setMangaMeta((metadataHolder as IManga).id, metadataKey, value).response;
break; break;
default: default:
throw new Error(`requestUpdateMetadataValue: unknown holderType "${holderType}"`); throw new Error(`requestUpdateMetadataValue: unknown holderType "${holderType}"`);
} }
const urlToMutate = `${RequestManager.API_VERSION}${endpoint}`;
mutate(
urlToMutate,
{ ...metadataHolder, ...wrapMetadataWithMetaKey(holderType !== 'global', mutatedMetadata) },
{ revalidate: false },
);
}; };
export const requestUpdateMetadata = async ( export const requestUpdateMetadata = async (
metadataHolder: MetadataHolder, metadataHolder: GqlMetaHolder,
holderType: MetadataHolderType, holderType: MetadataHolderType,
keysToValues: [AppMetadataKeys, AllowedMetadataValueTypes][], keysToValues: [AppMetadataKeys, AllowedMetadataValueTypes][],
): Promise<void[]> => ): Promise<void[]> =>
Promise.all(keysToValues.map(([key, value]) => requestUpdateMetadataValue(metadataHolder, holderType, key, value))); Promise.all(keysToValues.map(([key, value]) => requestUpdateMetadataValue(metadataHolder, holderType, key, value)));
export const requestUpdateServerMetadata = async ( export const requestUpdateServerMetadata = async (
serverMetadata: Metadata, serverMetadata: MetaType[],
keysToValues: MetadataKeyValuePair[], keysToValues: MetadataKeyValuePair[],
): Promise<void[]> => requestUpdateMetadata({ meta: serverMetadata }, 'global', keysToValues); ): Promise<void[]> => requestUpdateMetadata({ meta: serverMetadata }, 'global', keysToValues);
export const requestUpdateMangaMetadata = async ( export const requestUpdateMangaMetadata = async (
manga: IMangaCard | IManga, manga: TManga,
keysToValues: MetadataKeyValuePair[], keysToValues: MetadataKeyValuePair[],
): Promise<void[]> => requestUpdateMetadata(manga, 'manga', keysToValues); ): Promise<void[]> => requestUpdateMetadata(manga, 'manga', keysToValues);
export const requestUpdateChapterMetadata = async ( export const requestUpdateChapterMetadata = async (
mangaChapter: IMangaChapter, chapter: TChapter,
keysToValues: MetadataKeyValuePair[], keysToValues: MetadataKeyValuePair[],
): Promise<void[]> => requestUpdateMetadata(mangaChapter.chapter, 'chapter', keysToValues); ): Promise<void[]> => requestUpdateMetadata(chapter, 'chapter', keysToValues);
export const requestUpdateCategoryMetadata = async ( export const requestUpdateCategoryMetadata = async (
category: ICategory, category: TCategory,
keysToValues: MetadataKeyValuePair[], keysToValues: MetadataKeyValuePair[],
): Promise<void[]> => requestUpdateMetadata(category, 'category', keysToValues); ): Promise<void[]> => requestUpdateMetadata(category, 'category', keysToValues);

View File

@@ -6,9 +6,15 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import { IManga, Metadata, MetadataHolder, IReaderSettings, MetadataKeyValuePair } from '@/typings'; import { Metadata, IReaderSettings, MetadataKeyValuePair, GqlMetaHolder, TManga } from '@/typings';
import requestManager from '@/lib/RequestManager'; import requestManager from '@/lib/requests/RequestManager.ts';
import { getMetadataFrom, requestUpdateMangaMetadata, requestUpdateServerMetadata } from '@/util/metadata'; import {
convertFromGqlMeta,
getMetadataFrom,
requestUpdateMangaMetadata,
requestUpdateServerMetadata,
} from '@/util/metadata';
import { MetaType } from '@/lib/graphql/generated/graphql.ts';
type UndefinedReaderSettings = { type UndefinedReaderSettings = {
[setting in keyof IReaderSettings]: IReaderSettings[setting] | undefined; [setting in keyof IReaderSettings]: IReaderSettings[setting] | undefined;
@@ -37,20 +43,21 @@ export const getReaderSettingsFromMetadata = (
): IReaderSettings => getReaderSettingsWithDefaultValueFallback(meta, defaultSettings, applyMetadataMigration); ): IReaderSettings => getReaderSettingsWithDefaultValueFallback(meta, defaultSettings, applyMetadataMigration);
export const getReaderSettingsFor = ( export const getReaderSettingsFor = (
{ meta }: MetadataHolder, { meta }: GqlMetaHolder = {},
defaultSettings?: IReaderSettings, defaultSettings?: IReaderSettings,
applyMetadataMigration?: boolean, applyMetadataMigration?: boolean,
): IReaderSettings => getReaderSettingsFromMetadata(meta, defaultSettings, applyMetadataMigration); ): IReaderSettings => getReaderSettingsFromMetadata(convertFromGqlMeta(meta), defaultSettings, applyMetadataMigration);
export const useDefaultReaderSettings = (): { export const useDefaultReaderSettings = (): {
metadata?: Metadata; metadata?: Metadata;
settings: IReaderSettings; settings: IReaderSettings;
loading: boolean; loading: boolean;
} => { } => {
const { data: meta, isLoading } = requestManager.useGetGlobalMeta(); const { data, loading } = requestManager.useGetGlobalMeta();
const settings = getReaderSettingsWithDefaultValueFallback<IReaderSettings>(meta); const metadata = convertFromGqlMeta(data?.metas.nodes);
const settings = getReaderSettingsWithDefaultValueFallback<IReaderSettings>(metadata);
return { metadata: meta, settings, loading: isLoading }; return { metadata, settings, loading };
}; };
/** /**
@@ -61,11 +68,12 @@ export const useDefaultReaderSettings = (): {
* @param defaultSettings * @param defaultSettings
*/ */
export const checkAndHandleMissingStoredReaderSettings = async ( export const checkAndHandleMissingStoredReaderSettings = async (
metadataHolder: IManga | MetadataHolder, metadataHolder: Required<GqlMetaHolder> | MetaType[],
metadataHolderType: 'manga' | 'server', metadataHolderType: 'manga' | 'server',
defaultSettings: IReaderSettings, defaultSettings: IReaderSettings,
): Promise<void | void[]> => { ): Promise<void | void[]> => {
const meta = metadataHolder.meta ?? (metadataHolder as Metadata); const getMeta = () => (Array.isArray(metadataHolder) ? metadataHolder : metadataHolder.meta);
const meta = convertFromGqlMeta(getMeta())!;
const settingsToCheck = getReaderSettingsWithDefaultValueFallback( const settingsToCheck = getReaderSettingsWithDefaultValueFallback(
meta, meta,
{ {
@@ -79,7 +87,7 @@ export const checkAndHandleMissingStoredReaderSettings = async (
}, },
false, false,
); );
const newSettings = getReaderSettingsFor({ meta }, defaultSettings); const newSettings = getReaderSettingsFor({ meta: getMeta() }, defaultSettings);
const undefinedSettings = Object.entries(settingsToCheck).filter((setting) => setting[1] === undefined); const undefinedSettings = Object.entries(settingsToCheck).filter((setting) => setting[1] === undefined);
@@ -95,9 +103,9 @@ export const checkAndHandleMissingStoredReaderSettings = async (
} }
if (metadataHolderType === 'manga') { if (metadataHolderType === 'manga') {
await requestUpdateMangaMetadata(metadataHolder as IManga, settingsToUpdate); await requestUpdateMangaMetadata(metadataHolder as TManga, settingsToUpdate);
return; return;
} }
await requestUpdateServerMetadata(meta, settingsToUpdate); await requestUpdateServerMetadata(metadataHolder as MetaType[], settingsToUpdate);
}; };

View File

@@ -7,8 +7,8 @@
*/ */
import { Metadata, ISearchSettings } from '@/typings'; import { Metadata, ISearchSettings } from '@/typings';
import requestManager from '@/lib/RequestManager'; import requestManager from '@/lib/requests/RequestManager.ts';
import { getMetadataFrom } from '@/util/metadata'; import { convertFromGqlMeta, getMetadataFrom } from '@/util/metadata';
export const getDefaultSettings = (): ISearchSettings => ({ export const getDefaultSettings = (): ISearchSettings => ({
ignoreFilters: false, ignoreFilters: false,
@@ -24,8 +24,9 @@ export const useSearchSettings = (): {
settings: ISearchSettings; settings: ISearchSettings;
loading: boolean; loading: boolean;
} => { } => {
const { data: meta, isLoading } = requestManager.useGetGlobalMeta(); const { data, loading } = requestManager.useGetGlobalMeta();
const settings = getSearchSettingsWithDefaultValueFallback(meta); const metadata = convertFromGqlMeta(data?.metas.nodes);
const settings = getSearchSettingsWithDefaultValueFallback(metadata);
return { metadata: meta, settings, loading: isLoading }; return { metadata, settings, loading };
}; };

View File

@@ -0,0 +1,110 @@
/*
* 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 fs from 'fs';
import * as path from 'path';
const format = (source: string, regex: RegExp | string, replaceValue: string): string =>
source.replace(regex, replaceValue);
let generatedGraphQLFilePath = path.resolve(__dirname, '../../src/lib/graphql/generated/graphql.ts');
let generatedGraphQLFile = fs.readFileSync(generatedGraphQLFilePath, 'utf8');
// add logic to format the codegen generated graphql file
/* ******************************************* */
/* */
/* typescript, typescript-operations */
/* */
/* ******************************************* */
const fixCursorTyping = format(
generatedGraphQLFile,
/Cursor: \{ input: any; output: any; }/g,
'Cursor: { input: string; output: string; }',
);
const fixLongStringTyping = format(
fixCursorTyping,
/LongString: \{ input: any; output: any; }/g,
'LongString: { input: string; output: string; }',
);
const fixSubscriptionHookNameSuffix = format(fixLongStringTyping, /SubscriptionSubscription/g, 'Subscription');
fs.writeFileSync(generatedGraphQLFilePath, fixSubscriptionHookNameSuffix);
/* ****************************************** */
/* */
/* typescript-apollo-client-helpers */
/* */
/* ****************************************** */
generatedGraphQLFilePath = path.resolve(__dirname, '../../src/lib/graphql/generated/apollo-helpers.ts');
generatedGraphQLFile = fs.readFileSync(generatedGraphQLFilePath, 'utf8');
const addImports = format(
generatedGraphQLFile,
`import { FieldPolicy, FieldReadFunction, TypePolicies, TypePolicy } from '@apollo/client/cache';`,
`import { FieldPolicy, FieldReadFunction, TypePolicies, TypePolicy } from '@apollo/client/cache';
import { GetChaptersQuery } from "@/lib/graphql/generated/graphql.ts";`,
);
const fixTypingOfQueryTypePolicies = format(
addImports,
`export type QueryFieldPolicy = {
\tabout?: FieldPolicy<any> | FieldReadFunction<any>,
\tcategories?: FieldPolicy<any> | FieldReadFunction<any>,
\tcategory?: FieldPolicy<any> | FieldReadFunction<any>,
\tchapter?: FieldPolicy<any> | FieldReadFunction<any>,
\tchapters?: FieldPolicy<any> | FieldReadFunction<any>,
\tcheckForServerUpdates?: FieldPolicy<any> | FieldReadFunction<any>,
\tcheckForWebUIUpdate?: FieldPolicy<any> | FieldReadFunction<any>,
\tdownloadStatus?: FieldPolicy<any> | FieldReadFunction<any>,
\textension?: FieldPolicy<any> | FieldReadFunction<any>,
\textensions?: FieldPolicy<any> | FieldReadFunction<any>,
\tgetWebUIUpdateStatus?: FieldPolicy<any> | FieldReadFunction<any>,
\tlastUpdateTimestamp?: FieldPolicy<any> | FieldReadFunction<any>,
\tmanga?: FieldPolicy<any> | FieldReadFunction<any>,
\tmangas?: FieldPolicy<any> | FieldReadFunction<any>,
\tmeta?: FieldPolicy<any> | FieldReadFunction<any>,
\tmetas?: FieldPolicy<any> | FieldReadFunction<any>,
\trestoreStatus?: FieldPolicy<any> | FieldReadFunction<any>,
\tsettings?: FieldPolicy<any> | FieldReadFunction<any>,
\tsource?: FieldPolicy<any> | FieldReadFunction<any>,
\tsources?: FieldPolicy<any> | FieldReadFunction<any>,
\tupdateStatus?: FieldPolicy<any> | FieldReadFunction<any>,
\tvalidateBackup?: FieldPolicy<any> | FieldReadFunction<any>
};`,
`export type QueryFieldPolicy = {
\tabout?: FieldPolicy<any> | FieldReadFunction<any>,
\tcategories?: FieldPolicy<any> | FieldReadFunction<any>,
\tcategory?: FieldPolicy<any> | FieldReadFunction<any>,
\tchapter?: FieldPolicy<any> | FieldReadFunction<any>,
\tchapters?: FieldPolicy<GetChaptersQuery['chapters']> | FieldReadFunction<GetChaptersQuery['chapters']>,
\tcheckForServerUpdates?: FieldPolicy<any> | FieldReadFunction<any>,
\tcheckForWebUIUpdate?: FieldPolicy<any> | FieldReadFunction<any>,
\tdownloadStatus?: FieldPolicy<any> | FieldReadFunction<any>,
\textension?: FieldPolicy<any> | FieldReadFunction<any>,
\textensions?: FieldPolicy<any> | FieldReadFunction<any>,
\tgetWebUIUpdateStatus?: FieldPolicy<any> | FieldReadFunction<any>,
\tlastUpdateTimestamp?: FieldPolicy<any> | FieldReadFunction<any>,
\tmanga?: FieldPolicy<any> | FieldReadFunction<any>,
\tmangas?: FieldPolicy<any> | FieldReadFunction<any>,
\tmeta?: FieldPolicy<any> | FieldReadFunction<any>,
\tmetas?: FieldPolicy<any> | FieldReadFunction<any>,
\trestoreStatus?: FieldPolicy<any> | FieldReadFunction<any>,
\tsettings?: FieldPolicy<any> | FieldReadFunction<any>,
\tsource?: FieldPolicy<any> | FieldReadFunction<any>,
\tsources?: FieldPolicy<any> | FieldReadFunction<any>,
\tupdateStatus?: FieldPolicy<any> | FieldReadFunction<any>,
\tvalidateBackup?: FieldPolicy<any> | FieldReadFunction<any>
};`,
);
fs.writeFileSync(generatedGraphQLFilePath, fixTypingOfQueryTypePolicies);

View File

@@ -19,7 +19,7 @@
/* Bundler mode */ /* Bundler mode */
"esModuleInterop": true, "esModuleInterop": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"moduleResolution": "bundler", "moduleResolution": "node",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,

View File

@@ -8,6 +8,6 @@
}, },
"include": [ "include": [
"vite.config.ts", "vite.config.ts",
"vite.config.ts" "gql_codegen.ts"
] ]
} }

2512
yarn.lock

File diff suppressed because it is too large Load Diff