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
@@ -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}
@@ -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}
@@ -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) => {
@@ -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)}
>
@@ -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} />
-14
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
+14
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');
+44 -2
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',
+2 -2
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) {
+3 -5
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 () => {