Save reader settings per manga in Meta (#216)

* [#213] Add missing "meta" property

- IManga
- IMangaCard (optional)
- IChapter
- ICategory

* [#213] Add util functions for handling metadata

- global server
- manga
- chapter
- category

* [#213] Use "ReaderSettings" from the manga metadata

- get ReaderSettings from manga metadata
- remove unnecessary check "make sure settings has all the keys"
    in case the stored settings in the metadata are outdated they will be filled with default values

* [#213] Reload manga only in case "mangaId" changed

In case the chapter is still from the same manga, a reload is unnecessary

* [#213] Only update the changed reader setting

Otherwise the app has to send patch requests to the server for every setting even if it didn't change

* [#213] Hide "openButton" on first render in case "navBar" is shown

The "openButton" was always set to be visible event in case the navBar was shown on the first render

* [#213] Open the "ReaderNavBar" if needed after receiving new settings

Otherwise, the drawer won't open in case the "navBar" was hidden on the first render since the default state in that case was set to false

* [#213] Update "ReaderSettings" after receiving the manga response

Otherwise, the default settings aren't getting removed

* [#213] Hide/Show "OpenButton" when opening/closing the drawer

Otherwise, the button stays hidden until it gets updated due to scrolling

* [#213] Keep "ReaderNavBar" state when opening prev/next chapter

In case the navBar wasn't sticky but opened, it got closed when the prev/next chapter got opened

* [#213] Prevent "ReaderNavBar" from closing when changing "staticNav" setting
This commit is contained in:
Daniel
2023-01-06 17:15:29 +01:00
committed by GitHub
parent 34bafee3aa
commit 46ac5d5531
4 changed files with 268 additions and 60 deletions

View File

@@ -13,7 +13,7 @@ import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { useHistory, Link } from 'react-router-dom'; import { useHistory, Link, useLocation } from 'react-router-dom';
import Slide from '@mui/material/Slide'; import Slide from '@mui/material/Slide';
import Fade from '@mui/material/Fade'; import Fade from '@mui/material/Fade';
import Zoom from '@mui/material/Zoom'; import Zoom from '@mui/material/Zoom';
@@ -28,6 +28,7 @@ import Collapse from '@mui/material/Collapse';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import { styled } from '@mui/system'; import { styled } from '@mui/system';
import useBackTo from 'util/useBackTo'; import useBackTo from 'util/useBackTo';
import { getMetadataFrom } from 'util/metadata';
const Root = styled('div')(({ theme }) => ({ const Root = styled('div')(({ theme }) => ({
top: 0, top: 0,
@@ -105,7 +106,7 @@ const OpenDrawerButton = styled(IconButton)(({ theme }) => ({
}, },
})); }));
export const defaultReaderSettings = () => ({ const defaultReaderSettings = () => ({
staticNav: false, staticNav: false,
showPageNumber: true, showPageNumber: true,
continuesPageGap: false, continuesPageGap: false,
@@ -113,9 +114,22 @@ export const defaultReaderSettings = () => ({
readerType: 'ContinuesVertical', readerType: 'ContinuesVertical',
} as IReaderSettings); } as IReaderSettings);
const getReaderSettingsFromMetadata = (
meta?: IMetadata,
): IReaderSettings => ({
...getMetadataFrom(
{ meta },
Object.entries(defaultReaderSettings()) as MetadataKeyValuePair[],
) as unknown as IReaderSettings,
});
export const getReaderSettingsFor = (
{ meta }: IMangaCard | IManga,
): IReaderSettings => getReaderSettingsFromMetadata(meta);
interface IProps { interface IProps {
settings: IReaderSettings settings: IReaderSettings
setSettings: React.Dispatch<React.SetStateAction<IReaderSettings>> setSettingValue: (key: keyof IReaderSettings, value: string | boolean) => void
manga: IManga | IMangaCard manga: IManga | IMangaCard
chapter: IChapter chapter: IChapter
curPage: number curPage: number
@@ -124,17 +138,37 @@ interface IProps {
export default function ReaderNavBar(props: IProps) { export default function ReaderNavBar(props: IProps) {
const history = useHistory(); const history = useHistory();
const backTo = useBackTo(); const backTo = useBackTo();
const location = useLocation<{
prevDrawerOpen?: boolean,
prevSettingsCollapseOpen?: boolean
}>();
const {
prevDrawerOpen,
prevSettingsCollapseOpen,
} = location.state ?? {};
const { const {
settings, setSettings, manga, chapter, curPage, settings, setSettingValue, manga, chapter, curPage,
} = props; } = props;
const [drawerOpen, setDrawerOpen] = useState(false || settings.staticNav); const [drawerOpen, setDrawerOpen] = useState(settings.staticNav || prevDrawerOpen);
const [hideOpenButton, setHideOpenButton] = useState(false); const [updateDrawerOnRender, setUpdateDrawerOnRender] = useState(true);
const [hideOpenButton, setHideOpenButton] = useState(settings.staticNav || prevDrawerOpen);
const [prevScrollPos, setPrevScrollPos] = useState(0); const [prevScrollPos, setPrevScrollPos] = useState(0);
const [settingsCollapseOpen, setSettingsCollapseOpen] = useState(true); const [settingsCollapseOpen, setSettingsCollapseOpen] = useState(
prevSettingsCollapseOpen ?? true,
);
const setSettingValue = (key: string, value: any) => setSettings({ ...settings, [key]: value }); const updateSettingValue = (key: keyof IReaderSettings, value: string | boolean) => {
// prevent closing the navBar when updating the "staticNav" setting
setUpdateDrawerOnRender(key !== 'staticNav');
setSettingValue(key, value);
};
const updateDrawer = (open: boolean) => {
setDrawerOpen(open);
setHideOpenButton(open);
};
const handleScroll = () => { const handleScroll = () => {
const currentScrollPos = window.pageYOffset; const currentScrollPos = window.pageYOffset;
@@ -145,6 +179,12 @@ export default function ReaderNavBar(props: IProps) {
} }
}; };
useEffect(() => {
if (updateDrawerOnRender) {
updateDrawer(settings.staticNav);
}
}, [settings.staticNav]);
useEffect(() => { useEffect(() => {
window.addEventListener('scroll', handleScroll); window.addEventListener('scroll', handleScroll);
@@ -190,7 +230,7 @@ export default function ReaderNavBar(props: IProps) {
color="inherit" color="inherit"
aria-label="menu" aria-label="menu"
disableRipple disableRipple
onClick={() => setDrawerOpen(false)} onClick={() => updateDrawer(false)}
size="large" size="large"
> >
<KeyboardArrowLeftIcon /> <KeyboardArrowLeftIcon />
@@ -243,7 +283,7 @@ export default function ReaderNavBar(props: IProps) {
<Switch <Switch
edge="end" edge="end"
checked={settings.staticNav} checked={settings.staticNav}
onChange={(e) => setSettingValue('staticNav', e.target.checked)} onChange={(e) => updateSettingValue('staticNav', e.target.checked)}
/> />
</ListItemSecondaryAction> </ListItemSecondaryAction>
</ListItem> </ListItem>
@@ -253,7 +293,7 @@ export default function ReaderNavBar(props: IProps) {
<Switch <Switch
edge="end" edge="end"
checked={settings.showPageNumber} checked={settings.showPageNumber}
onChange={(e) => setSettingValue('showPageNumber', e.target.checked)} onChange={(e) => updateSettingValue('showPageNumber', e.target.checked)}
/> />
</ListItemSecondaryAction> </ListItemSecondaryAction>
</ListItem> </ListItem>
@@ -263,7 +303,7 @@ export default function ReaderNavBar(props: IProps) {
<Switch <Switch
edge="end" edge="end"
checked={settings.loadNextonEnding} checked={settings.loadNextonEnding}
onChange={(e) => setSettingValue('loadNextonEnding', e.target.checked)} onChange={(e) => updateSettingValue('loadNextonEnding', e.target.checked)}
/> />
</ListItemSecondaryAction> </ListItemSecondaryAction>
</ListItem> </ListItem>
@@ -272,7 +312,7 @@ export default function ReaderNavBar(props: IProps) {
<Select <Select
variant="standard" variant="standard"
value={settings.readerType} value={settings.readerType}
onChange={(e) => setSettingValue('readerType', e.target.value)} onChange={(e) => updateSettingValue('readerType', e.target.value)}
sx={{ p: 0 }} sx={{ p: 0 }}
> >
<MenuItem value="SingleLTR"> <MenuItem value="SingleLTR">
@@ -313,35 +353,47 @@ export default function ReaderNavBar(props: IProps) {
</span> </span>
<ChapterNavigation> <ChapterNavigation>
{chapter.index > 1 {chapter.index > 1
&& ( && (
<Link <Link
replace replace
to={{ pathname: `/manga/${manga.id}/chapter/${chapter.index - 1}`, state: history.location.state }} to={{
> pathname: `/manga/${manga.id}/chapter/${chapter.index - 1}`,
<Button state: {
variant="outlined" prevDrawerOpen: drawerOpen,
sx={{ gridArea: 'prev' }} prevSettingsCollapseOpen: settingsCollapseOpen,
startIcon={<KeyboardArrowLeftIcon />} },
}}
> >
Prev. Chapter <Button
</Button> variant="outlined"
</Link> sx={{ gridArea: 'prev' }}
)} startIcon={<KeyboardArrowLeftIcon />}
>
Prev. Chapter
</Button>
</Link>
)}
{chapter.index < chapter.chapterCount {chapter.index < chapter.chapterCount
&& ( && (
<Link <Link
replace replace
style={{ gridArea: 'next' }} style={{ gridArea: 'next' }}
to={{ pathname: `/manga/${manga.id}/chapter/${chapter.index + 1}`, state: history.location.state }} to={{
> pathname: `/manga/${manga.id}/chapter/${chapter.index + 1}`,
<Button state: {
variant="outlined" prevDrawerOpen: drawerOpen,
endIcon={<KeyboardArrowRightIcon />} prevSettingsCollapseOpen: settingsCollapseOpen,
},
}}
> >
Next Chapter <Button
</Button> variant="outlined"
</Link> endIcon={<KeyboardArrowRightIcon />}
)} >
Next Chapter
</Button>
</Link>
)}
</ChapterNavigation> </ChapterNavigation>
</Navigation> </Navigation>
</Root> </Root>
@@ -353,7 +405,7 @@ export default function ReaderNavBar(props: IProps) {
aria-label="menu" aria-label="menu"
disableRipple disableRipple
disableFocusRipple disableFocusRipple
onClick={() => setDrawerOpen(true)} onClick={() => updateDrawer(true)}
size="large" size="large"
> >
<KeyboardArrowRightIcon /> <KeyboardArrowRightIcon />

View File

@@ -15,12 +15,13 @@ import PageNumber from 'components/reader/PageNumber';
import PagedPager from 'components/reader/pager/PagedPager'; import PagedPager from 'components/reader/pager/PagedPager';
import DoublePagedPager from 'components/reader/pager/DoublePagedPager'; import DoublePagedPager from 'components/reader/pager/DoublePagedPager';
import VerticalPager from 'components/reader/pager/VerticalPager'; import VerticalPager from 'components/reader/pager/VerticalPager';
import ReaderNavBar, { defaultReaderSettings } from 'components/navbar/ReaderNavBar'; import ReaderNavBar, { getReaderSettingsFor } from 'components/navbar/ReaderNavBar';
import NavbarContext from 'components/context/NavbarContext'; import NavbarContext from 'components/context/NavbarContext';
import client from 'util/client'; import client from 'util/client';
import useLocalStorage from 'util/useLocalStorage'; import useLocalStorage from 'util/useLocalStorage';
import cloneObject from 'util/cloneObject';
import { Box } from '@mui/system'; import { Box } from '@mui/system';
import { requestUpdateMangaMetadata } from 'util/metadata';
import makeToast from '../components/util/Toast';
const getReaderComponent = (readerType: ReaderType) => { const getReaderComponent = (readerType: ReaderType) => {
switch (readerType) { switch (readerType) {
@@ -57,8 +58,6 @@ const initialChapter = () => ({
}); });
export default function Reader() { export default function Reader() {
const [settings, setSettings] = useLocalStorage<IReaderSettings>('readerSettings', defaultReaderSettings);
const history = useHistory(); const history = useHistory();
const [serverAddress] = useLocalStorage<String>('serverBaseURL', ''); const [serverAddress] = useLocalStorage<String>('serverBaseURL', '');
@@ -69,20 +68,14 @@ export default function Reader() {
const [curPage, setCurPage] = useState<number>(0); const [curPage, setCurPage] = useState<number>(0);
const { setOverride, setTitle } = useContext(NavbarContext); const { setOverride, setTitle } = useContext(NavbarContext);
useEffect(() => { const [settings, setSettings] = useState(getReaderSettingsFor(manga));
// make sure settings has all the keys
const settingsClone = cloneObject(settings) as any;
const defualtSettings = defaultReaderSettings();
let shouldUpdateSettings = false;
Object.keys(defualtSettings).forEach((key) => {
const keyOf = key as keyof IReaderSettings;
if (settings[keyOf] === undefined) {
settingsClone[keyOf] = defualtSettings[keyOf];
shouldUpdateSettings = true;
}
});
if (shouldUpdateSettings) { setSettings(settingsClone); }
const setSettingValue = (key: keyof IReaderSettings, value: string | boolean) => {
setSettings({ ...settings, [key]: value });
requestUpdateMangaMetadata(manga, [[key, value]]).catch(() => makeToast('Failed to save the reader settings to the server', 'warning'));
};
useEffect(() => {
// set the custom navbar // set the custom navbar
setOverride( setOverride(
{ {
@@ -90,7 +83,7 @@ export default function Reader() {
value: ( value: (
<ReaderNavBar <ReaderNavBar
settings={settings} settings={settings}
setSettings={setSettings} setSettingValue={setSettingValue}
manga={manga} manga={manga}
chapter={chapter as IChapter} chapter={chapter as IChapter}
curPage={curPage} curPage={curPage}
@@ -110,8 +103,9 @@ export default function Reader() {
.then((data: IManga) => { .then((data: IManga) => {
setManga(data); setManga(data);
setTitle(data.title); setTitle(data.title);
setSettings(getReaderSettingsFor(data));
}); });
}, [chapterIndex]); }, [mangaId]);
useEffect(() => { useEffect(() => {
setChapter(initialChapter); setChapter(initialChapter);

22
src/typings.d.ts vendored
View File

@@ -54,6 +54,22 @@ interface IState {
index: number index: number
} }
interface IMetadata<VALUES extends AllowedMetadataValueTypes = string> {
[key: string]: VALUES;
}
interface IMetadataHolder<VALUES extends AllowedMetadataValueTypes = string> {
meta?: IMetadata<VALUES>
}
type AllowedMetadataValueTypes = string | boolean | number | undefined;
type MangaMetadataKeys = keyof IReaderSettings;
type AppMetadataKeys = MangaMetadataKeys;
type MetadataKeyValuePair = [AppMetadataKeys, AllowedMetadataValueTypes];
interface IMangaCard { interface IMangaCard {
id: number id: number
title: string title: string
@@ -61,6 +77,7 @@ interface IMangaCard {
unreadCount?: number unreadCount?: number
downloadCount?: number downloadCount?: number
inLibrary?: boolean inLibrary?: boolean
meta?: IMetadata
} }
interface IManga { interface IManga {
@@ -80,8 +97,9 @@ interface IManga {
inLibrary: boolean inLibrary: boolean
source: ISource source: ISource
realUrl: string meta: IMetadata
realUrl: string
freshData: boolean freshData: boolean
unreadCount?: number unreadCount?: number
downloadCount?: number downloadCount?: number
@@ -107,6 +125,7 @@ interface IChapter {
chapterCount: number chapterCount: number
pageCount: number pageCount: number
downloaded: boolean downloaded: boolean
meta: IAppMetadata
} }
interface IMangaChapter { interface IMangaChapter {
@@ -126,6 +145,7 @@ interface ICategory {
order: number order: number
name: string name: string
default: boolean default: boolean
meta: IAppMetadata
} }
interface INavbarOverride { interface INavbarOverride {

142
src/util/metadata.ts Normal file
View File

@@ -0,0 +1,142 @@
/*
* 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 { mutate } from 'swr';
import client from './client';
const APP_METADATA_KEY_PREFIX = 'webUI_';
const getMetadataKey = (key: string) => `${APP_METADATA_KEY_PREFIX}${key}`;
const convertValueFromMetadata = <
T extends AllowedMetadataValueTypes = AllowedMetadataValueTypes,
>(
value: string,
): T => {
if (!Number.isNaN(+value)) {
return +value as T;
}
if (value === 'true' || value === 'false') {
return (value === 'true') as T;
}
if (value === 'undefined') {
return undefined as T;
}
return value as T;
};
export const getMetadataValueFrom = <
T extends AllowedMetadataValueTypes = AllowedMetadataValueTypes,
>(
{ meta }: IMetadataHolder,
key: AppMetadataKeys,
defaultValue?: T,
): T | undefined => {
const metadataKey = getMetadataKey(key);
const isMissingKey = !Object.prototype.hasOwnProperty.call(meta ?? {}, metadataKey);
if (meta === undefined || isMissingKey) {
return defaultValue;
}
return convertValueFromMetadata(meta[metadataKey]);
};
export const getMetadataFrom = (
{ meta }: IMetadataHolder,
keysToDefaultValues: MetadataKeyValuePair[],
): IMetadata<AllowedMetadataValueTypes> => {
const appMetadata: IMetadata<AllowedMetadataValueTypes> = {};
keysToDefaultValues.forEach(([key, defaultValue]) => {
appMetadata[key] = getMetadataValueFrom({ meta }, key, defaultValue);
});
return appMetadata;
};
const wrapMetadataWithMetaKey = (wrap: boolean, metadata: IMetadata): IMetadataHolder => {
if (wrap) {
return {
meta: {
...metadata,
},
};
}
return {
...metadata,
};
};
export const requestUpdateMetadataValue = async (
endpoint: string,
metadataHolder: IMetadataHolder,
key: AppMetadataKeys,
value: AllowedMetadataValueTypes,
endpointToMutate: string = endpoint,
wrapWithMetaKey: boolean = true,
): Promise<void> => {
const restApiVersion = '/api/v1';
const url = `${restApiVersion}${endpoint}/meta`;
const urlToMutate = `${restApiVersion}${endpointToMutate}`;
const metadataKey = getMetadataKey(key);
const valueAsString = `${value}`;
const formData = new FormData();
formData.append('key', metadataKey);
formData.append('value', valueAsString);
const mutatedMetadata = {
...metadataHolder.meta,
[metadataKey]: valueAsString,
};
await client.patch(url, formData);
await mutate(
urlToMutate,
{ ...metadataHolder, ...wrapMetadataWithMetaKey(wrapWithMetaKey, mutatedMetadata) },
{ revalidate: false },
);
};
export const requestUpdateMetadata = async (
endpoint: string,
metadataHolder: IMetadataHolder,
keysToValues: [AppMetadataKeys, AllowedMetadataValueTypes][],
endpointToMutate?: string,
wrapWithMetaKey?: boolean,
): Promise<void[]> => Promise.all(keysToValues.map(
([key, value]) => requestUpdateMetadataValue(
endpoint, metadataHolder, key, value, endpointToMutate, wrapWithMetaKey,
),
));
export const requestUpdateServerMetadata = async (
serverMetadata: IMetadata,
keysToValues: MetadataKeyValuePair[],
): Promise<void[]> => requestUpdateMetadata('', { meta: serverMetadata }, keysToValues, '/meta', false);
export const requestUpdateMangaMetadata = async (
manga: IMangaCard | IManga,
keysToValues: MetadataKeyValuePair[],
): Promise<void[]> => requestUpdateMetadata(`/manga/${manga.id}`, manga, keysToValues);
export const requestUpdateChapterMetadata = async (
mangaChapter: IMangaChapter,
keysToValues: MetadataKeyValuePair[],
): Promise<void[]> => requestUpdateMetadata(`/manga/${mangaChapter.manga.id}/chapter/${mangaChapter.chapter.index}`, mangaChapter.chapter, keysToValues);
export const requestUpdateCategoryMetadata = async (
category: ICategory,
keysToValues: MetadataKeyValuePair[],
): Promise<void[]> => requestUpdateMetadata(`/category/${category.id}`, category, keysToValues);