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:
@@ -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]);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,17 +215,12 @@ 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 = [],
|
contentType: SourceContentType;
|
||||||
clearCache = false,
|
clearCache: boolean;
|
||||||
} = useLocation<{
|
}>().state ?? {};
|
||||||
contentType: SourceContentType;
|
|
||||||
filtersToApply: IPos[];
|
|
||||||
clearCache: boolean;
|
|
||||||
search: string;
|
|
||||||
}>().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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
51
src/util/AppStorage.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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
88
src/util/useStorage.tsx
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user