Files
suwayomi-material-you-webui/src/features/tracker/components/TrackerSearch.tsx
schroda e38031a02f Fix tracker search results missing bound track record
There is a possibility that the source changed the manga title after the track record was bound.
In that case it is possible that the track record won't be found anymore when executing the search.

Thus, instead of using the manga title as the search string, the existing track records title should be used instead to make sure that the bound track record will be included in the search results.
2026-05-05 11:21:55 +02:00

256 lines
11 KiB
TypeScript

/*
* 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 Button from '@mui/material/Button';
import List from '@mui/material/List';
import Stack from '@mui/material/Stack';
import { useEffect, useMemo, useState } from 'react';
import IconButton from '@mui/material/IconButton';
import ArrowBack from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import InputAdornment from '@mui/material/InputAdornment';
import InfoIcon from '@mui/icons-material/Info';
import PopupState, { bindPopover, bindTrigger } from 'material-ui-popup-state';
import Popover from '@mui/material/Popover';
import Typography from '@mui/material/Typography';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import { useLingui } from '@lingui/react/macro';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { EmptyViewAbsoluteCentered } from '@/base/components/feedback/EmptyViewAbsoluteCentered.tsx';
import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholder.tsx';
import { SearchTextField } from '@/base/components/inputs/SearchTextField.tsx';
import { makeToast } from '@/base/utils/Toast.ts';
import { TrackerMangaCard } from '@/features/tracker/components/cards/TrackerMangaCard.tsx';
import { DIALOG_PADDING } from '@/features/tracker/Tracker.constants.ts';
import { useGetOptionForDirection } from '@/features/theme/services/ThemeCreator.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import type { MangaType } from '@/lib/graphql/generated/graphql.ts';
import type { MangaIdInfo } from '@/features/manga/Manga.types.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { applyStyles } from '@/base/utils/ApplyStyles.ts';
import type { TrackerIdInfo, TTrackerBind } from '@/features/tracker/Tracker.types.ts';
import { Tracker } from '@/features/tracker/Tracker.types.ts';
import { CustomButtonIcon } from '@/base/components/buttons/CustomButtonIcon.tsx';
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
import { STABLE_EMPTY_ARRAY } from '@/base/Base.constants.ts';
const TrackButton = ({
mangaId,
selectedTrackerRemoteId,
trackerId,
closeSearchMode,
supportsPrivateTracking,
}: {
trackerId: TrackerIdInfo['id'];
mangaId: MangaIdInfo['id'];
selectedTrackerRemoteId: string | undefined;
closeSearchMode: () => void;
supportsPrivateTracking: boolean;
}) => {
const { t } = useLingui();
const [bindTracker, bindTrackerMutation] = requestManager.useBindTracker();
const trackManga = (asPrivate: boolean) => {
if (selectedTrackerRemoteId === undefined) {
return;
}
bindTracker({
variables: { input: { mangaId, remoteId: selectedTrackerRemoteId, trackerId, private: asPrivate } },
})
.then(() => {
makeToast(t`Tracked manga`, 'success');
closeSearchMode();
})
.catch((e) => makeToast(t`Could not track manga`, 'error', getErrorMessage(e)));
};
return (
<Stack
direction="row"
sx={{
justifyContent: 'center',
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
paddingBottom: DIALOG_PADDING,
gap: 2,
px: DIALOG_PADDING,
}}
>
<Button
disabled={bindTrackerMutation.loading}
size="large"
variant="contained"
onClick={() => trackManga(false)}
sx={{ flexBasis: '65%' }}
>
{t`Track`}
</Button>
{supportsPrivateTracking && (
<CustomTooltip title={t`Track privately`} disabled={bindTrackerMutation.loading}>
<CustomButtonIcon
disabled={bindTrackerMutation.loading}
sx={{ flexBasis: '10%', maxWidth: '100px' }}
variant="contained"
onClick={() => trackManga(true)}
>
<VisibilityOffIcon />
</CustomButtonIcon>
</CustomTooltip>
)}
</Stack>
);
};
export const TrackerSearch = ({
manga,
tracker,
closeSearchMode,
trackedId,
trackedTitle,
}: {
manga: MangaIdInfo & Pick<MangaType, 'title'>;
tracker: TTrackerBind;
closeSearchMode: () => void;
trackedId?: string;
trackedTitle?: string;
}) => {
const { t } = useLingui();
const getOptionForDirection = useGetOptionForDirection();
const [searchString, setSearchString] = useState<string>(trackedTitle ?? manga.title);
const [tmpSearchString, setTmpSearchString] = useState(searchString);
const [selectedTrackerRemoteId, setSelectedTrackerRemoteId] = useState<string | undefined>(trackedId);
const trackerSearch = requestManager.useTrackerSearch(tracker.id, searchString);
const searchResults = trackerSearch.data?.searchTracker.trackSearches ?? STABLE_EMPTY_ARRAY;
const hasResults = !!searchResults.length;
const hasNoResults = !trackerSearch.loading && !trackerSearch.error && !hasResults;
const hasError = !!trackerSearch.error && !trackerSearch.loading;
useEffect(() => {
setSelectedTrackerRemoteId(trackedId);
return () =>
trackerSearch.abortRequest(new Error(`MangaTrackerSearchCard(${tracker.id}, ${manga.id}): search changed`));
}, [searchString]);
const showTrackButton =
useMemo(
() =>
!!selectedTrackerRemoteId &&
!!searchResults.find((searchResult) => searchResult.remoteId === selectedTrackerRemoteId),
[selectedTrackerRemoteId, searchResults],
) && !hasError;
return (
<>
<DialogTitle sx={{ padding: DIALOG_PADDING }}>
<Stack
direction="row"
sx={{
gap: '10px',
alignItems: 'center',
}}
>
<IconButton onClick={closeSearchMode}>
{getOptionForDirection(<ArrowBack />, <ArrowForwardIcon />)}
</IconButton>
<SearchTextField
sx={{ width: '100%' }}
variant="standard"
value={tmpSearchString}
onChange={(e) => setTmpSearchString(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setSearchString(tmpSearchString);
}
}}
onCancel={() => setTmpSearchString('')}
slotProps={{
input: {
startAdornment: tracker.id === Tracker.MYANIMELIST && (
<InputAdornment position="start">
<PopupState variant="popover" popupId="tracker-search-info">
{(popupState) => (
<>
<IconButton {...bindTrigger(popupState)} color="inherit">
<InfoIcon />
</IconButton>
<Popover
{...bindPopover(popupState)}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
>
<Typography sx={{ padding: 1, whiteSpace: 'pre-line' }}>
{t`Search for a ID via "id:<ID>" (e.g. "id:13")\nLimit search to your lists via "my:<Title>" (e.g. "my:One Piece")`}
</Typography>
</Popover>
</>
)}
</PopupState>
</InputAdornment>
),
},
}}
/>
</Stack>
</DialogTitle>
<DialogContent
dividers
sx={{
padding: DIALOG_PADDING,
height: '100vh',
...applyStyles(hasNoResults || hasError, { position: 'relative' }),
}}
>
{hasNoResults && <EmptyViewAbsoluteCentered message={t`No manga found`} />}
{trackerSearch.loading && <LoadingPlaceholder />}
{hasError && (
<EmptyViewAbsoluteCentered
message={t`Unable to load data`}
messageExtra={getErrorMessage(trackerSearch.error)}
retry={() =>
trackerSearch.refetch().catch(defaultPromiseErrorHandler('TrackerSearch::refetch'))
}
/>
)}
<List sx={{ padding: 0 }}>
{hasResults &&
searchResults.map((trackerManga) => (
<TrackerMangaCard
key={trackerManga.id}
manga={trackerManga}
selected={trackerManga.remoteId === selectedTrackerRemoteId}
onSelect={() => setSelectedTrackerRemoteId(trackerManga.remoteId)}
/>
))}
</List>
{showTrackButton && (
<TrackButton
mangaId={manga.id}
trackerId={tracker.id}
closeSearchMode={closeSearchMode}
selectedTrackerRemoteId={selectedTrackerRemoteId}
supportsPrivateTracking={tracker.supportsPrivateTracking}
/>
)}
</DialogContent>
</>
);
};