Fix selecting migration destination from source browse search page

When going to the source search from the global search, the manga card mode wasn't passed, which prevented a manga from getting selected as the migration destination

#1110
This commit is contained in:
schroda
2026-05-27 22:28:28 +02:00
parent 27c87185b1
commit 278ebb312f
9 changed files with 54 additions and 25 deletions

View File

@@ -41,6 +41,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- (**Migration**) Fix search hotkey (ctrl+f) in the single manga migration search page not focusing the search textfield - (**Migration**) Fix search hotkey (ctrl+f) in the single manga migration search page not focusing the search textfield
- (**Migration**) Fix missing manual search option for in progress entry search without a selected match on mobile - (**Migration**) Fix missing manual search option for in progress entry search without a selected match on mobile
- (**Migration**) Fix being unable to retry failed search for a match without a selected match - (**Migration**) Fix being unable to retry failed search for a match without a selected match
- (**Migration**) Fix selecting a destination from a source browse search page
- (**Reader**) Fix scrollbar appearing with "fit to widt/height/screen" page scale mode and applied safe area insets - (**Reader**) Fix scrollbar appearing with "fit to widt/height/screen" page scale mode and applied safe area insets
- (**Reader**) Fix wrongly positioned mobile progress bar current page indicator - (**Reader**) Fix wrongly positioned mobile progress bar current page indicator
- (**Reader**) Fix mobile progress bar previous/next chapter button visibility on hover and while disabled - (**Reader**) Fix mobile progress bar previous/next chapter button visibility on hover and while disabled

View File

@@ -6,7 +6,10 @@
* 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 type { MangaCardMode } from '@/features/manga/Manga.types.ts';
export interface RouteStateSourcesSearchAll { export interface RouteStateSourcesSearchAll {
title?: string; title?: string;
shouldShowOnlyPinnedSources?: boolean; shouldShowOnlyPinnedSources?: boolean;
mode?: MangaCardMode;
} }

View File

@@ -26,7 +26,7 @@ import { useLingui } from '@lingui/react/macro';
import { requestManager } from '@/lib/requests/RequestManager.ts'; import { requestManager } from '@/lib/requests/RequestManager.ts';
import { AppbarSearch } from '@/base/components/AppbarSearch.tsx'; import { AppbarSearch } from '@/base/components/AppbarSearch.tsx';
import { useDebounce } from '@/base/hooks/useDebounce.ts'; import { useDebounce } from '@/base/hooks/useDebounce.ts';
import type { MangaCardProps } from '@/features/manga/Manga.types.ts'; import type { MangaCardProps, MangaIdInfo } from '@/features/manga/Manga.types.ts';
import { EmptyView } from '@/base/components/feedback/EmptyView.tsx'; import { EmptyView } from '@/base/components/feedback/EmptyView.tsx';
import { STABLE_EMPTY_ARRAY, STABLE_EMPTY_OBJECT } from '@/base/Base.constants.ts'; import { STABLE_EMPTY_ARRAY, STABLE_EMPTY_OBJECT } from '@/base/Base.constants.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
@@ -59,7 +59,6 @@ import { SearchParam } from '@/base/Base.types.ts';
import { MigrationManager } from '@/features/migration/MigrationManager.ts'; import { MigrationManager } from '@/features/migration/MigrationManager.ts';
import { assertIsDefined } from '@/base/Asserts.ts'; import { assertIsDefined } from '@/base/Asserts.ts';
import { ReactRouter } from '@/lib/react-router/ReactRouter.ts'; import { ReactRouter } from '@/lib/react-router/ReactRouter.ts';
import { SubpathUtil } from '@/lib/utils/SubpathUtil.ts';
import type { RouteStateSourcesSearchAll } from '@/features/global-search/SearchAll.types.ts'; import type { RouteStateSourcesSearchAll } from '@/features/global-search/SearchAll.types.ts';
type SourceLoadingState = { isLoading: boolean; hasResults: boolean; emptySearch: boolean; error: any }; type SourceLoadingState = { isLoading: boolean; hasResults: boolean; emptySearch: boolean; error: any };
@@ -128,11 +127,13 @@ const SourceSearchPreview = React.memo(
mode, mode,
shouldShowOnlySourcesWithResults, shouldShowOnlySourcesWithResults,
onMigrateSelect, onMigrateSelect,
mangaId,
}: { }: {
source: SourceIdInfo & SourceDisplayNameInfo & SourceNameInfo & SourceLanguageInfo; source: SourceIdInfo & SourceDisplayNameInfo & SourceNameInfo & SourceLanguageInfo;
onSearchRequestFinished: (source: SourceIdInfo, state: SourceLoadingState) => void; onSearchRequestFinished: (source: SourceIdInfo, state: SourceLoadingState) => void;
searchString: string | null | undefined; searchString: string | null | undefined;
emptyQuery: boolean; emptyQuery: boolean;
mangaId?: MangaIdInfo['id'];
} & Pick<MangaCardProps, 'mode' | 'onMigrateSelect'> & } & Pick<MangaCardProps, 'mode' | 'onMigrateSelect'> &
Pick<MetadataBrowseSettings, 'shouldShowOnlySourcesWithResults'>) => { Pick<MetadataBrowseSettings, 'shouldShowOnlySourcesWithResults'>) => {
const { t } = useLingui(); const { t } = useLingui();
@@ -194,6 +195,10 @@ const SourceSearchPreview = React.memo(
<CardActionArea <CardActionArea
component={Link} component={Link}
to={AppRoutes.sources.children.browse.path(id, searchString)} to={AppRoutes.sources.children.browse.path(id, searchString)}
state={AppRoutes.sources.children.browse.state({
mode,
mangaId,
})}
sx={{ p: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }} sx={{ p: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
> >
<Box> <Box>
@@ -254,8 +259,6 @@ export const SearchAll = ({
const { state } = useLocation<RouteStateSourcesSearchAll>(); const { state } = useLocation<RouteStateSourcesSearchAll>();
const { ref: filterHeaderRef, height: filterHeaderHeight } = useElementSize(); const { ref: filterHeaderRef, height: filterHeaderHeight } = useElementSize();
const isMigrateMode = SubpathUtil.getPathname().startsWith(AppRoutes.migrate.path);
const { mangaId } = useParams<{ mangaId?: string }>() ?? STABLE_EMPTY_OBJECT; const { mangaId } = useParams<{ mangaId?: string }>() ?? STABLE_EMPTY_OBJECT;
const [query] = useQueryParam(SearchParam.QUERY, StringParam); const [query] = useQueryParam(SearchParam.QUERY, StringParam);
const searchString = useDebounce(query, TRIGGER_SEARCH_THRESHOLD); const searchString = useDebounce(query, TRIGGER_SEARCH_THRESHOLD);
@@ -281,7 +284,13 @@ export const SearchAll = ({
const sourceLanguages = useMemo(() => Sources.getLanguages(sources, { excludeLocalSource: true }), [sources]); const sourceLanguages = useMemo(() => Sources.getLanguages(sources, { excludeLocalSource: true }), [sources]);
const hasPinnedSources = useMemo(() => !!Sources.filter(sources, { pinned: true }).length, [sources]); const hasPinnedSources = useMemo(() => !!Sources.filter(sources, { pinned: true }).length, [sources]);
const shouldShowOnlyPinnedSources = state?.shouldShowOnlyPinnedSources ?? hasPinnedSources;
const {
title = t`Global Search`,
shouldShowOnlyPinnedSources = hasPinnedSources,
mode = 'source',
} = state ?? STABLE_EMPTY_OBJECT;
const isMigrateMode = ['migrate.select.single', 'migrate.select.bulk'].includes(mode);
const filteredSources = useMemo( const filteredSources = useMemo(
() => () =>
@@ -319,7 +328,7 @@ export const SearchAll = ({
); );
useAppTitleAndAction( useAppTitleAndAction(
isMigrateMode ? state?.title : t`Global Search`, title,
<> <>
<AppbarSearch isClosable={false} /> <AppbarSearch isClosable={false} />
<SourceLanguageSelect <SourceLanguageSelect
@@ -429,10 +438,10 @@ export const SearchAll = ({
onSearchRequestFinished={updateSourceLoadingState} onSearchRequestFinished={updateSourceLoadingState}
searchString={searchString} searchString={searchString}
emptyQuery={!query} emptyQuery={!query}
mode={isMigrateMode ? 'migrate.select' : 'source'} mode={mode}
shouldShowOnlySourcesWithResults={shouldShowOnlySourcesWithResults} shouldShowOnlySourcesWithResults={shouldShowOnlySourcesWithResults}
onMigrateSelect={ onMigrateSelect={
migrationDestinationSourceIds isMigrateMode
? (match) => { ? (match) => {
assertIsDefined(mangaId); assertIsDefined(mangaId);
MigrationManager.selectManualMatch(Number(mangaId), { MigrationManager.selectManualMatch(Number(mangaId), {
@@ -444,6 +453,7 @@ export const SearchAll = ({
} }
: undefined : undefined
} }
mangaId={mangaId ? Number(mangaId) : undefined}
/> />
))} ))}
</Box> </Box>

View File

@@ -30,7 +30,7 @@ import type {
TrackRecordType, TrackRecordType,
} from '@/lib/graphql/generated/graphql-base.types.ts'; } from '@/lib/graphql/generated/graphql-base.types.ts';
export type MangaCardMode = 'default' | 'source' | 'migrate.search' | 'migrate.select' | 'duplicate'; export type MangaCardMode = 'default' | 'source' | 'migrate.select.bulk' | 'migrate.select.single' | 'duplicate';
type MangaCardBaseProps = Pick<MangaTypeGql, 'id' | 'title' | 'sourceId'> & type MangaCardBaseProps = Pick<MangaTypeGql, 'id' | 'title' | 'sourceId'> &
Omit<SingleModeProps['manga'], 'downloadCount' | 'unreadCount' | 'chapters'> & Omit<SingleModeProps['manga'], 'downloadCount' | 'unreadCount' | 'chapters'> &

View File

@@ -31,21 +31,16 @@ import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler';
import { MangaMigration } from '@/features/migration/MangaMigration.ts'; import { MangaMigration } from '@/features/migration/MangaMigration.ts';
import { MANGA_ACTION_TO_TRANSLATION } from '@/features/manga/Manga.constants.ts'; import { MANGA_ACTION_TO_TRANSLATION } from '@/features/manga/Manga.constants.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts'; import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { assertIsDefined } from '@/base/Asserts.ts';
const getMangaLinkTo = ( const getMangaLinkTo = (mode: MangaCardMode, mangaId: number): string => {
mode: MangaCardMode,
mangaId: number,
sourceId: string | undefined,
mangaTitle: string,
): string => {
switch (mode) { switch (mode) {
case 'default': case 'default':
case 'source': case 'source':
case 'duplicate': case 'duplicate':
return AppRoutes.manga.path(mangaId); return AppRoutes.manga.path(mangaId);
case 'migrate.search': case 'migrate.select.single':
return AppRoutes.migrate.children.singleMangaSearch.path(sourceId ?? '-1', mangaId, mangaTitle); case 'migrate.select.bulk':
case 'migrate.select':
return ''; return '';
default: default:
throw new Error(`getMangaLinkTo: unexpected MangaCardMode "${mode}"`); throw new Error(`getMangaLinkTo: unexpected MangaCardMode "${mode}"`);
@@ -73,13 +68,15 @@ export const MangaCard = memo((props: MangaCardProps) => {
const { updateLibraryState, isInLibrary } = useManageMangaLibraryState(manga, mode === 'source'); const { updateLibraryState, isInLibrary } = useManageMangaLibraryState(manga, mode === 'source');
const mangaLinkTo = getMangaLinkTo(mode, manga.id, manga.sourceId, manga.title); const mangaLinkTo = getMangaLinkTo(mode, manga.id);
const handleClick = useCallback( const handleClick = useCallback(
(event: React.MouseEvent | React.TouchEvent, openMenu?: () => void) => { (event: React.MouseEvent | React.TouchEvent, openMenu?: () => void) => {
const isDefaultMode = mode === 'default'; const isDefaultMode = mode === 'default';
const isSourceMode = mode === 'source'; const isSourceMode = mode === 'source';
const isMigrateSelectMode = mode === 'migrate.select'; const isMigrationSelectSingleMode = mode === 'migrate.select.single';
const isMigrationSelectBulkMode = mode === 'migrate.select.bulk';
const isMigrateSelectMode = isMigrationSelectSingleMode || isMigrationSelectBulkMode;
const isSelectionMode = selected !== null; const isSelectionMode = selected !== null;
const isLongPress = !!openMenu; const isLongPress = !!openMenu;
@@ -107,8 +104,8 @@ export const MangaCard = memo((props: MangaCardProps) => {
} }
if (isMigrateSelectMode) { if (isMigrateSelectMode) {
const isBulkMigrationManualSearch = !!onMigrateSelect; if (isMigrationSelectBulkMode) {
if (isBulkMigrationManualSearch) { assertIsDefined(onMigrateSelect);
onMigrateSelect({ ...manga, missingChapters: undefined }); onMigrateSelect({ ...manga, missingChapters: undefined });
return; return;
} }

View File

@@ -323,6 +323,7 @@ export class MigrationManager {
{ {
state: AppRoutes.migrate.children.singleMangaSearch.state({ state: AppRoutes.migrate.children.singleMangaSearch.state({
title: t`Migrate "${manga.title}"`, title: t`Migrate "${manga.title}"`,
mode: 'migrate.select.single',
}), }),
}, },
); );

View File

@@ -164,7 +164,7 @@ export const MigrationSelectMangas = () => {
return ( return (
<> <>
<BaseMangaGrid <BaseMangaGrid
mode="migrate.select" mode="migrate.select.bulk"
hasNextPage={false} hasNextPage={false}
loadMore={noOp} loadMore={noOp}
isLoading={areMangasLoading} isLoading={areMangasLoading}

View File

@@ -16,7 +16,7 @@ import type {
SourcePreferenceChangeInput, SourcePreferenceChangeInput,
SourceType, SourceType,
} from '@/lib/graphql/generated/graphql-base.types.ts'; } from '@/lib/graphql/generated/graphql-base.types.ts';
import type { MangaCardMode } from '@/features/manga/Manga.types.ts'; import type { MangaCardMode, MangaIdInfo } from '@/features/manga/Manga.types.ts';
export interface IPos { export interface IPos {
type: 'selectState' | 'textState' | 'checkBoxState' | 'triState' | 'sortState'; type: 'selectState' | 'textState' | 'checkBoxState' | 'triState' | 'sortState';
@@ -86,4 +86,5 @@ export interface RouteStateSourceBrowse {
contentType?: SourceContentType; contentType?: SourceContentType;
clearCache?: boolean; clearCache?: boolean;
mode?: MangaCardMode; mode?: MangaCardMode;
mangaId?: MangaIdInfo['id'];
} }

View File

@@ -58,6 +58,8 @@ import { useAppTitleAndAction } from '@/features/navigation-bar/hooks/useAppTitl
import { useNavBarContext } from '@/features/navigation-bar/NavbarContext.tsx'; import { useNavBarContext } from '@/features/navigation-bar/NavbarContext.tsx';
import { VirtuosoUtil } from '@/lib/virtuoso/Virtuoso.util.tsx'; import { VirtuosoUtil } from '@/lib/virtuoso/Virtuoso.util.tsx';
import { IconWebView } from '@/assets/icons/IconWebView.tsx'; import { IconWebView } from '@/assets/icons/IconWebView.tsx';
import { MigrationManager } from '@/features/migration/MigrationManager.ts';
import { ReactRouter } from '@/lib/react-router/ReactRouter.ts';
const DEFAULT_SOURCE: SourceIdInfo = { id: '-1' }; const DEFAULT_SOURCE: SourceIdInfo = { id: '-1' };
@@ -216,6 +218,8 @@ export function SourceMangas() {
const { const {
contentType: initialContentType = SourceContentType.POPULAR, contentType: initialContentType = SourceContentType.POPULAR,
clearCache = false, clearCache = false,
mode = 'source',
mangaId,
} = useLocation<RouteStateSourceBrowse>().state ?? STABLE_EMPTY_OBJECT; } = useLocation<RouteStateSourceBrowse>().state ?? STABLE_EMPTY_OBJECT;
const { const {
@@ -485,8 +489,20 @@ export function SourceMangas() {
messageExtra={messageExtra} messageExtra={messageExtra}
isLoading={isLoading} isLoading={isLoading}
gridLayout={sourceGridLayout} gridLayout={sourceGridLayout}
mode="source" mode={mode}
inLibraryIndicator inLibraryIndicator
onMigrateSelect={
mangaId
? (match) => {
MigrationManager.selectManualMatch(mangaId, {
...match,
sourceTitle: Sources.getFromCache(match.sourceId)?.displayName,
latestChapterNumber: undefined,
});
ReactRouter.navigate(AppRoutes.migrate.path);
}
: undefined
}
/> />
)} )}
{error && ( {error && (