Improve global search

Add options to show only searches for
- pinned sources
- all sources
- sources with results
This commit is contained in:
schroda
2025-05-18 00:59:01 +02:00
parent 1e1489df75
commit 7885bcda98
6 changed files with 117 additions and 22 deletions

View File

@@ -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"

View File

@@ -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;
}; };

View File

@@ -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>
); );
}; };

View File

@@ -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',

View File

@@ -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,

View File

@@ -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)),
); );
} }