Files
immich/web/src/lib/utils/navigation.ts
T
Mees Frensel 3d320d9751 fix(web): fix shared link /s/photos.* navigation after password login (#27788)
* fix(web): fix shared link navigation after password login

* use regex after all

* chore: use special case for shared link with slug route

* dont use onMount

* fix lint

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-05-02 09:26:35 -04:00

162 lines
6.0 KiB
TypeScript

import { goto } from '$app/navigation';
import { page } from '$app/state';
import type { RouteId } from '$app/types';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { Route } from '$lib/route';
export type AssetGridRouteSearchParams = {
at: string | null | undefined;
};
export const isExternalUrl = (url: string): boolean => {
return new URL(url, globalThis.location.href).origin !== globalThis.location.origin;
};
export const isPhotosRoute = (route?: string | null) => !!route?.startsWith('/(user)/photos/[[assetId=id]]');
const isSharedLinkSlugRoute = (route?: string | null) => !!route?.startsWith('/(user)/s/[slug]');
export const isSharedLinkRoute = (route?: string | null) =>
!!route?.startsWith('/(user)/share/[key]') || isSharedLinkSlugRoute(route);
export const isSearchRoute = (route?: string | null) => !!route?.startsWith('/(user)/search');
export const isAlbumsRoute = (route?: string | null) => !!route?.startsWith('/(user)/albums/[albumId=id]');
export const isPeopleRoute = (route?: string | null) => !!route?.startsWith('/(user)/people/[personId]');
export const isLockedFolderRoute = (route?: string | null) => !!route?.startsWith('/(user)/locked');
export const isAssetViewerRoute = (
target?: { route?: { id?: RouteId | null }; params?: Record<string, string> | null } | null,
) => !!(target?.route?.id?.endsWith('/[[assetId=id]]') && 'assetId' in (target?.params || {}));
export function getAssetInfoFromParam({ assetId, slug, key }: { assetId?: string; key?: string; slug?: string }) {
return assetId ? assetCacheManager.getAsset({ id: assetId, slug, key }, false) : undefined;
}
function currentUrlWithoutAsset() {
// This contains special casing for the /photos/:assetId route, which hangs directly
// off / instead of a subpath, unlike every other asset-containing route.
if (isPhotosRoute(page.route.id)) {
return Route.photos() + page.url.search;
} else if (isSharedLinkSlugRoute(page.route.id)) {
return Route.viewSharedLink({ slug: page.data.slug, key: page.data.key }) + page.url.search;
} else {
return page.url.pathname.replace(/(\/photos.*)$/, '') + page.url.search;
}
}
export function currentUrlReplaceAssetId(assetId: string) {
const params = new URLSearchParams(page.url.search);
// always remove the assetGridScrollTargetParams
params.delete('at');
const paramsString = params.toString();
const searchparams = paramsString == '' ? '' : '?' + params.toString();
// this contains special casing for the /photos/:assetId photos route, which hangs directly
// off / instead of a subpath, unlike every other asset-containing route.
return isPhotosRoute(page.route.id)
? `${Route.viewAsset({ id: assetId })}${searchparams}`
: `${page.url.pathname.replace(/\/photos\/[^/]+$/, '')}/photos/${assetId}${searchparams}`;
}
function replaceScrollTarget(url: string, searchParams?: AssetGridRouteSearchParams | null) {
const parsed = new URL(url, page.url);
const { at: assetId } = searchParams || { at: null };
if (!assetId) {
return parsed.pathname;
}
const params = new URLSearchParams(page.url.search);
if (assetId) {
params.set('at', assetId);
}
return parsed.pathname + '?' + params.toString();
}
function currentUrl() {
const current = page.url;
return current.pathname + current.search + current.hash;
}
interface Route {
/**
* The route to target, or 'current' to stay on current route.
*/
targetRoute: string | 'current';
}
interface AssetRoute extends Route {
targetRoute: 'current';
assetId: string | null | undefined;
}
interface AssetGridRoute extends Route {
targetRoute: 'current';
assetId: string | null | undefined;
assetGridRouteSearchParams: AssetGridRouteSearchParams | null | undefined;
}
type ImmichRoute = AssetRoute | AssetGridRoute;
type NavOptions = {
/* navigate even if url is the same */
forceNavigate?: boolean | undefined;
replaceState?: boolean | undefined;
noScroll?: boolean | undefined;
keepFocus?: boolean | undefined;
invalidateAll?: boolean | undefined;
state?: App.PageState | undefined;
};
function isAssetRoute(route: Route): route is AssetRoute {
return route.targetRoute === 'current' && 'assetId' in route;
}
function isAssetGridRoute(route: Route): route is AssetGridRoute {
return route.targetRoute === 'current' && 'assetId' in route && 'assetGridRouteSearchParams' in route;
}
async function navigateAssetRoute(route: AssetRoute, options?: NavOptions) {
const { assetId } = route;
const next = assetId ? currentUrlReplaceAssetId(assetId) : currentUrlWithoutAsset();
const current = currentUrl();
if (next !== current || options?.forceNavigate) {
await goto(next, options);
}
}
async function navigateAssetGridRoute(route: AssetGridRoute, options?: NavOptions) {
const { assetId, assetGridRouteSearchParams: assetGridScrollTarget } = route;
const assetUrl = assetId ? currentUrlReplaceAssetId(assetId) : currentUrlWithoutAsset();
const next = replaceScrollTarget(assetUrl, assetGridScrollTarget);
const current = currentUrl();
if (next !== current || options?.forceNavigate) {
await goto(next, options);
}
}
export function navigate(change: ImmichRoute, options?: NavOptions): Promise<void> {
if (isAssetGridRoute(change)) {
return navigateAssetGridRoute(change, options);
} else if (isAssetRoute(change)) {
return navigateAssetRoute(change, options);
}
// future navigation requests here
throw `Invalid navigation: ${JSON.stringify(change)}`;
}
export const clearQueryParam = async (queryParam: string, url: URL) => {
if (url.searchParams.has(queryParam)) {
url.searchParams.delete(queryParam);
await goto(url, { keepFocus: true });
}
};
export const getQueryValue = (queryKey: string) => {
const url = globalThis.location.href;
const urlObject = new URL(url);
return urlObject.searchParams.get(queryKey);
};
export const setQueryValue = async (queryKey: string, queryValue: string) => {
const url = globalThis.location.href;
const urlObject = new URL(url);
urlObject.searchParams.set(queryKey, queryValue);
await goto(urlObject, { keepFocus: true });
};