Feature/improve restoring grid snapshots (#713)

* Update local storage functions

* Update local storage hooks

* Add session storage

* Improve storing virtuoso grid snapshot

* Use "useSyncExternalStore" for browser storage access

* Get rid of location replacements in "AppbarSearch"

Use session storage instead to prevent reloading the page all the time due to changing the location

* Get rid of location replacements in "SourceMangas"

Use session storage instead to prevent reloading the page all the time due to changing the location
This commit is contained in:
schroda
2024-04-07 15:22:03 +02:00
committed by GitHub
parent ea2f9dc5a4
commit aab3e25031
20 changed files with 220 additions and 190 deletions

View File

@@ -10,15 +10,16 @@ import React, { ForwardedRef, forwardRef, useEffect, useLayoutEffect, useMemo, u
import Grid, { GridTypeMap } from '@mui/material/Grid'; import Grid, { GridTypeMap } from '@mui/material/Grid';
import { Box, Typography } from '@mui/material'; import { Box, Typography } from '@mui/material';
import { GridItemProps, GridStateSnapshot, VirtuosoGrid } from 'react-virtuoso'; import { GridItemProps, GridStateSnapshot, VirtuosoGrid } from 'react-virtuoso';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
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, MangaCardProps } from '@/components/MangaCard'; import { MangaCard, MangaCardProps } from '@/components/MangaCard';
import { GridLayout } from '@/components/context/LibraryOptionsContext'; import { GridLayout } from '@/components/context/LibraryOptionsContext';
import { useLocalStorage } from '@/util/useLocalStorage'; import { useLocalStorage, useSessionStorage } from '@/util/useStorage.tsx';
import { TManga, TPartialManga } from '@/typings.ts'; import { TManga, TPartialManga } from '@/typings.ts';
import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts'; import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts';
import { DEFAULT_FULL_FAB_HEIGHT } from '@/components/util/StyledFab.tsx'; import { DEFAULT_FULL_FAB_HEIGHT } from '@/components/util/StyledFab.tsx';
import { AppStorage } from '@/util/AppStorage.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' }}>
@@ -144,8 +145,9 @@ const VerticalGrid = forwardRef(
ref: ForwardedRef<HTMLDivElement | null>, ref: ForwardedRef<HTMLDivElement | null>,
) => { ) => {
const location = useLocation<{ snapshot?: GridStateSnapshot }>(); const location = useLocation<{ snapshot?: GridStateSnapshot }>();
const navigate = useNavigate();
const { snapshot } = location.state ?? {}; const snapshotSessionKey = `MangaGrid-snapshot-location-${location.key}`;
const [snapshot] = useSessionStorage<GridStateSnapshot | undefined>(snapshotSessionKey, undefined);
const persistGridStateTimeout = useRef<NodeJS.Timeout | undefined>(); const persistGridStateTimeout = useRef<NodeJS.Timeout | undefined>();
const persistGridState = (gridState: GridStateSnapshot) => { const persistGridState = (gridState: GridStateSnapshot) => {
@@ -158,10 +160,7 @@ const VerticalGrid = forwardRef(
return; return;
} }
navigate( AppStorage.session.setItem(snapshotSessionKey, gridState);
{ pathname: '', search: location.search },
{ replace: true, state: { ...location.state, snapshot: gridState } },
);
}, 250); }, 250);
}; };
useEffect(() => clearTimeout(persistGridStateTimeout.current), [location.key, persistGridStateTimeout.current]); useEffect(() => clearTimeout(persistGridStateTimeout.current), [location.key, persistGridStateTimeout.current]);

View File

@@ -15,7 +15,7 @@ import {
TChapter, TChapter,
TranslationKey, TranslationKey,
} from '@/typings.ts'; } from '@/typings.ts';
import { useReducerLocalStorage } from '@/util/useLocalStorage.tsx'; import { useReducerLocalStorage } from '@/util/useStorage.tsx';
const defaultChapterOptions: ChapterListOptions = { const defaultChapterOptions: ChapterListOptions = {
active: false, active: false,

View File

@@ -12,7 +12,7 @@ import { BrowserRouter as Router } from 'react-router-dom';
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';
import { useLocalStorage } from '@/util/useLocalStorage'; import { useLocalStorage } from '@/util/useStorage.tsx';
import { DarkTheme } from '@/components/context/DarkTheme'; import { DarkTheme } from '@/components/context/DarkTheme';
import { NavBarContextProvider } from '@/components/navbar/NavBarContextProvider'; import { NavBarContextProvider } from '@/components/navbar/NavBarContextProvider';
import { LibraryOptionsContextProvider } from '@/components/library/LibraryOptionsProvider'; import { LibraryOptionsContextProvider } from '@/components/library/LibraryOptionsProvider';

View File

@@ -8,7 +8,7 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { LibraryOptions } from '@/typings'; import { LibraryOptions } from '@/typings';
import { useLocalStorage } from '@/util/useLocalStorage'; import { useLocalStorage } from '@/util/useStorage.tsx';
import { LibraryOptionsContext, DefaultLibraryOptions } from '@/components/context/LibraryOptionsContext'; import { LibraryOptionsContext, DefaultLibraryOptions } from '@/components/context/LibraryOptionsContext';
interface IProps { interface IProps {

View File

@@ -16,7 +16,7 @@ import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { requestManager } from '@/lib/requests/RequestManager.ts'; import { requestManager } from '@/lib/requests/RequestManager.ts';
import { getVersion } from '@/screens/settings/About.tsx'; import { getVersion } from '@/screens/settings/About.tsx';
import { useLocalStorage } from '@/util/useLocalStorage.tsx'; import { useLocalStorage } from '@/util/useStorage.tsx';
import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts'; import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts';
const UPDATE_CHECK_INTERVAL = 1000 * 60 * 60 * 24; // 1 day const UPDATE_CHECK_INTERVAL = 1000 * 60 * 60 * 24; // 1 day

View File

@@ -11,35 +11,44 @@ import SearchIcon from '@mui/icons-material/Search';
import { IconButton, Tooltip } from '@mui/material'; import { IconButton, Tooltip } from '@mui/material';
import { useQueryParam, StringParam } from 'use-query-params'; import { useQueryParam, StringParam } from 'use-query-params';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { SearchTextField } from '@/components/atoms/SearchTextField.tsx'; import { SearchTextField } from '@/components/atoms/SearchTextField.tsx';
import { useSessionStorage } from '@/util/useStorage.tsx';
interface IProps { interface IProps {
autoOpen?: boolean; isClosable?: boolean;
} }
const defaultProps = {
autoOpen: false,
};
export const AppbarSearch: React.FunctionComponent<IProps> = (props) => { export const AppbarSearch: React.FunctionComponent<IProps> = (props) => {
const { autoOpen } = props; const { isClosable = true } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const { pathname, search: locationSearch, state: fullLocationState } = useLocation<{ wasSearchOpen?: boolean }>(); const [prevLocationKey, setPrevLocationKey] = useState<string>();
const { wasSearchOpen, ...locationState } = fullLocationState ?? {}; const location = useLocation();
const navigate = useNavigate();
const [query, setQuery] = useQueryParam('query', StringParam); const [query, setQuery] = useQueryParam('query', StringParam);
const [searchOpen, setSearchOpen] = useState(!!query); const [isSearchOpen, setIsSearchOpen] = useState(!isClosable || !!query);
const inputRef = React.useRef<HTMLInputElement>(); const inputRef = React.useRef<HTMLInputElement>();
const [searchString, setSearchString] = useState(query ?? ''); const [searchString, setSearchString] = useState(query ?? '');
const [locationQuery, setLocationQuery] = useSessionStorage<string | null>(`appbarsearch-location-${location.key}`);
if (prevLocationKey !== location.key) {
setPrevLocationKey(location.key);
setLocationQuery(query);
setSearchString(locationQuery ?? '');
setIsSearchOpen(!isClosable || !!locationQuery);
}
const isOpen = isSearchOpen || !!locationQuery;
const updateSearchOpenState = (open: boolean) => { const updateSearchOpenState = (open: boolean) => {
setSearchOpen(open); if (!isClosable) {
return;
}
setIsSearchOpen(open);
// try to focus input component since in case of navigating to the previous/next page in the browser history // try to focus input component since in case of navigating to the previous/next page in the browser history
// the "openSearch" state might not change and thus, won't trigger a focus // the "openSearch" state might not change and thus, won't trigger a focus
@@ -54,6 +63,7 @@ export const AppbarSearch: React.FunctionComponent<IProps> = (props) => {
} }
setQuery(newQuery); setQuery(newQuery);
updateSearchOpenState(false);
} }
const cancelSearch = () => { const cancelSearch = () => {
@@ -72,46 +82,6 @@ export const AppbarSearch: React.FunctionComponent<IProps> = (props) => {
} }
}; };
useEffect(() => {
if ((autoOpen && wasSearchOpen === undefined) || (wasSearchOpen && query)) {
updateSearchOpenState(true);
return;
}
updateSearchOpenState(false);
}, [autoOpen, pathname]);
useEffect(() => {
if (!searchOpen || !inputRef.current) {
return;
}
inputRef.current.focus();
}, [searchOpen, inputRef.current]);
useEffect(() => {
if (wasSearchOpen === searchOpen) {
return;
}
navigate(
{ pathname, search: locationSearch },
{ replace: true, state: { ...locationState, wasSearchOpen: searchOpen } },
);
}, [searchOpen]);
useEffect(() => {
if (query === undefined && searchString !== undefined) {
setSearchString('');
return;
}
if (query && searchString !== query) {
setSearchString(query);
updateSearchOpenState(true);
}
}, [query]);
useEffect(() => { useEffect(() => {
window.addEventListener('keydown', handleKeyboardEvent); window.addEventListener('keydown', handleKeyboardEvent);
@@ -120,9 +90,10 @@ export const AppbarSearch: React.FunctionComponent<IProps> = (props) => {
}; };
}, [handleKeyboardEvent]); }, [handleKeyboardEvent]);
if (searchOpen) { if (isOpen) {
return ( return (
<SearchTextField <SearchTextField
autoFocus
variant="standard" variant="standard"
value={searchString} value={searchString}
onCancel={cancelSearch} onCancel={cancelSearch}
@@ -146,5 +117,3 @@ export const AppbarSearch: React.FunctionComponent<IProps> = (props) => {
</Tooltip> </Tooltip>
); );
}; };
AppbarSearch.defaultProps = defaultProps;

View File

@@ -6,7 +6,7 @@
* 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 * as storage from '@/util/localStorage.tsx'; import { AppStorage } from '@/util/AppStorage.ts';
export abstract class BaseClient<Client, ClientConfig, Fetcher> { export abstract class BaseClient<Client, ClientConfig, Fetcher> {
protected client!: Client; protected client!: Client;
@@ -22,7 +22,7 @@ export abstract class BaseClient<Client, ClientConfig, Fetcher> {
// if port is 3000 it's probably running from webpack development server // if port is 3000 it's probably running from webpack development server
const inferredPort = port === '3000' ? '4567' : port; const inferredPort = port === '3000' ? '4567' : port;
return storage.getItem('serverBaseURL', `${protocol}//${hostname}:${inferredPort}`); return AppStorage.local.getItemParsed('serverBaseURL', `${protocol}//${hostname}:${inferredPort}`);
} }
protected abstract createClient(): void; protected abstract createClient(): void;

View File

@@ -17,7 +17,7 @@ import { useTheme } from '@mui/material/styles';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { requestManager } from '@/lib/requests/RequestManager.ts'; import { requestManager } from '@/lib/requests/RequestManager.ts';
import { extensionDefaultLangs, DefaultLanguage, langSortCmp } from '@/util/language'; import { extensionDefaultLangs, DefaultLanguage, langSortCmp } from '@/util/language';
import { useLocalStorage } from '@/util/useLocalStorage'; import { useLocalStorage } from '@/util/useStorage.tsx';
import { import {
ExtensionState, ExtensionState,
GroupedExtensions, GroupedExtensions,

View File

@@ -18,7 +18,7 @@ import { EmptyView } from '@/components/util/EmptyView.tsx';
import { MangaGrid } from '@/components/MangaGrid.tsx'; import { MangaGrid } from '@/components/MangaGrid.tsx';
import { TPartialManga } from '@/typings.ts'; import { TPartialManga } from '@/typings.ts';
import { GridLayouts } from '@/components/source/GridLayouts.tsx'; import { GridLayouts } from '@/components/source/GridLayouts.tsx';
import { useLocalStorage } from '@/util/useLocalStorage.tsx'; import { useLocalStorage } from '@/util/useStorage.tsx';
import { GridLayout } from '@/components/context/LibraryOptionsContext.tsx'; import { GridLayout } from '@/components/context/LibraryOptionsContext.tsx';
export const Migrate = () => { export const Migrate = () => {

View File

@@ -13,7 +13,7 @@ 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/requests/RequestManager.ts'; import { requestManager } from '@/lib/requests/RequestManager.ts';
import { useLocalStorage } from '@/util/useLocalStorage'; import { useLocalStorage } from '@/util/useStorage.tsx';
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';
import { AppbarSearch } from '@/components/util/AppbarSearch'; import { AppbarSearch } from '@/components/util/AppbarSearch';
@@ -214,7 +214,7 @@ export const SearchAll: React.FC = () => {
setTitle(t(isMigrateMode ? 'migrate.search.title' : 'search.title.global_search', { title: mangaTitle })); setTitle(t(isMigrateMode ? 'migrate.search.title' : 'search.title.global_search', { title: mangaTitle }));
setAction( setAction(
<> <>
<AppbarSearch autoOpen /> <AppbarSearch isClosable={false} />
<LangSelect <LangSelect
shownLangs={shownLangs} shownLangs={shownLangs}
setShownLangs={setShownLangs} setShownLangs={setShownLangs}

View File

@@ -30,7 +30,7 @@ import ExploreOutlinedIcon from '@mui/icons-material/ExploreOutlined';
import DevicesIcon from '@mui/icons-material/Devices'; import DevicesIcon from '@mui/icons-material/Devices';
import SyncIcon from '@mui/icons-material/Sync'; import SyncIcon from '@mui/icons-material/Sync';
import { langCodeToName } from '@/util/language'; import { langCodeToName } from '@/util/language';
import { useLocalStorage } from '@/util/useLocalStorage'; import { useLocalStorage } from '@/util/useStorage.tsx';
import { ListItemLink } from '@/components/util/ListItemLink'; import { ListItemLink } from '@/components/util/ListItemLink';
import { DarkTheme } from '@/components/context/DarkTheme'; import { DarkTheme } from '@/components/context/DarkTheme';
import { NavBarContext } from '@/components/context/NavbarContext.tsx'; import { NavBarContext } from '@/components/context/NavbarContext.tsx';

View File

@@ -23,7 +23,6 @@ import {
AbortableApolloUseMutationPaginatedResponse, AbortableApolloUseMutationPaginatedResponse,
SPECIAL_ED_SOURCES, SPECIAL_ED_SOURCES,
} from '@/lib/requests/RequestManager.ts'; } from '@/lib/requests/RequestManager.ts';
import { useDebounce } from '@/util/useDebounce.ts';
import { useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext'; import { useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
import { SourceGridLayout } from '@/components/source/SourceGridLayout'; import { SourceGridLayout } from '@/components/source/SourceGridLayout';
import { AppbarSearch } from '@/components/util/AppbarSearch'; import { AppbarSearch } from '@/components/util/AppbarSearch';
@@ -35,6 +34,7 @@ import {
} from '@/lib/graphql/generated/graphql.ts'; } from '@/lib/graphql/generated/graphql.ts';
import { NavBarContext, useSetDefaultBackTo } from '@/components/context/NavbarContext.tsx'; import { NavBarContext, useSetDefaultBackTo } from '@/components/context/NavbarContext.tsx';
import { useMetadataServerSettings } from '@/lib/metadata/metadataServerSettings.ts'; import { useMetadataServerSettings } from '@/lib/metadata/metadataServerSettings.ts';
import { useSessionStorage } from '@/util/useStorage.tsx';
const ContentTypeMenu = styled('div')(({ theme }) => ({ const ContentTypeMenu = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
@@ -215,16 +215,11 @@ export function SourceMangas() {
const { sourceId } = useParams<{ sourceId: string }>(); const { sourceId } = useParams<{ sourceId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { search } = useLocation(); const { key: locationKey, state: locationState } = useLocation();
const { const { contentType: initialContentType = SourceContentType.POPULAR, clearCache = false } =
contentType: currentLocationContentType = SourceContentType.POPULAR, useLocation<{
filtersToApply: currentLocationFiltersToApply = [],
clearCache = false,
} = useLocation<{
contentType: SourceContentType; contentType: SourceContentType;
filtersToApply: IPos[];
clearCache: boolean; clearCache: boolean;
search: string;
}>().state ?? {}; }>().state ?? {};
useSetDefaultBackTo('sources'); useSetDefaultBackTo('sources');
@@ -241,13 +236,19 @@ export function SourceMangas() {
const { options } = useLibraryOptionsContext(); const { options } = useLibraryOptionsContext();
const [query] = useQueryParam('query', StringParam); const [query] = useQueryParam('query', StringParam);
const [dialogFiltersToApply, setDialogFiltersToApply] = useState<IPos[]>(currentLocationFiltersToApply); const [filtersToApply, setFiltersToApply] = useSessionStorage<IPos[]>(
const [filtersToApply, setFiltersToApply] = useState<IPos[]>(currentLocationFiltersToApply); `source-mangas-location-${locationKey}-${sourceId}-filters`,
const searchTerm = useDebounce(query, 1000); [],
);
const [dialogFiltersToApply, setDialogFiltersToApply] = useState<IPos[]>(filtersToApply);
const [resetScrollPosition, setResetScrollPosition] = useState(false); const [resetScrollPosition, setResetScrollPosition] = useState(false);
const [contentType, setContentType] = useState(currentLocationContentType); const [contentType, setContentType] = useSessionStorage(
`source-mangas-location-${locationKey}-${sourceId}-content-type`,
query ? SourceContentType.SEARCH : initialContentType,
);
const [loadPage, { data, isLoading: loading, size: lastPageNum, abortRequest, filteredOutAllItemsOfFetchedPage }] = const [loadPage, { data, isLoading: loading, size: lastPageNum, abortRequest, filteredOutAllItemsOfFetchedPage }] =
useSourceManga(sourceId, contentType, searchTerm, filtersToApply, 1, hideLibraryEntries); useSourceManga(sourceId, contentType, query, filtersToApply, 1, hideLibraryEntries);
const isLoading = loading || filteredOutAllItemsOfFetchedPage; const isLoading = loading || filteredOutAllItemsOfFetchedPage;
const mangas = data?.fetchSourceManga.mangas ?? []; const mangas = data?.fetchSourceManga.mangas ?? [];
const hasNextPage = data?.fetchSourceManga.hasNextPage ?? false; const hasNextPage = data?.fetchSourceManga.hasNextPage ?? false;
@@ -268,50 +269,36 @@ export function SourceMangas() {
) : undefined; ) : undefined;
const updateContentType = useCallback( const updateContentType = useCallback(
( (newContentType: SourceContentType, newSearch?: string | null) => {
newContentType: SourceContentType, setContentType(newContentType);
{ updateLocationState = true, search: newSearch }: { updateLocationState?: boolean; search?: string } = {}, setResetScrollPosition(true);
) => {
if (updateLocationState) { if (query && !newSearch) {
navigate( navigate(
{ pathname: '', search: newSearch },
{ {
replace: true, pathname: '',
state: {
contentType: newContentType,
}, },
{
state: { ...locationState, contentType: newContentType },
}, },
); );
} }
setContentType(newContentType);
setResetScrollPosition(true);
}, },
[setContentType], [setContentType, query],
); );
const updateLocationFilters = useCallback( const updateLocationFilters = useCallback(
(updatedFilters: IPos[]) => { (updatedFilters: IPos[]) => {
if (contentType === SourceContentType.SEARCH) { if (contentType === SourceContentType.SEARCH) {
navigate( setFiltersToApply(updatedFilters);
{ pathname: '', search },
{
replace: true,
state: {
contentType,
filtersToApply: updatedFilters,
},
},
);
} }
}, },
[contentType, search], [contentType, query],
); );
const isSearchTermAvailable = searchTerm && query?.length; const setSearchContentType = !!query && contentType !== SourceContentType.SEARCH;
const setSearchContentType = isSearchTermAvailable && contentType !== SourceContentType.SEARCH;
if (setSearchContentType) { if (setSearchContentType) {
updateContentType(SourceContentType.SEARCH, { search }); updateContentType(SourceContentType.SEARCH, query);
} }
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
@@ -346,10 +333,6 @@ export function SourceMangas() {
} }
requestManager.clearBrowseCacheFor(sourceId); requestManager.clearBrowseCacheFor(sourceId);
navigate('', {
replace: true,
state: { contentType: currentLocationContentType, filters: currentLocationFiltersToApply },
});
}, [clearCache]); }, [clearCache]);
useEffect( useEffect(
@@ -363,7 +346,7 @@ export function SourceMangas() {
abortRequest(new Error(`SourceMangas(${sourceId}): search string changed`)); abortRequest(new Error(`SourceMangas(${sourceId}): search string changed`));
setResetScrollPosition(true); setResetScrollPosition(true);
}, },
[searchTerm], [query],
); );
useEffect(() => { useEffect(() => {
@@ -426,7 +409,7 @@ export function SourceMangas() {
<ContentTypeButton <ContentTypeButton
variant={contentType === SourceContentType.SEARCH ? 'contained' : 'outlined'} variant={contentType === SourceContentType.SEARCH ? 'contained' : 'outlined'}
startIcon={<FilterListIcon />} startIcon={<FilterListIcon />}
onClick={() => updateContentType(SourceContentType.SEARCH)} onClick={() => updateContentType(SourceContentType.SEARCH, query)}
> >
{t('global.button.filter')} {t('global.button.filter')}
</ContentTypeButton> </ContentTypeButton>

View File

@@ -13,7 +13,7 @@ import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ISource, TPartialSource } from '@/typings'; import { ISource, TPartialSource } from '@/typings';
import { requestManager } from '@/lib/requests/RequestManager.ts'; import { requestManager } from '@/lib/requests/RequestManager.ts';
import { useLocalStorage } from '@/util/useLocalStorage'; import { useLocalStorage } from '@/util/useStorage.tsx';
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';
import { LoadingPlaceholder } from '@/components/util/LoadingPlaceholder'; import { LoadingPlaceholder } from '@/components/util/LoadingPlaceholder';

View File

@@ -18,7 +18,7 @@ import { NumberSetting } from '@/components/settings/NumberSetting.tsx';
import { MutableListSetting } from '@/components/settings/MutableListSetting.tsx'; import { MutableListSetting } from '@/components/settings/MutableListSetting.tsx';
import { MetadataBrowseSettings, ServerSettings as GqlServerSettings } from '@/typings.ts'; import { MetadataBrowseSettings, ServerSettings as GqlServerSettings } from '@/typings.ts';
import { TextSetting } from '@/components/settings/text/TextSetting.tsx'; import { TextSetting } from '@/components/settings/text/TextSetting.tsx';
import { useLocalStorage } from '@/util/useLocalStorage.tsx'; import { useLocalStorage } from '@/util/useStorage.tsx';
import { import {
createUpdateMetadataServerSettings, createUpdateMetadataServerSettings,
useMetadataServerSettings, useMetadataServerSettings,

View File

@@ -12,7 +12,7 @@ import { Link, List, ListItem, ListItemText, Switch } from '@mui/material';
import ListSubheader from '@mui/material/ListSubheader'; import ListSubheader from '@mui/material/ListSubheader';
import { NavBarContext, useSetDefaultBackTo } from '@/components/context/NavbarContext.tsx'; import { NavBarContext, useSetDefaultBackTo } from '@/components/context/NavbarContext.tsx';
import { requestManager } from '@/lib/requests/RequestManager.ts'; import { requestManager } from '@/lib/requests/RequestManager.ts';
import { useLocalStorage } from '@/util/useLocalStorage.tsx'; import { useLocalStorage } from '@/util/useStorage.tsx';
import { TextSetting } from '@/components/settings/text/TextSetting.tsx'; import { TextSetting } from '@/components/settings/text/TextSetting.tsx';
import { ServerSettings as GqlServerSettings } from '@/typings.ts'; import { ServerSettings as GqlServerSettings } from '@/typings.ts';
import { NumberSetting } from '@/components/settings/NumberSetting.tsx'; import { NumberSetting } from '@/components/settings/NumberSetting.tsx';

51
src/util/AppStorage.ts Normal file
View File

@@ -0,0 +1,51 @@
/*
* 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 max-classes-per-file
export class Storage {
constructor(private readonly storage: typeof window.localStorage) {}
parseValue<T>(value: string | null, defaultValue: T): T {
if (value === null) {
return defaultValue;
}
return JSON.parse(value);
}
getItem(key: string): string | null {
return this.storage.getItem(key);
}
getItemParsed<T>(key: string, defaultValue: T): T {
return this.parseValue(this.getItem(key), defaultValue);
}
setItem(key: string, value: unknown): void {
if (value === undefined) {
return;
}
const valueToStore = JSON.stringify(value);
this.storage.setItem(key, valueToStore);
window.dispatchEvent(
new StorageEvent('storage', {
key,
oldValue: this.getItem(key),
newValue: valueToStore,
}),
);
}
}
export class AppStorage {
static readonly local: Storage = new Storage(window.localStorage);
static readonly session: Storage = new Storage(window.sessionStorage);
}

View File

@@ -1,22 +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/.
*/
export function getItem<T>(key: string, defaultValue: T): T {
const item = window.localStorage.getItem(key);
if (item !== null) {
return JSON.parse(item);
}
window.localStorage.setItem(key, JSON.stringify(defaultValue));
return defaultValue;
}
export function setItem<T>(key: string, value: T): void {
window.localStorage.setItem(key, JSON.stringify(value));
}

View File

@@ -1,38 +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 { useState, Dispatch, SetStateAction, useReducer, Reducer, useCallback } from 'react';
import * as storage from '@/util/localStorage';
export function useLocalStorage<T>(key: string, defaultValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>] {
const initialState = defaultValue instanceof Function ? defaultValue() : defaultValue;
const [storedValue, setStoredValue] = useState<T>(storage.getItem(key, initialState));
const setValue = useCallback<React.Dispatch<React.SetStateAction<T>>>(
(value) => {
setStoredValue((prevValue) => {
// Allow value to be a function so we have same API as useState
const valueToStore = value instanceof Function ? value(prevValue) : value;
storage.setItem(key, valueToStore);
return valueToStore;
});
},
[key],
);
return [storedValue, setValue];
}
export function useReducerLocalStorage<S, A>(reducer: Reducer<S, A>, key: string, defaultState: S | (() => S)) {
const [storedValue, setValue] = useLocalStorage(key, defaultState);
return useReducer((state: S, action: A): S => {
const newState = reducer(state, action);
setValue(newState);
return newState;
}, storedValue);
}

View File

@@ -6,7 +6,7 @@
* 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 { useLocalStorage } from '@/util/useLocalStorage.tsx'; import { useLocalStorage } from '@/util/useStorage.tsx';
export const getPersistedServerSetting = <T,>(serverValue: T | undefined, lastValue: T): T => { export const getPersistedServerSetting = <T,>(serverValue: T | undefined, lastValue: T): T => {
const isDisabled = serverValue === 0; const isDisabled = serverValue === 0;

88
src/util/useStorage.tsx Normal file
View File

@@ -0,0 +1,88 @@
/*
* 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 { Dispatch, Reducer, SetStateAction, useCallback, useMemo, useReducer, useSyncExternalStore } from 'react';
import { AppStorage, Storage } from '@/util/AppStorage.ts';
const subscribeToStorageUpdates = (callback: () => void) => {
window.addEventListener('storage', callback);
return () => window.removeEventListener('storage', callback);
};
function useStorage<T>(storage: Storage, key: string, defaultValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>];
function useStorage<T = undefined>(
storage: Storage,
key: string,
): [T | undefined, Dispatch<SetStateAction<T | undefined>>];
function useStorage<T>(
storage: Storage,
key: string,
defaultValue?: T | (() => T) | undefined,
): [T | undefined, Dispatch<SetStateAction<T | undefined>>] {
const initialState = defaultValue instanceof Function ? defaultValue() : defaultValue;
const storedValueRaw = useSyncExternalStore(subscribeToStorageUpdates, () => storage.getItem(key));
const setValue = useCallback<React.Dispatch<React.SetStateAction<T | undefined>>>(
(value) => {
// Allow value to be a function so we have same API as useState
const valueToStore = value instanceof Function ? value(storage.getItemParsed(key, initialState)) : value;
storage.setItem(key, valueToStore);
},
[key],
);
const storedValue = useMemo(
() => (storedValueRaw !== null ? JSON.parse(storedValueRaw) : initialState),
[storedValueRaw, key],
);
return [storedValue, setValue];
}
const useReducerStorage = <S, A>(
storage: Storage,
reducer: Reducer<S, A>,
key: string,
defaultState: S | (() => S),
) => {
const [storedValue, setValue] = useStorage(storage, key, defaultState);
return useReducer((state: S, action: A): S => {
const newState = reducer(state, action);
setValue(newState);
return newState;
}, storedValue);
};
export function useLocalStorage<T>(key: string, defaultValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>];
export function useLocalStorage<T = undefined>(key: string): [T | undefined, Dispatch<SetStateAction<T | undefined>>];
export function useLocalStorage<T>(
key: string,
defaultValue?: T | undefined | (() => T | undefined),
): [T | undefined, Dispatch<SetStateAction<T | undefined>>] {
return useStorage(AppStorage.local, key, defaultValue);
}
export function useReducerLocalStorage<S, A>(reducer: Reducer<S, A>, key: string, defaultState: S | (() => S)) {
return useReducerStorage(AppStorage.local, reducer, key, defaultState);
}
export function useSessionStorage<T>(key: string, defaultValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>];
export function useSessionStorage<T = undefined>(key: string): [T | undefined, Dispatch<SetStateAction<T | undefined>>];
export function useSessionStorage<T>(
key: string,
defaultValue?: T | (() => T),
): [T | undefined, Dispatch<SetStateAction<T | undefined>>] {
return useStorage(AppStorage.session, key, defaultValue);
}
export function useReducerSessionStorage<S, A>(reducer: Reducer<S, A>, key: string, defaultState: S | (() => S)) {
return useReducerStorage(AppStorage.session, reducer, key, defaultState);
}