refactor(web): routes (#25365)

This commit is contained in:
Jason Rasmussen
2026-01-19 12:07:31 -05:00
committed by GitHub
parent a8198f9934
commit 4a7c4b6d15
18 changed files with 96 additions and 78 deletions

View File

@@ -1,9 +1,8 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { shortcut } from '$lib/actions/shortcut';
import { AppRoute } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import { Route } from '$lib/route';
import { removeTag } from '$lib/utils/asset-utils';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { Icon, modalManager } from '@immich/ui';
@@ -46,7 +45,7 @@
<div class="flex group transition-all">
<a
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary rounded-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
href={resolve(`${AppRoute.TAGS}/?path=${encodeURI(tag.value)}`)}
href={Route.tags({ path: tag.value })}
>
<p class="text-sm">
{tag.value}

View File

@@ -1,11 +1,10 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import DetailPanelDescription from '$lib/components/asset-viewer/detail-panel-description.svelte';
import DetailPanelLocation from '$lib/components/asset-viewer/detail-panel-location.svelte';
import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.svelte';
import DetailPanelTags from '$lib/components/asset-viewer/detail-panel-tags.svelte';
import { AppRoute, QueryParameter, timeToLoadTheMap } from '$lib/constants';
import { timeToLoadTheMap } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
@@ -73,6 +72,7 @@
})(),
);
let previousId: string | undefined = $state();
let previousRoute = $derived(currentAlbum?.id ? Route.viewAlbum(currentAlbum) : Route.photos());
$effect(() => {
if (!previousId) {
@@ -100,11 +100,8 @@
};
const getAssetFolderHref = (asset: AssetResponseDto) => {
const folderUrl = new URL(AppRoute.FOLDERS, globalThis.location.href);
// Remove the last part of the path to get the parent path
const assetParentPath = getParentPath(asset.originalPath);
folderUrl.searchParams.set(QueryParameter.PATH, assetParentPath);
return folderUrl.href;
return Route.folders({ path: getParentPath(asset.originalPath) });
};
const toggleAssetPath = () => (showAssetPath = !showAssetPath);
@@ -205,11 +202,7 @@
{#if showingHiddenPeople || !person.isHidden}
<a
class="w-22"
href={resolve(
`${AppRoute.PEOPLE}/${person.id}?${QueryParameter.PREVIOUS_ROUTE}=${
currentAlbum?.id ? Route.viewAlbum(currentAlbum) : Route.photos()
}`,
)}
href={Route.viewPerson(person, { previousRoute })}
onfocus={() => ($boundingBoxesArray = people[index].faces)}
onblur={() => ($boundingBoxesArray = [])}
onmouseover={() => ($boundingBoxesArray = people[index].faces)}
@@ -472,7 +465,7 @@
simplified
useLocationPin
showSimpleControls={!showEditFaces}
onOpenInMapView={() => goto(resolve(`${AppRoute.MAP}#12.5/${latlng.lat}/${latlng.lng}`))}
onOpenInMapView={() => goto(Route.map({ ...latlng, zoom: 12.5 }))}
>
{#snippet popup({ marker })}
{@const { lat, lon } = marker}

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { ActionQueryParameterValue, AppRoute, QueryParameter } from '$lib/constants';
import { Route } from '$lib/route';
import { handleError } from '$lib/utils/handle-error';
import { getAllPeople, getPerson, mergePerson, type PersonResponseDto } from '@immich/sdk';
import { Button, Icon, IconButton, modalManager, toastManager } from '@immich/ui';
@@ -39,8 +38,7 @@
const handleSwapPeople = async () => {
[person, selectedPeople[0]] = [selectedPeople[0], person];
page.url.searchParams.set(QueryParameter.ACTION, ActionQueryParameterValue.MERGE);
await goto(`${AppRoute.PEOPLE}/${person.id}?${page.url.searchParams.toString()}`);
await goto(Route.viewPerson(person, { previousRoute: Route.people(), action: 'merge' }));
};
const onSelect = async (selected: PersonResponseDto) => {

View File

@@ -2,7 +2,7 @@
import { focusOutside } from '$lib/actions/focus-outside';
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import { Route } from '$lib/route';
import { getPersonActions } from '$lib/services/person.service';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { type PersonResponseDto } from '@immich/sdk';
@@ -42,7 +42,7 @@
use:focusOutside={{ onFocusOut: () => (showVerticalDots = false) }}
>
<a
href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={AppRoute.PEOPLE}"
href={Route.viewPerson(person, { previousRoute: Route.people() })}
draggable="false"
onfocus={() => (showVerticalDots = true)}
>

View File

@@ -2,7 +2,6 @@
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
import RecentAlbums from '$lib/components/shared-components/side-bar/recent-albums.svelte';
import Sidebar from '$lib/components/sidebar/sidebar.svelte';
import { AppRoute } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route';
import { recentAlbumsDropdown } from '$lib/stores/preferences.store';
@@ -45,11 +44,11 @@
{/if}
{#if featureFlagsManager.value.map}
<NavbarItem title={$t('map')} href={AppRoute.MAP} icon={mdiMapOutline} activeIcon={mdiMap} />
<NavbarItem title={$t('map')} href={Route.map()} icon={mdiMapOutline} activeIcon={mdiMap} />
{/if}
{#if $preferences.people.enabled && $preferences.people.sidebarWeb}
<NavbarItem title={$t('people')} href={AppRoute.PEOPLE} icon={mdiAccountOutline} activeIcon={mdiAccount} />
<NavbarItem title={$t('people')} href={Route.people()} icon={mdiAccountOutline} activeIcon={mdiAccount} />
{/if}
{#if $preferences.sharedLinks.enabled && $preferences.sharedLinks.sidebarWeb}
@@ -81,11 +80,11 @@
</NavbarItem>
{#if $preferences.tags.enabled && $preferences.tags.sidebarWeb}
<NavbarItem title={$t('tags')} href={AppRoute.TAGS} icon={{ icon: mdiTagMultipleOutline, flipped: true }} />
<NavbarItem title={$t('tags')} href={Route.tags()} icon={{ icon: mdiTagMultipleOutline, flipped: true }} />
{/if}
{#if $preferences.folders.enabled && $preferences.folders.sidebarWeb}
<NavbarItem title={$t('folders')} href={AppRoute.FOLDERS} icon={{ icon: mdiFolderOutline, flipped: true }} />
<NavbarItem title={$t('folders')} href={Route.folders()} icon={{ icon: mdiFolderOutline, flipped: true }} />
{/if}
<NavbarItem title={$t('utilities')} href={Route.utilities()} icon={mdiToolboxOutline} activeIcon={mdiToolbox} />

View File

@@ -19,16 +19,6 @@ export enum AssetAction {
RATING = 'rating',
}
export enum AppRoute {
PEOPLE = '/people',
SEARCH = '/search',
MAP = '/map',
BUY = '/buy',
FOLDERS = '/folders',
TAGS = '/tags',
MAINTENANCE = '/maintenance',
}
export type SharedLinkTab = 'all' | 'album' | 'individual';
export enum ProjectionType {
@@ -82,10 +72,6 @@ export enum OpenQueryParam {
PURCHASE_SETTINGS = 'user-purchase-settings',
}
export enum ActionQueryParameterValue {
MERGE = 'merge',
}
export const maximumLengthSearchPeople = 1000;
// time to load the map before displaying the loading spinner

View File

@@ -24,6 +24,20 @@ describe('Route', () => {
});
});
describe(Route.tags.name, () => {
it('should work', () => {
expect(Route.tags()).toBe('/tags');
});
it('should support query parameters', () => {
expect(Route.tags({ path: '/some/path' })).toBe('/tags?path=%2Fsome%2Fpath');
});
it('should ignore an empty path', () => {
expect(Route.tags({ path: '' })).toBe('/tags');
});
});
describe(Route.systemSettings.name, () => {
it('should work', () => {
expect(Route.systemSettings()).toBe('/admin/system-settings');

View File

@@ -14,9 +14,29 @@ export const fromQueueSlug = (slug: string): QueueName | undefined => {
};
type QueryValue = number | string;
const asQueryString = (params?: Record<string, QueryValue | undefined>) => {
const asQueryString = (
params?: Record<string, QueryValue | undefined>,
options?: { skipEmptyStrings?: boolean; skipNullValues?: boolean },
) => {
const { skipEmptyStrings = true, skipNullValues = true } = options ?? {};
const items = Object.entries(params ?? {})
.filter((item): item is [string, QueryValue] => item[1] !== undefined)
.filter((item): item is [string, QueryValue] => {
const value = item[1];
if (value === undefined) {
return false;
}
if (skipNullValues && value === null) {
return false;
}
if (skipEmptyStrings && value === '') {
return false;
}
return true;
})
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
return items.length === 0 ? '' : `?${items.join('&')}`;
@@ -36,22 +56,40 @@ export const Route = {
viewAlbumAsset: ({ albumId, assetId }: { albumId: string; assetId: string }) =>
`/albums/${albumId}/photos/${assetId}`,
// buy
buy: () => '/buy',
// explore
explore: () => '/explore',
places: () => '/places',
// folders
folders: (params?: { path?: string }) => '/folders' + asQueryString(params),
// libraries
libraries: () => '/admin/library-management',
newLibrary: () => '/admin/library-management/new',
viewLibrary: ({ id }: { id: string }) => `/admin/library-management/${id}`,
editLibrary: ({ id }: { id: string }) => `/admin/library-management/${id}/edit`,
// maintenance
maintenanceMode: (params?: { continue?: string }) => '/maintenance' + asQueryString(params),
// map
map: (point?: { zoom: number; lat: number; lng: number }) =>
'/map' + (point ? `#${point.zoom}/${point.lat}/${point.lng}` : ''),
// memories
memories: (params?: { id?: string }) => '/memory' + asQueryString(params),
// partners
viewPartner: ({ id }: { id: string }) => `/partners/${id}`,
// people
people: () => '/people',
viewPerson: ({ id }: { id: string }, params?: { previousRoute?: string; action?: 'merge' }) =>
`/people/${id}` + asQueryString(params),
// photos
photos: (params?: { at?: string }) => '/photos' + asQueryString(params),
viewAsset: ({ id }: { id: string }) => `/photos/${id}`,
@@ -82,6 +120,10 @@ export const Route = {
// system
systemSettings: (params?: { isOpen?: OpenQueryParam }) => '/admin/system-settings' + asQueryString(params),
systemStatistics: () => '/admin/server-status',
systemMaintenance: (params?: { continue?: string }) => '/admin/maintenance' + asQueryString(params),
// tags
tags: (params?: { path?: string }) => '/tags' + asQueryString(params),
// users
users: () => '/admin/users',

View File

@@ -1,7 +1,7 @@
import { page } from '$app/state';
import { AppRoute } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { Route } from '$lib/route';
import { notificationManager } from '$lib/stores/notification-manager.svelte';
import type { ReleaseEvent } from '$lib/types';
import { createEventEmitter } from '$lib/utils/eventemitter';
@@ -63,7 +63,7 @@ websocket
export const openWebsocketConnection = () => {
try {
if (get(user) || page.url.pathname.startsWith(AppRoute.MAINTENANCE)) {
if (get(user) || page.url.pathname.startsWith(Route.maintenanceMode())) {
websocket.connect();
}
} catch (error) {

View File

@@ -1,11 +1,9 @@
import { AppRoute } from '$lib/constants';
import { Route } from '$lib/route';
import { maintenanceAuth as maintenanceAuth$ } from '$lib/stores/maintenance.store';
import { maintenanceLogin } from '@immich/sdk';
export function maintenanceCreateUrl(url: URL) {
const target = new URL(AppRoute.MAINTENANCE, url.origin);
target.searchParams.set('continue', url.pathname + url.search);
return target.href;
return new URL(Route.maintenanceMode({ continue: url.pathname + url.search }), url.origin).href;
}
export function maintenanceReturnUrl(searchParams: URLSearchParams) {
@@ -13,7 +11,7 @@ export function maintenanceReturnUrl(searchParams: URLSearchParams) {
}
export function maintenanceShouldRedirect(maintenanceMode: boolean, currentUrl: URL | Location) {
return maintenanceMode !== currentUrl.pathname.startsWith(AppRoute.MAINTENANCE);
return maintenanceMode !== currentUrl.pathname.startsWith(Route.maintenanceMode());
}
export const loadMaintenanceAuth = async () => {

View File

@@ -3,7 +3,6 @@
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import SingleGridRow from '$lib/components/shared-components/single-grid-row.svelte';
import { AppRoute } from '$lib/constants';
import { Route } from '$lib/route';
import { websocketEvents } from '$lib/stores/websocket';
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
@@ -47,7 +46,7 @@
<div class="flex justify-between">
<p class="mb-4 font-medium dark:text-immich-dark-fg">{$t('people')}</p>
<a
href={AppRoute.PEOPLE}
href={Route.people()}
class="pe-4 text-sm font-medium hover:text-immich-primary dark:text-immich-dark-fg dark:hover:text-immich-dark-primary"
draggable="false">{$t('view_all')}</a
>
@@ -55,7 +54,7 @@
<SingleGridRow class="grid grid-flow-col md:grid-auto-fill-28 grid-auto-fill-20 gap-x-4">
{#snippet children({ itemCount })}
{#each people.slice(0, itemCount) as person (person.id)}
<a href="{AppRoute.PEOPLE}/{person.id}" class="text-center relative">
<a href={Route.viewPerson(person)} class="text-center relative">
<ImageThumbnail
circle
shadow

View File

@@ -19,9 +19,9 @@
import FavoriteAction from '$lib/components/timeline/actions/FavoriteAction.svelte';
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import SkipLink from '$lib/elements/SkipLink.svelte';
import type { Viewport } from '$lib/managers/timeline-manager/types';
import { Route } from '$lib/route';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { foldersStore } from '$lib/stores/folders.svelte';
import { preferences } from '$lib/stores/user.store';
@@ -44,11 +44,7 @@
const handleNavigateToFolder = (folderName: string) => navigateToView(joinPaths(data.tree.path, folderName));
function getLinkForPath(path: string) {
const url = new URL(AppRoute.FOLDERS, globalThis.location.href);
url.searchParams.set(QueryParameter.PATH, path);
return url.href;
}
const getLinkForPath = (path: string) => Route.folders({ path });
afterNavigate(function clearAssetSelection() {
// Clear the asset selection when we navigate (like going to another folder)

View File

@@ -10,8 +10,9 @@
import SearchPeople from '$lib/components/faces-page/people-search.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { ActionQueryParameterValue, AppRoute, QueryParameter, SessionStorageKey } from '$lib/constants';
import { QueryParameter, SessionStorageKey } from '$lib/constants';
import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte';
import { Route } from '$lib/route';
import { locale } from '$lib/stores/preferences.store';
import { websocketEvents } from '$lib/stores/websocket';
import { handlePromiseError } from '$lib/utils';
@@ -205,9 +206,7 @@
};
const handleMergePeople = async (detail: PersonResponseDto) => {
await goto(
`${AppRoute.PEOPLE}/${detail.id}?${QueryParameter.ACTION}=${ActionQueryParameterValue.MERGE}&${QueryParameter.PREVIOUS_ROUTE}=${AppRoute.PEOPLE}`,
);
await goto(Route.viewPerson(detail, { previousRoute: Route.people(), action: 'merge' }));
};
const onResetSearchBar = async () => {
@@ -300,7 +299,7 @@
[
scrollMemory,
{
routeStartsWith: AppRoute.PEOPLE,
routeStartsWith: Route.people(),
beforeSave: () => {
if (currentPage) {
sessionStorage.setItem(SessionStorageKey.INFINITE_SCROLL_PAGE, currentPage.toString());

View File

@@ -27,7 +27,7 @@
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants';
import { PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte';
@@ -219,7 +219,7 @@
await updateAssetCount();
return { merged: true };
}
await goto(`${AppRoute.PEOPLE}/${personToBeMergedInto.id}`, { replaceState: true });
await goto(Route.viewPerson(personToBeMergedInto), { replaceState: true });
return { merged: true };
};
@@ -339,7 +339,7 @@
<main
class="relative z-0 h-dvh overflow-hidden px-2 md:px-6 md:pt-(--navbar-height-md) pt-(--navbar-height)"
use:scrollMemoryClearer={{
routeStartsWith: AppRoute.PEOPLE,
routeStartsWith: Route.people(),
beforeClear: () => {
sessionStorage.removeItem(SessionStorageKey.INFINITE_SCROLL_PAGE);
},

View File

@@ -21,9 +21,10 @@
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import { AppRoute, AssetAction, QueryParameter } from '$lib/constants';
import { AssetAction } from '$lib/constants';
import SkipLink from '$lib/elements/SkipLink.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { Route } from '$lib/route';
import { getTagActions } from '$lib/services/tag.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { preferences, user } from '$lib/stores/user.store';
@@ -50,11 +51,7 @@
const handleNavigation = (tag: string) => navigateToView(joinPaths(data.path, tag));
const getLink = (path: string) => {
const url = new URL(AppRoute.TAGS, globalThis.location.href);
url.searchParams.set(QueryParameter.PATH, path);
return url.href;
};
const getLink = (path: string) => Route.tags({ path });
const navigateToView = (path: string) => goto(getLink(path));

View File

@@ -8,7 +8,6 @@
import AppleHeader from '$lib/components/shared-components/apple-header.svelte';
import NavigationLoadingBar from '$lib/components/shared-components/navigation-loading-bar.svelte';
import UploadPanel from '$lib/components/shared-components/upload-panel.svelte';
import { AppRoute } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { themeManager } from '$lib/managers/theme-manager.svelte';
@@ -89,7 +88,7 @@
});
$effect.pre(() => {
if ($user || page.url.pathname.startsWith(AppRoute.MAINTENANCE)) {
if ($user || page.url.pathname.startsWith(Route.maintenanceMode())) {
openWebsocketConnection();
} else {
closeWebsocketConnection();

View File

@@ -1,4 +1,3 @@
import { AppRoute } from '$lib/constants';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { Route } from '$lib/route';
import { getFormatter } from '$lib/utils/i18n';
@@ -15,7 +14,7 @@ export const load = (async ({ fetch }) => {
await init(fetch);
if (serverConfigManager.value.maintenanceMode) {
redirect(307, AppRoute.MAINTENANCE);
redirect(307, Route.maintenanceMode());
}
const authenticated = await loadUser();

View File

@@ -1,4 +1,4 @@
import { AppRoute, OpenQueryParam } from '$lib/constants';
import { OpenQueryParam } from '$lib/constants';
import { Route } from '$lib/route';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
@@ -34,7 +34,7 @@ export const load = (({ url }) => {
// https://my.immich.app/link?target=activate_license&licenseKey=IMCL-9XC3-T4S3-37BU-GGJ5-8MWP-F2Y1-BGEX-AQTF
const licenseKey = queryParams.get('licenseKey');
const activationKey = queryParams.get('activationKey');
const redirectUrl = new URL(AppRoute.BUY, url.origin);
const redirectUrl = new URL(Route.buy(), url.origin);
if (licenseKey) {
redirectUrl.searchParams.append('licenseKey', licenseKey);