Improve global search
Add options to show only searches for - pinned sources - all sources - sources with results
This commit is contained in:
@@ -1045,6 +1045,9 @@
|
|||||||
"source_search_failed": "Could not search source"
|
"source_search_failed": "Could not search source"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"filter": {
|
||||||
|
"has_results": "Has results"
|
||||||
|
},
|
||||||
"title": {
|
"title": {
|
||||||
"global_search": "Global Search",
|
"global_search": "Global Search",
|
||||||
"search": "Search"
|
"search": "Search"
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ export type MetadataBrowseSettings = {
|
|||||||
extensionLanguages: string[];
|
extensionLanguages: string[];
|
||||||
showNsfw: boolean;
|
showNsfw: boolean;
|
||||||
lastUsedSourceId: SourceIdInfo['id'] | null;
|
lastUsedSourceId: SourceIdInfo['id'] | null;
|
||||||
|
shouldShowOnlySourcesWithResults: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,8 +14,14 @@ import { Link, useLocation } from 'react-router-dom';
|
|||||||
import { StringParam, useQueryParam } from 'use-query-params';
|
import { StringParam, useQueryParam } from 'use-query-params';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import PushPinIcon from '@mui/icons-material/PushPin';
|
||||||
|
import DoneAllIcon from '@mui/icons-material/DoneAll';
|
||||||
|
import FilterListIcon from '@mui/icons-material/FilterList';
|
||||||
|
import { useElementSize } from '@mantine/hooks';
|
||||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||||
import { useLocalStorage } from '@/modules/core/hooks/useStorage.tsx';
|
import { useLocalStorage, useSessionStorage } from '@/modules/core/hooks/useStorage.tsx';
|
||||||
import { getDefaultLanguages } from '@/modules/core/utils/Languages.ts';
|
import { getDefaultLanguages } from '@/modules/core/utils/Languages.ts';
|
||||||
import { AppbarSearch } from '@/modules/core/components/AppbarSearch.tsx';
|
import { AppbarSearch } from '@/modules/core/components/AppbarSearch.tsx';
|
||||||
import { LanguageSelect } from '@/modules/core/components/inputs/LanguageSelect.tsx';
|
import { LanguageSelect } from '@/modules/core/components/inputs/LanguageSelect.tsx';
|
||||||
@@ -32,9 +38,13 @@ import { AppRoutes } from '@/modules/core/AppRoute.constants.ts';
|
|||||||
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
|
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
|
||||||
import { Sources } from '@/modules/source/services/Sources.ts';
|
import { Sources } from '@/modules/source/services/Sources.ts';
|
||||||
import { SourceDisplayNameInfo, SourceIdInfo, SourceMetaInfo } from '@/modules/source/Source.types.ts';
|
import { SourceDisplayNameInfo, SourceIdInfo, SourceMetaInfo } from '@/modules/source/Source.types.ts';
|
||||||
import { useMetadataServerSettings } from '@/modules/settings/services/ServerSettingsMetadata.ts';
|
import {
|
||||||
|
createUpdateMetadataServerSettings,
|
||||||
|
useMetadataServerSettings,
|
||||||
|
} from '@/modules/settings/services/ServerSettingsMetadata.ts';
|
||||||
import { useAppTitleAndAction } from '@/modules/navigation-bar/hooks/useAppTitleAndAction.ts';
|
import { useAppTitleAndAction } from '@/modules/navigation-bar/hooks/useAppTitleAndAction.ts';
|
||||||
import { getSourceMetadata } from '@/modules/source/services/SourceMetadata.ts';
|
import { getSourceMetadata } from '@/modules/source/services/SourceMetadata.ts';
|
||||||
|
import { makeToast } from '@/modules/core/utils/Toast.ts';
|
||||||
|
|
||||||
type SourceLoadingState = { isLoading: boolean; hasResults: boolean; emptySearch: boolean; error: any };
|
type SourceLoadingState = { isLoading: boolean; hasResults: boolean; emptySearch: boolean; error: any };
|
||||||
type SourceToLoadingStateMap = Map<string, SourceLoadingState>;
|
type SourceToLoadingStateMap = Map<string, SourceLoadingState>;
|
||||||
@@ -200,31 +210,56 @@ const SourceSearchPreview = React.memo(
|
|||||||
|
|
||||||
export const SearchAll: React.FC = () => {
|
export const SearchAll: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { pathname, state } = useLocation<{ mangaTitle?: string }>();
|
const { pathname, state } = useLocation<{ mangaTitle?: string }>();
|
||||||
|
const { ref: filterHeaderRef, height: filterHeaderHeight } = useElementSize();
|
||||||
|
|
||||||
const isMigrateMode = pathname.startsWith('/migrate/source');
|
const isMigrateMode = pathname.startsWith('/migrate/source');
|
||||||
|
|
||||||
const [query] = useQueryParam('query', StringParam);
|
const [query] = useQueryParam('query', StringParam);
|
||||||
const searchString = useDebounce(query, TRIGGER_SEARCH_THRESHOLD);
|
const searchString = useDebounce(query, TRIGGER_SEARCH_THRESHOLD);
|
||||||
|
|
||||||
const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownSourceLangs', getDefaultLanguages());
|
const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownSourceLangs', getDefaultLanguages());
|
||||||
|
const [shouldShowOnlyPinnedSources, setShouldShowOnlyPinnedSources] = useSessionStorage(
|
||||||
|
'SearchAll::shouldShowOnlyPinnedSources',
|
||||||
|
true,
|
||||||
|
);
|
||||||
const {
|
const {
|
||||||
settings: { showNsfw },
|
settings: { showNsfw, shouldShowOnlySourcesWithResults },
|
||||||
} = useMetadataServerSettings();
|
} = useMetadataServerSettings();
|
||||||
|
|
||||||
const { data, loading, error, refetch } = requestManager.useGetSourceList({ notifyOnNetworkStatusChange: true });
|
const { data, loading, error, refetch } = requestManager.useGetSourceList({ notifyOnNetworkStatusChange: true });
|
||||||
const sources = useMemo(() => data?.sources.nodes ?? [], [data?.sources.nodes]);
|
const sources = useMemo(() => data?.sources.nodes ?? [], [data?.sources.nodes]);
|
||||||
const filteredSources = useMemo(
|
|
||||||
() => Sources.filter(sources, { showNsfw, languages: shownLangs, keepLocalSource: true }),
|
|
||||||
[sources, shownLangs],
|
|
||||||
);
|
|
||||||
const sourcesSortedByName = useMemo(() => [...filteredSources].toSorted(compareSourceByName), [filteredSources]);
|
|
||||||
|
|
||||||
const [sourceToLoadingStateMap, setSourceToLoadingStateMap] = useState<SourceToLoadingStateMap>(new Map());
|
const [sourceToLoadingStateMap, setSourceToLoadingStateMap] = useState<SourceToLoadingStateMap>(new Map());
|
||||||
const debouncedSourceToLoadingStateMap = useDebounce(sourceToLoadingStateMap, 500);
|
const debouncedSourceToLoadingStateMap = useDebounce(sourceToLoadingStateMap, 500);
|
||||||
|
|
||||||
const sourceLanguages = useMemo(() => Sources.getLanguages(sources), [sources]);
|
const sourceLanguages = useMemo(() => Sources.getLanguages(sources), [sources]);
|
||||||
|
|
||||||
|
const filteredSources = useMemo(
|
||||||
|
() =>
|
||||||
|
Sources.filter(sources, {
|
||||||
|
showNsfw,
|
||||||
|
languages: shownLangs,
|
||||||
|
keepLocalSource: true,
|
||||||
|
pinned: shouldShowOnlyPinnedSources,
|
||||||
|
}),
|
||||||
|
[sources, shownLangs, shouldShowOnlyPinnedSources],
|
||||||
|
);
|
||||||
|
const filteredSourcesByHasResult = useMemo(() => {
|
||||||
|
if (!shouldShowOnlySourcesWithResults) {
|
||||||
|
return filteredSources;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredSources.filter((source) => {
|
||||||
|
const sourceState = debouncedSourceToLoadingStateMap.get(source.id);
|
||||||
|
|
||||||
|
return !sourceState || sourceState?.isLoading || (sourceState?.hasResults && !sourceState?.error);
|
||||||
|
});
|
||||||
|
}, [filteredSources, shouldShowOnlySourcesWithResults, debouncedSourceToLoadingStateMap]);
|
||||||
|
const sourcesSortedByName = useMemo(
|
||||||
|
() => [...filteredSourcesByHasResult].toSorted(compareSourceByName),
|
||||||
|
[filteredSourcesByHasResult],
|
||||||
|
);
|
||||||
const sourcesSortedByResult = useMemo(
|
const sourcesSortedByResult = useMemo(
|
||||||
() =>
|
() =>
|
||||||
[...sourcesSortedByName].sort((sourceA, sourceB) =>
|
[...sourcesSortedByName].sort((sourceA, sourceB) =>
|
||||||
@@ -244,6 +279,10 @@ export const SearchAll: React.FC = () => {
|
|||||||
[setSourceToLoadingStateMap],
|
[setSourceToLoadingStateMap],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const updateMetadataSettings = createUpdateMetadataServerSettings<'shouldShowOnlySourcesWithResults'>((e) =>
|
||||||
|
makeToast(t('global.error.label.failed_to_save_changes'), 'error', getErrorMessage(e)),
|
||||||
|
);
|
||||||
|
|
||||||
useAppTitleAndAction(
|
useAppTitleAndAction(
|
||||||
t(isMigrateMode ? 'migrate.search.title' : 'search.title.global_search', { title: state?.mangaTitle }),
|
t(isMigrateMode ? 'migrate.search.title' : 'search.title.global_search', { title: state?.mangaTitle }),
|
||||||
<>
|
<>
|
||||||
@@ -272,18 +311,59 @@ export const SearchAll: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: 1 }}>
|
<Box sx={{ position: 'relative', px: 1, pb: 1 }}>
|
||||||
{sourcesSortedByResult.map((source, index) => (
|
<Stack
|
||||||
<Box key={source.id} sx={{ pb: index + 1 !== sourcesSortedByResult.length ? 2 : 0 }}>
|
ref={filterHeaderRef}
|
||||||
<SourceSearchPreview
|
sx={{
|
||||||
source={source}
|
width: '100%',
|
||||||
onSearchRequestFinished={updateSourceLoadingState}
|
position: 'fixed',
|
||||||
searchString={searchString}
|
zIndex: 1,
|
||||||
emptyQuery={!query}
|
flexDirection: 'row',
|
||||||
mode={isMigrateMode ? 'migrate.select' : 'source'}
|
gap: 2,
|
||||||
/>
|
pt: 1,
|
||||||
</Box>
|
pb: 2,
|
||||||
))}
|
background: (theme) => theme.palette.background.default,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack sx={{ flexDirection: 'row', gap: 1 }}>
|
||||||
|
<Button
|
||||||
|
startIcon={<PushPinIcon />}
|
||||||
|
variant={shouldShowOnlyPinnedSources ? 'contained' : 'outlined'}
|
||||||
|
onClick={() => setShouldShowOnlyPinnedSources(true)}
|
||||||
|
>
|
||||||
|
{t('global.label.pinned')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
startIcon={<DoneAllIcon />}
|
||||||
|
variant={!shouldShowOnlyPinnedSources ? 'contained' : 'outlined'}
|
||||||
|
onClick={() => setShouldShowOnlyPinnedSources(false)}
|
||||||
|
>
|
||||||
|
{t('extension.language.all')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
<Button
|
||||||
|
startIcon={<FilterListIcon />}
|
||||||
|
variant={shouldShowOnlySourcesWithResults ? 'contained' : 'outlined'}
|
||||||
|
onClick={() =>
|
||||||
|
updateMetadataSettings('shouldShowOnlySourcesWithResults', !shouldShowOnlySourcesWithResults)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('search.filter.has_results')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
<Box sx={{ pt: `${filterHeaderHeight}px` }}>
|
||||||
|
{sourcesSortedByResult.map((source, index) => (
|
||||||
|
<Box key={source.id} sx={{ pb: index + 1 !== sourcesSortedByResult.length ? 2 : 0 }}>
|
||||||
|
<SourceSearchPreview
|
||||||
|
source={source}
|
||||||
|
onSearchRequestFinished={updateSourceLoadingState}
|
||||||
|
searchString={searchString}
|
||||||
|
emptyQuery={!query}
|
||||||
|
mode={isMigrateMode ? 'migrate.select' : 'source'}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export const APP_METADATA_KEY_TO_TYPE = {
|
|||||||
mangaGridItemWidth: 'number',
|
mangaGridItemWidth: 'number',
|
||||||
isPinned: 'boolean',
|
isPinned: 'boolean',
|
||||||
lastUsedSourceId: 'string',
|
lastUsedSourceId: 'string',
|
||||||
|
shouldShowOnlySourcesWithResults: 'boolean',
|
||||||
} as const satisfies Record<AppMetadataKeys, 'auto' | 'string' | 'number' | 'boolean'>;
|
} as const satisfies Record<AppMetadataKeys, 'auto' | 'string' | 'number' | 'boolean'>;
|
||||||
|
|
||||||
export const VALID_APP_METADATA_KEYS = Object.keys(APP_METADATA_KEY_TO_TYPE);
|
export const VALID_APP_METADATA_KEYS = Object.keys(APP_METADATA_KEY_TO_TYPE);
|
||||||
@@ -149,6 +150,7 @@ export const GLOBAL_METADATA_KEYS: AppMetadataKeys[] = [
|
|||||||
'extensionLanguages',
|
'extensionLanguages',
|
||||||
'showNsfw',
|
'showNsfw',
|
||||||
'lastUsedSourceId',
|
'lastUsedSourceId',
|
||||||
|
'shouldShowOnlySourcesWithResults',
|
||||||
|
|
||||||
// history
|
// history
|
||||||
'hideHistory',
|
'hideHistory',
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export const SERVER_SETTINGS_METADATA_DEFAULT: MetadataServerSettings = {
|
|||||||
extensionLanguages: getDefaultLanguages(),
|
extensionLanguages: getDefaultLanguages(),
|
||||||
showNsfw: true,
|
showNsfw: true,
|
||||||
lastUsedSourceId: null,
|
lastUsedSourceId: null,
|
||||||
|
shouldShowOnlySourcesWithResults: true,
|
||||||
|
|
||||||
// history
|
// history
|
||||||
hideHistory: false,
|
hideHistory: false,
|
||||||
|
|||||||
@@ -80,7 +80,8 @@ export class Sources {
|
|||||||
showNsfw,
|
showNsfw,
|
||||||
languages,
|
languages,
|
||||||
keepLocalSource,
|
keepLocalSource,
|
||||||
}: { showNsfw?: boolean; languages?: string[]; keepLocalSource?: boolean } = {},
|
pinned,
|
||||||
|
}: { showNsfw?: boolean; languages?: string[]; keepLocalSource?: boolean; pinned?: boolean } = {},
|
||||||
): Source[] {
|
): Source[] {
|
||||||
const normalizedLanguages = toComparableLanguages(toUniqueLanguageCodes(languages ?? []));
|
const normalizedLanguages = toComparableLanguages(toUniqueLanguageCodes(languages ?? []));
|
||||||
|
|
||||||
@@ -97,6 +98,13 @@ export class Sources {
|
|||||||
!languages ||
|
!languages ||
|
||||||
normalizedLanguages.includes(toComparableLanguage(Sources.getLanguage(source))) ||
|
normalizedLanguages.includes(toComparableLanguage(Sources.getLanguage(source))) ||
|
||||||
(keepLocalSource && Sources.isLocalSource(source)),
|
(keepLocalSource && Sources.isLocalSource(source)),
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
(source) =>
|
||||||
|
pinned === undefined ||
|
||||||
|
!pinned ||
|
||||||
|
getSourceMetadata(source).isPinned ||
|
||||||
|
(keepLocalSource && Sources.isLocalSource(source)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user