feat: add a timeline to the map

This commit is contained in:
martabal 2024-09-07 13:42:39 +02:00
parent 233372303b
commit ab0080a2c9
No known key found for this signature in database
GPG Key ID: C00196E3148A52BD
24 changed files with 768 additions and 186 deletions

View File

@ -44,7 +44,15 @@ class TimelineApi {
/// * [bool] withPartners:
///
/// * [bool] withStacked:
Future<Response> getTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async {
///
/// * [double] x1:
///
/// * [double] x2:
///
/// * [double] y1:
///
/// * [double] y2:
Future<Response> getTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, double? x1, double? x2, double? y1, double? y2, }) async {
// ignore: prefer_const_declarations
final path = r'/timeline/bucket';
@ -90,6 +98,18 @@ class TimelineApi {
if (withStacked != null) {
queryParams.addAll(_queryParams('', 'withStacked', withStacked));
}
if (x1 != null) {
queryParams.addAll(_queryParams('', 'x1', x1));
}
if (x2 != null) {
queryParams.addAll(_queryParams('', 'x2', x2));
}
if (y1 != null) {
queryParams.addAll(_queryParams('', 'y1', y1));
}
if (y2 != null) {
queryParams.addAll(_queryParams('', 'y2', y2));
}
const contentTypes = <String>[];
@ -132,8 +152,16 @@ class TimelineApi {
/// * [bool] withPartners:
///
/// * [bool] withStacked:
Future<List<AssetResponseDto>?> getTimeBucket(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async {
final response = await getTimeBucketWithHttpInfo(size, timeBucket, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, withPartners: withPartners, withStacked: withStacked, );
///
/// * [double] x1:
///
/// * [double] x2:
///
/// * [double] y1:
///
/// * [double] y2:
Future<List<AssetResponseDto>?> getTimeBucket(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, double? x1, double? x2, double? y1, double? y2, }) async {
final response = await getTimeBucketWithHttpInfo(size, timeBucket, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, withPartners: withPartners, withStacked: withStacked, x1: x1, x2: x2, y1: y1, y2: y2, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
@ -176,7 +204,15 @@ class TimelineApi {
/// * [bool] withPartners:
///
/// * [bool] withStacked:
Future<Response> getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async {
///
/// * [double] x1:
///
/// * [double] x2:
///
/// * [double] y1:
///
/// * [double] y2:
Future<Response> getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, double? x1, double? x2, double? y1, double? y2, }) async {
// ignore: prefer_const_declarations
final path = r'/timeline/buckets';
@ -221,6 +257,18 @@ class TimelineApi {
if (withStacked != null) {
queryParams.addAll(_queryParams('', 'withStacked', withStacked));
}
if (x1 != null) {
queryParams.addAll(_queryParams('', 'x1', x1));
}
if (x2 != null) {
queryParams.addAll(_queryParams('', 'x2', x2));
}
if (y1 != null) {
queryParams.addAll(_queryParams('', 'y1', y1));
}
if (y2 != null) {
queryParams.addAll(_queryParams('', 'y2', y2));
}
const contentTypes = <String>[];
@ -261,8 +309,16 @@ class TimelineApi {
/// * [bool] withPartners:
///
/// * [bool] withStacked:
Future<List<TimeBucketResponseDto>?> getTimeBuckets(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async {
final response = await getTimeBucketsWithHttpInfo(size, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, withPartners: withPartners, withStacked: withStacked, );
///
/// * [double] x1:
///
/// * [double] x2:
///
/// * [double] y1:
///
/// * [double] y2:
Future<List<TimeBucketResponseDto>?> getTimeBuckets(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, double? x1, double? x2, double? y1, double? y2, }) async {
final response = await getTimeBucketsWithHttpInfo(size, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, withPartners: withPartners, withStacked: withStacked, x1: x1, x2: x2, y1: y1, y2: y2, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

View File

@ -6632,6 +6632,50 @@
"schema": {
"type": "boolean"
}
},
{
"name": "x1",
"required": false,
"in": "query",
"schema": {
"minimum": -180,
"maximum": 180,
"format": "double",
"type": "number"
}
},
{
"name": "x2",
"required": false,
"in": "query",
"schema": {
"minimum": -180,
"maximum": 180,
"format": "double",
"type": "number"
}
},
{
"name": "y1",
"required": false,
"in": "query",
"schema": {
"minimum": -90,
"maximum": 90,
"format": "double",
"type": "number"
}
},
{
"name": "y2",
"required": false,
"in": "query",
"schema": {
"minimum": -90,
"maximum": 90,
"format": "double",
"type": "number"
}
}
],
"responses": {
@ -6768,6 +6812,50 @@
"schema": {
"type": "boolean"
}
},
{
"name": "x1",
"required": false,
"in": "query",
"schema": {
"minimum": -180,
"maximum": 180,
"format": "double",
"type": "number"
}
},
{
"name": "x2",
"required": false,
"in": "query",
"schema": {
"minimum": -180,
"maximum": 180,
"format": "double",
"type": "number"
}
},
{
"name": "y1",
"required": false,
"in": "query",
"schema": {
"minimum": -90,
"maximum": 90,
"format": "double",
"type": "number"
}
},
{
"name": "y2",
"required": false,
"in": "query",
"schema": {
"minimum": -90,
"maximum": 90,
"format": "double",
"type": "number"
}
}
],
"responses": {

View File

@ -2986,7 +2986,7 @@ export function tagAssets({ id, bulkIdsDto }: {
body: bulkIdsDto
})));
}
export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, timeBucket, userId, withPartners, withStacked }: {
export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, timeBucket, userId, withPartners, withStacked, x1, x2, y1, y2 }: {
albumId?: string;
isArchived?: boolean;
isFavorite?: boolean;
@ -3000,6 +3000,10 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key,
userId?: string;
withPartners?: boolean;
withStacked?: boolean;
x1?: number;
x2?: number;
y1?: number;
y2?: number;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
@ -3017,12 +3021,16 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key,
timeBucket,
userId,
withPartners,
withStacked
withStacked,
x1,
x2,
y1,
y2
}))}`, {
...opts
}));
}
export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, userId, withPartners, withStacked }: {
export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, userId, withPartners, withStacked, x1, x2, y1, y2 }: {
albumId?: string;
isArchived?: boolean;
isFavorite?: boolean;
@ -3035,6 +3043,10 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key
userId?: string;
withPartners?: boolean;
withStacked?: boolean;
x1?: number;
x2?: number;
y1?: number;
y2?: number;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
@ -3051,7 +3063,11 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key
tagId,
userId,
withPartners,
withStacked
withStacked,
x1,
x2,
y1,
y2
}))}`, {
...opts
}));

View File

@ -1,5 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { Type } from 'class-transformer';
import { IsEnum, IsNotEmpty, IsNumber, IsString, Max, Min } from 'class-validator';
import { AssetOrder } from 'src/enum';
import { TimeBucketSize } from 'src/interfaces/asset.interface';
import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
@ -41,6 +42,38 @@ export class TimeBucketDto {
@Optional()
@ApiProperty({ enum: AssetOrder, enumName: 'AssetOrder' })
order?: AssetOrder;
@Optional()
@IsNumber()
@Min(-180)
@Max(180)
@Type(() => Number)
@ApiProperty({ type: 'number', format: 'double' })
x1?: number;
@Optional()
@IsNumber()
@Min(-90)
@Max(90)
@Type(() => Number)
@ApiProperty({ type: 'number', format: 'double' })
y1?: number;
@Optional()
@IsNumber()
@Min(-180)
@Max(180)
@Type(() => Number)
@ApiProperty({ type: 'number', format: 'double' })
x2?: number;
@Optional()
@IsNumber()
@Min(-90)
@Max(90)
@Type(() => Number)
@ApiProperty({ type: 'number', format: 'double' })
y2?: number;
}
export class TimeBucketAssetDto extends TimeBucketDto {

View File

@ -57,6 +57,10 @@ export interface AssetBuilderOptions {
withStacked?: boolean;
exifInfo?: boolean;
assetType?: AssetType;
x1?: number;
x2?: number;
y1?: number;
y2?: number;
}
export interface TimeBucketOptions extends AssetBuilderOptions {

View File

@ -694,7 +694,13 @@ FROM
LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id"
AND ("stackedAssets"."deletedAt" IS NULL)
WHERE
("asset"."isVisible" = true)
(
"asset"."isVisible" = true
AND "exifInfo"."longitude" > $1
AND "exifInfo"."longitude" < $2
AND "exifInfo"."latitude" > $3
AND "exifInfo"."latitude" < $4
)
AND ("asset"."deletedAt" IS NULL)
GROUP BY
(

View File

@ -633,7 +633,7 @@ export class AssetRepository implements IAssetRepository {
return builder.orderBy('RANDOM()').limit(count).getMany();
}
@GenerateSql({ params: [{ size: TimeBucketSize.MONTH }] })
@GenerateSql({ params: [{ size: TimeBucketSize.MONTH, x1: 1, x2: 2, y1: 2, y2: 1 }] })
getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]> {
const truncated = dateTrunc(options);
return this.getBuilder(options)
@ -789,6 +789,34 @@ export class AssetRepository implements IAssetRepository {
);
}
if (options.x1 && options.x2 && options.y1 && options.y2) {
/**
/* the API already makes sure that -180 < x1, x2 < 180
/* the first case is when you search an asset with the International Date Line (x1 is on the west side, x2 on the east)
*/
if (options.x1 > options.x2) {
builder.andWhere(
'(exifInfo.longitude > :x1 OR exifInfo.longitude < :x2) AND exifInfo.latitude > :y2 AND exifInfo.latitude < :y1',
{
x1: options.x1,
x2: options.x2,
y1: options.y1,
y2: options.y2,
},
);
} else {
builder.andWhere(
'exifInfo.longitude > :x1 AND exifInfo.longitude < :x2 AND exifInfo.latitude > :y2 AND exifInfo.latitude < :y1',
{
x1: options.x1,
x2: options.x2,
y1: options.y1,
y2: options.y2,
},
);
}
}
return builder;
}

View File

@ -197,8 +197,8 @@
<a
class="w-[90px]"
href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={currentAlbum?.id
? `${AppRoute.ALBUMS}/${currentAlbum?.id}`
: AppRoute.PHOTOS}"
? `${encodeURIComponent(`${AppRoute.ALBUMS}/${currentAlbum?.id}`)}`
: encodeURIComponent(AppRoute.PHOTOS)}"
on:focus={() => ($boundingBoxesArray = people[index].faces)}
on:blur={() => ($boundingBoxesArray = [])}
on:mouseover={() => ($boundingBoxesArray = people[index].faces)}

View File

@ -42,7 +42,7 @@
use:focusOutside={{ onFocusOut: () => (showVerticalDots = false) }}
>
<a
href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={AppRoute.PEOPLE}"
href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={encodeURIComponent(AppRoute.PEOPLE)}"
draggable="false"
on:focus={() => (showVerticalDots = true)}
>

View File

@ -14,6 +14,7 @@
export let description: string | undefined = undefined;
export let scrollbar = true;
export let admin = false;
export let showCustomSidebar: boolean = false;
$: scrollbarClass = scrollbar ? 'immich-scrollbar p-2 pb-8' : 'scrollbar-hidden';
$: hasTitleClass = title ? 'top-16 h-[calc(100%-theme(spacing.16))]' : 'top-0 h-full';
@ -34,7 +35,9 @@
{#if admin}
<AdminSideBar />
{:else}
<SideBar />
<SideBar {showCustomSidebar}>
<slot name="customSidebar" slot="customSidebar" />
</SideBar>
{/if}
</slot>

View File

@ -4,17 +4,16 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { Theme } from '$lib/constants';
import { AppRoute, AssetGridOptionsValues, QueryParameter, Theme } from '$lib/constants';
import { colorTheme, mapSettings } from '$lib/stores/preferences.store';
import { getAssetThumbnailUrl, getKey, handlePromiseError } from '$lib/utils';
import { getMapStyle, MapTheme, type MapMarkerResponseDto } from '@immich/sdk';
import mapboxRtlUrl from '@mapbox/mapbox-gl-rtl-text/mapbox-gl-rtl-text.min.js?url';
import { mdiCog, mdiMap, mdiMapMarker } from '@mdi/js';
import { mdiArrowDown, mdiArrowUp, mdiCog, mdiMap, mdiMapMarker, mdiOpenInNew } from '@mdi/js';
import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson';
import type { GeoJSONSource, LngLatLike, StyleSpecification } from 'maplibre-gl';
import maplibregl from 'maplibre-gl';
import { createEventDispatcher } from 'svelte';
import { t } from 'svelte-i18n';
import { createEventDispatcher, onMount } from 'svelte';
import {
AttributionControl,
Control,
@ -30,6 +29,11 @@
ScaleControl,
type Map,
} from 'svelte-maplibre';
import { t } from 'svelte-i18n';
import Button from '$lib/components/elements/buttons/button.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
import { AssetStore } from '$lib/stores/assets.store';
import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
export let mapMarkers: MapMarkerResponseDto[];
export let showSettingsModal: boolean | undefined = undefined;
@ -39,6 +43,19 @@
export let simplified = false;
export let clickable = false;
export let useLocationPin = false;
// if you want to have the assetGrid with the map
export let showAssetGrid = false;
// to open the assetGrid on pageLoad
export let isAssetGridOpenedOnInit = false;
onMount(() => {
if (isAssetGridOpenedOnInit) {
handleOpenAssetGird();
}
});
export function addClipMapMarker(lng: number, lat: number) {
if (map) {
if (marker) {
@ -56,6 +73,34 @@
let map: maplibregl.Map;
let marker: maplibregl.Marker | null = null;
let x1: number | undefined = undefined;
let x2: number | undefined = undefined;
let y1: number | undefined = undefined;
let y2: number | undefined = undefined;
let currentAssets: MapMarkerResponseDto[] = [];
let previousUrl: string | undefined = undefined;
let isAssetGridOpened: boolean = false;
let numberOfAssets: number | undefined = undefined;
let timelineStore: AssetStore | undefined = undefined;
const timelineInteractionStore = createAssetInteractionStore();
$: {
let url = `${AppRoute.PHOTOS}?${QueryParameter.COORDINATESX1}=${x1}&${QueryParameter.COORDINATESX2}=${x2}&${QueryParameter.COORDINATESY1}=${y1}&${QueryParameter.COORDINATESY2}=${y2}&${QueryParameter.PREVIOUS_ROUTE}=${encodeURIComponent(`${AppRoute.MAP}?${QueryParameter.IS_TIMELINE_OPENED}=${isAssetGridOpened}${location.hash}`)}`;
let options: string[] = [];
if ($mapSettings.withPartners) {
options.push(AssetGridOptionsValues.withPartners);
}
if ($mapSettings.onlyFavorites) {
options.push(AssetGridOptionsValues.onlyFavorites);
}
if (options.length > 0) {
url += `&${QueryParameter.ASSET_GRID_OPTIONS}=${options.join(',')}`;
}
previousUrl = url;
}
$: style = (() =>
getMapStyle({
theme: ($mapSettings.allowDarkMode ? $colorTheme.value : Theme.LIGHT) as unknown as MapTheme,
@ -125,6 +170,84 @@
country: featurePoint.properties.country,
};
};
const isAssetinBounds = (mapMarker: MapMarkerResponseDto, x1: number, x2: number, y1: number, y2: number) => {
// if x1 is before the international date line, and x2 after it, you want to check if the asset is after x1 OR after x2
const isLonWithinBounds =
x1 > x2 ? mapMarker.lon >= x1 || mapMarker.lon <= x2 : mapMarker.lon >= x1 && mapMarker.lon <= x2;
const isLatWithinBounds = mapMarker.lat >= y2 && mapMarker.lat <= y1;
return isLonWithinBounds && isLatWithinBounds;
};
const changeBounds = () => {
if (!showAssetGrid) {
return;
}
if (map) {
const bounds = map.getBounds();
let lng_e: number, lng_w: number, lat_e: number, lat_w: number;
/*
/*
/* longitude and latitude can be >180 and <180 with maplibre
/* that part fixes it to always have longitude coordinates -180 < x1, x2 < 180
/*
*/
if (Math.abs(bounds._ne.lng) + Math.abs(bounds._sw.lng) > 360) {
lng_e = -180;
lng_w = 180;
} else if (Math.abs(bounds._sw.lng) > 180) {
lng_e = bounds._sw.lng + 360;
lng_w = bounds._ne.lng;
} else if (Math.abs(bounds._ne.lng) > 180) {
lng_e = bounds._sw.lng;
lng_w = bounds._ne.lng - 360;
} else {
lng_e = bounds._sw.lng;
lng_w = bounds._ne.lng;
}
lat_e = bounds._ne.lat;
lat_w = bounds._sw.lat;
// TODO: get the number of assets from the server?
let assetsInBounds = mapMarkers.filter((mapMarker) => isAssetinBounds(mapMarker, lng_e, lng_w, lat_e, lat_w));
numberOfAssets = assetsInBounds.length;
[x1, x2, y1, y2] = [lng_e, lng_w, lat_e, lat_w];
// refresh only if the assets displayed on screen has changed to avoid refresh the AssetGrid when nothing has changed on screen
if (currentAssets.length === 0 || assetsInBounds.toString() !== currentAssets.toString()) {
newGrid();
currentAssets = assetsInBounds;
}
}
};
const newGrid = () => {
// TODO: missing includeArchived and withSharedAlbums
const isFavorite = $mapSettings.onlyFavorites ? true : undefined;
timelineStore = new AssetStore({
isArchived: false,
withPartners: $mapSettings.withPartners,
isFavorite,
x1,
x2,
y1,
y2,
});
};
const handleOpenAssetGird = () => {
isAssetGridOpened = !isAssetGridOpened;
if (isAssetGridOpened) {
changeBounds();
newGrid();
}
};
</script>
{#await style then style}
@ -140,6 +263,7 @@
on:load={(event) => event.detail.setMaxZoom(18)}
on:load={(event) => event.detail.on('click', handleMapClick)}
bind:map
on:moveend={changeBounds}
>
<NavigationControl position="top-left" showCompass={!simplified} />
@ -222,6 +346,51 @@
{/if}
</MarkerLayer>
</GeoJSON>
{#if showAssetGrid && !$mapSettings.withSharedAlbums && !$mapSettings.includeArchived}
<div
class="absolute transition ease-in-out inset-x-0 bottom-0 px-2 rounded-t-lg rounded-x-lg z-50 {isAssetGridOpened
? 'h-[50%] bg-immich-bg dark:bg-immich-dark-bg border-t-2 border-x-2 dark:border-immich-dark-gray'
: ''}"
>
<div class="grid grid-cols-3 gap-4 py-2 w-full content-start dark:text-immich-dark-primary">
<div>
{#if isAssetGridOpened}
<div
title={$t('number_of_assets')}
class="bg-immich-primary dark:bg-immich-dark-primary text-white dark:text-immich-dark-gray text-center h-8 w-8 p-2 rounded-full"
>
{numberOfAssets}
</div>
{/if}
</div>
<div class=" text-white text-center">
<Button class="h-14 w-14 p-2" rounded="full" size="tiny" on:click={handleOpenAssetGird}>
<Icon path={isAssetGridOpened ? mdiArrowDown : mdiArrowUp} size="36" />
</Button>
</div>
<div class="text-right">
{#if isAssetGridOpened && numberOfAssets && previousUrl}
<a href={previousUrl}>
<Button class="h-8 w-8 p-2 " rounded="full" size="tiny" on:click={handleOpenAssetGird}>
<Icon path={mdiOpenInNew} size="12" />
</Button>
</a>
{/if}
</div>
</div>
{#if isAssetGridOpened && timelineStore}
{#key timelineStore}
<AssetGrid
enableRouting={false}
isSelectionMode={false}
assetStore={timelineStore}
assetInteractionStore={timelineInteractionStore}
/>
{/key}
{/if}
</div>
{/if}
</MapLibre>
<style>
.location-pin {

View File

@ -0,0 +1,61 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import Icon from '$lib/components/elements/icon.svelte';
import { mdiInformationOutline } from '@mdi/js';
export let title: string;
export let icon: string;
export let flippedLogo = false;
export let isSelected = false;
export let onClick: (() => void) | undefined = undefined;
export let moreInformation: boolean;
let showMoreInformation = false;
</script>
<button
type="button"
draggable="false"
aria-current={isSelected ? 'page' : undefined}
class="flex w-full place-items-center justify-between gap-4 rounded-r-full py-3 transition-[padding] delay-100 duration-100 hover:cursor-pointer hover:bg-immich-gray hover:text-immich-primary dark:text-immich-dark-fg dark:hover:bg-immich-dark-gray dark:hover:text-immich-dark-primary
{isSelected
? 'bg-immich-primary/10 text-immich-primary hover:bg-immich-primary/10 dark:bg-immich-dark-primary/10 dark:text-immich-dark-primary'
: ''}
pl-5 group-hover:sm:px-5 md:px-5
"
on:click={onClick}
>
<div class="flex w-full place-items-center gap-4 overflow-hidden truncate">
<Icon path={icon} size="1.5em" class="shrink-0" flipped={flippedLogo} ariaHidden />
<span class="text-sm font-medium">{title}</span>
</div>
<div
class="h-0 overflow-hidden transition-[height] delay-1000 duration-100 sm:group-hover:h-auto group-hover:sm:overflow-visible md:h-auto md:overflow-visible"
>
{#if moreInformation}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="relative flex cursor-default select-none justify-center"
on:mouseenter={() => (showMoreInformation = true)}
on:mouseleave={() => (showMoreInformation = false)}
>
<div class="p-1 text-gray-600 hover:cursor-help dark:text-gray-400">
<Icon path={mdiInformationOutline} />
</div>
{#if showMoreInformation}
<div class="absolute right-6 top-0">
<div
class="flex place-content-center place-items-center whitespace-nowrap rounded-3xl border bg-immich-bg px-6 py-3 text-xs text-immich-fg shadow-lg dark:border-immich-dark-gray dark:bg-gray-600 dark:text-immich-dark-fg"
class:hidden={!showMoreInformation}
transition:fade={{ duration: 200 }}
>
<slot name="moreInformation" />
</div>
</div>
{/if}
</div>
{/if}
</div>
</button>

View File

@ -1,9 +1,7 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import Icon from '$lib/components/elements/icon.svelte';
import { mdiInformationOutline } from '@mdi/js';
import { resolveRoute } from '$app/paths';
import { page } from '$app/stores';
import SideBarButton from '$lib/components/shared-components/side-bar/side-bar-button.svelte';
export let title: string;
export let routeId: string;
@ -12,7 +10,6 @@
export let isSelected = false;
export let preloadData = true;
let showMoreInformation = false;
$: routePath = resolveRoute(routeId, {});
$: isSelected = ($page.route.id?.match(/^\/(admin|\(user\))\/[^/]*/) || [])[0] === routeId;
</script>
@ -22,44 +19,8 @@
data-sveltekit-preload-data={preloadData ? 'hover' : 'off'}
draggable="false"
aria-current={isSelected ? 'page' : undefined}
class="flex w-full place-items-center justify-between gap-4 rounded-r-full py-3 transition-[padding] delay-100 duration-100 hover:cursor-pointer hover:bg-immich-gray hover:text-immich-primary dark:text-immich-dark-fg dark:hover:bg-immich-dark-gray dark:hover:text-immich-dark-primary
{isSelected
? 'bg-immich-primary/10 text-immich-primary hover:bg-immich-primary/10 dark:bg-immich-dark-primary/10 dark:text-immich-dark-primary'
: ''}
pl-5 group-hover:sm:px-5 md:px-5
"
>
<div class="flex w-full place-items-center gap-4 overflow-hidden truncate">
<Icon path={icon} size="1.5em" class="shrink-0" flipped={flippedLogo} ariaHidden />
<span class="text-sm font-medium">{title}</span>
</div>
<div
class="h-0 overflow-hidden transition-[height] delay-1000 duration-100 sm:group-hover:h-auto group-hover:sm:overflow-visible md:h-auto md:overflow-visible"
>
{#if $$slots.moreInformation}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="relative flex cursor-default select-none justify-center"
on:mouseenter={() => (showMoreInformation = true)}
on:mouseleave={() => (showMoreInformation = false)}
>
<div class="p-1 text-gray-600 hover:cursor-help dark:text-gray-400">
<Icon path={mdiInformationOutline} />
</div>
{#if showMoreInformation}
<div class="absolute right-6 top-0">
<div
class="flex place-content-center place-items-center whitespace-nowrap rounded-3xl border bg-immich-bg px-6 py-3 text-xs text-immich-fg shadow-lg dark:border-immich-dark-gray dark:bg-gray-600 dark:text-immich-dark-fg"
class:hidden={!showMoreInformation}
transition:fade={{ duration: 200 }}
>
<slot name="moreInformation" />
</div>
</div>
{/if}
</div>
{/if}
</div>
<SideBarButton {title} {icon} {flippedLogo} {isSelected} moreInformation={$$slots.moreInformation}>
<slot name="moreInformation" slot="moreInformation" />
</SideBarButton>
</a>

View File

@ -38,113 +38,119 @@
let isSharingSelected: boolean;
let isTrashSelected: boolean;
let isUtilitiesSelected: boolean;
export let showCustomSidebar: boolean = false;
</script>
<SideBarSection>
<nav aria-label={$t('primary')}>
<SideBarLink
title={$t('photos')}
routeId="/(user)/photos"
bind:isSelected={isPhotosSelected}
icon={isPhotosSelected ? mdiImageMultiple : mdiImageMultipleOutline}
>
<svelte:fragment slot="moreInformation">
<MoreInformationAssets assetStats={{ isArchived: false }} />
</svelte:fragment>
</SideBarLink>
{#if $featureFlags.search}
<SideBarLink title={$t('explore')} routeId="/(user)/explore" icon={mdiMagnify} />
{/if}
{#if $featureFlags.map}
{#if !showCustomSidebar}
<SideBarLink
title={$t('map')}
routeId="/(user)/map"
bind:isSelected={isMapSelected}
icon={isMapSelected ? mdiMap : mdiMapOutline}
/>
{/if}
{#if $preferences.people.enabled && $preferences.people.sidebarWeb}
<SideBarLink
title={$t('people')}
routeId="/(user)/people"
bind:isSelected={isPeopleSelected}
icon={isPeopleSelected ? mdiAccount : mdiAccountOutline}
/>
{/if}
<SideBarLink
title={$t('sharing')}
routeId="/(user)/sharing"
icon={isSharingSelected ? mdiAccountMultiple : mdiAccountMultipleOutline}
bind:isSelected={isSharingSelected}
>
<svelte:fragment slot="moreInformation">
<MoreInformationAlbums albumType="shared" />
</svelte:fragment>
</SideBarLink>
<div class="text-xs transition-all duration-200 dark:text-immich-dark-fg">
<p class="hidden p-6 group-hover:sm:block md:block">{$t('library').toUpperCase()}</p>
<hr class="mx-4 mb-[31px] mt-8 block group-hover:sm:hidden md:hidden" />
</div>
<SideBarLink
title={$t('favorites')}
routeId="/(user)/favorites"
icon={isFavoritesSelected ? mdiHeart : mdiHeartOutline}
bind:isSelected={isFavoritesSelected}
>
<svelte:fragment slot="moreInformation">
<MoreInformationAssets assetStats={{ isFavorite: true }} />
</svelte:fragment>
</SideBarLink>
<SideBarLink title={$t('albums')} routeId="/(user)/albums" icon={mdiImageAlbum} flippedLogo>
<svelte:fragment slot="moreInformation">
<MoreInformationAlbums albumType="owned" />
</svelte:fragment>
</SideBarLink>
{#if $preferences.tags.enabled && $preferences.tags.sidebarWeb}
<SideBarLink title={$t('tags')} routeId="/(user)/tags" icon={mdiTagMultipleOutline} flippedLogo />
{/if}
{#if $preferences.folders.enabled && $preferences.folders.sidebarWeb}
<SideBarLink title={$t('folders')} routeId="/(user)/folders" icon={mdiFolderOutline} flippedLogo />
{/if}
<SideBarLink
title={$t('utilities')}
routeId="/(user)/utilities"
bind:isSelected={isUtilitiesSelected}
icon={isUtilitiesSelected ? mdiToolbox : mdiToolboxOutline}
></SideBarLink>
<SideBarLink
title={$t('archive')}
routeId="/(user)/archive"
bind:isSelected={isArchiveSelected}
icon={isArchiveSelected ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline}
>
<svelte:fragment slot="moreInformation">
<MoreInformationAssets assetStats={{ isArchived: true }} />
</svelte:fragment>
</SideBarLink>
{#if $featureFlags.trash}
<SideBarLink
title={$t('trash')}
routeId="/(user)/trash"
bind:isSelected={isTrashSelected}
icon={isTrashSelected ? mdiTrashCan : mdiTrashCanOutline}
title={$t('photos')}
routeId="/(user)/photos"
bind:isSelected={isPhotosSelected}
icon={isPhotosSelected ? mdiImageMultiple : mdiImageMultipleOutline}
>
<svelte:fragment slot="moreInformation">
<MoreInformationAssets assetStats={{ isTrashed: true }} />
<MoreInformationAssets assetStats={{ isArchived: false }} />
</svelte:fragment>
</SideBarLink>
{#if $featureFlags.search}
<SideBarLink title={$t('explore')} routeId="/(user)/explore" icon={mdiMagnify} />
{/if}
{#if $featureFlags.map}
<SideBarLink
title={$t('map')}
routeId="/(user)/map"
bind:isSelected={isMapSelected}
icon={isMapSelected ? mdiMap : mdiMapOutline}
/>
{/if}
{#if $preferences.people.enabled && $preferences.people.sidebarWeb}
<SideBarLink
title={$t('people')}
routeId="/(user)/people"
bind:isSelected={isPeopleSelected}
icon={isPeopleSelected ? mdiAccount : mdiAccountOutline}
/>
{/if}
<SideBarLink
title={$t('sharing')}
routeId="/(user)/sharing"
icon={isSharingSelected ? mdiAccountMultiple : mdiAccountMultipleOutline}
bind:isSelected={isSharingSelected}
>
<svelte:fragment slot="moreInformation">
<MoreInformationAlbums albumType="shared" />
</svelte:fragment>
</SideBarLink>
<div class="text-xs transition-all duration-200 dark:text-immich-dark-fg">
<p class="hidden p-6 group-hover:sm:block md:block">{$t('library').toUpperCase()}</p>
<hr class="mx-4 mb-[31px] mt-8 block group-hover:sm:hidden md:hidden" />
</div>
<SideBarLink
title={$t('favorites')}
routeId="/(user)/favorites"
icon={isFavoritesSelected ? mdiHeart : mdiHeartOutline}
bind:isSelected={isFavoritesSelected}
>
<svelte:fragment slot="moreInformation">
<MoreInformationAssets assetStats={{ isFavorite: true }} />
</svelte:fragment>
</SideBarLink>
<SideBarLink title={$t('albums')} routeId="/(user)/albums" icon={mdiImageAlbum} flippedLogo>
<svelte:fragment slot="moreInformation">
<MoreInformationAlbums albumType="owned" />
</svelte:fragment>
</SideBarLink>
{#if $preferences.tags.enabled && $preferences.tags.sidebarWeb}
<SideBarLink title={$t('tags')} routeId="/(user)/tags" icon={mdiTagMultipleOutline} flippedLogo />
{/if}
{#if $preferences.folders.enabled && $preferences.folders.sidebarWeb}
<SideBarLink title={$t('folders')} routeId="/(user)/folders" icon={mdiFolderOutline} flippedLogo />
{/if}
<SideBarLink
title={$t('utilities')}
routeId="/(user)/utilities"
bind:isSelected={isUtilitiesSelected}
icon={isUtilitiesSelected ? mdiToolbox : mdiToolboxOutline}
></SideBarLink>
<SideBarLink
title={$t('archive')}
routeId="/(user)/archive"
bind:isSelected={isArchiveSelected}
icon={isArchiveSelected ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline}
>
<svelte:fragment slot="moreInformation">
<MoreInformationAssets assetStats={{ isArchived: true }} />
</svelte:fragment>
</SideBarLink>
{#if $featureFlags.trash}
<SideBarLink
title={$t('trash')}
routeId="/(user)/trash"
bind:isSelected={isTrashSelected}
icon={isTrashSelected ? mdiTrashCan : mdiTrashCanOutline}
>
<svelte:fragment slot="moreInformation">
<MoreInformationAssets assetStats={{ isTrashed: true }} />
</svelte:fragment>
</SideBarLink>
{/if}
{:else}
<slot name="customSidebar" />
{/if}
</nav>

View File

@ -69,10 +69,51 @@ export const dateFormats = {
},
};
interface Coordinates {
x1: number;
x2: number;
y1: number;
y2: number;
}
export enum AssetGridOptionsValues {
onlyFavorites = 'onlyFavorites',
withPartners = 'withPartners',
}
export interface AssetGridOptions {
onlyFavorites: boolean;
withPartners: boolean;
}
export interface IQueryParameter {
previousRoute?: string;
coordinates?: Coordinates;
assetGridOptions?: AssetGridOptions;
}
export const createAssetGridOptionsFromArray = (enumArray: string[]): AssetGridOptions => {
const defaultValues: AssetGridOptions = {} as AssetGridOptions;
for (const key of Object.keys(AssetGridOptionsValues)) {
defaultValues[key as keyof AssetGridOptions] = false;
}
for (const enumValue of Object.values(AssetGridOptionsValues)) {
if (enumArray.includes(enumValue)) {
defaultValues[enumValue as keyof AssetGridOptions] = true;
}
}
return defaultValues;
};
export enum QueryParameter {
ACTION = 'action',
ASSET_INDEX = 'assetIndex',
ASSET_GRID_OPTIONS = 'assetGridOptions',
IS_OPEN = 'isOpen',
IS_TIMELINE_OPENED = 'isTimelineOpened',
MEMORY_INDEX = 'memoryIndex',
ONBOARDING_STEP = 'step',
OPEN_SETTING = 'openSetting',
@ -82,6 +123,10 @@ export enum QueryParameter {
SMART_SEARCH = 'smartSearch',
PAGE = 'page',
PATH = 'path',
COORDINATESX1 = 'coordinatesx1',
COORDINATESX2 = 'coordinatesx2',
COORDINATESY1 = 'coordinatesy1',
COORDINATESY2 = 'coordinatesY2',
}
export enum OpenSettingQueryParameterValue {

View File

@ -758,6 +758,7 @@
"include_archived": "Include archived",
"include_shared_albums": "Include shared albums",
"include_shared_partner_assets": "Include shared partner assets",
"include_timeline": "Include timeline",
"individual_share": "Individual share",
"info": "Info",
"interval": {
@ -872,6 +873,7 @@
"notification_toggle_setting_description": "Enable email notifications",
"notifications": "Notifications",
"notifications_setting_description": "Manage notifications",
"number_of_assets": "Number of assets",
"oauth": "OAuth",
"offline": "Offline",
"offline_paths": "Offline paths",

View File

@ -792,6 +792,7 @@
"include_archived": "Inclure les archives",
"include_shared_albums": "Inclure les albums partagés",
"include_shared_partner_assets": "Inclure les médias partagés du partenaire",
"include_timeline": "Inclure la chronologie",
"individual_share": "Partage individuel",
"info": "Information",
"interval": {
@ -931,6 +932,7 @@
"notification_toggle_setting_description": "Activer les notifications par courriel",
"notifications": "Notifications",
"notifications_setting_description": "Gérer les notifications",
"number_of_assets": "Nombre de médias",
"oauth": "OAuth",
"offline": "Hors ligne",
"offline_paths": "Chemins hors ligne",

View File

@ -336,3 +336,13 @@ export const suggestDuplicateByFileSize = (assets: AssetResponseDto[]): AssetRes
// eslint-disable-next-line unicorn/prefer-code-point
export const decodeBase64 = (data: string) => Uint8Array.from(atob(data), (c) => c.charCodeAt(0));
export const parseNumberOrUndefined = (value: string | null): number | undefined => {
return value !== null && !Number.isNaN(Number(value)) ? Number(value) : undefined;
};
export const checkEnumInArray = <T extends Record<string, string>>(enumArray: string[], enumType: T): boolean => {
const enumValues = Object.values(enumType) as string[];
return enumArray.some((value) => enumValues.includes(value));
};

View File

@ -113,7 +113,14 @@
{#if $featureFlags.loaded && $featureFlags.map}
<UserPageLayout title={data.meta.title}>
<div class="isolate h-full w-full">
<Map hash bind:mapMarkers bind:showSettingsModal on:selected={(event) => onViewAssets(event.detail)} />
<Map
hash
showAssetGrid
bind:mapMarkers
bind:showSettingsModal
isAssetGridOpenedOnInit={data.isTimelineOpened}
on:selected={(event) => onViewAssets(event.detail)}
/>
</div>
</UserPageLayout>
<Portal target="body">

View File

@ -1,15 +1,18 @@
import { QueryParameter } from '$lib/constants';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import type { PageLoad } from './$types';
export const load = (async ({ params }) => {
export const load = (async ({ params, url }) => {
await authenticate();
const asset = await getAssetInfoFromParam(params);
const $t = await getFormatter();
const isTimelineOpened = url.searchParams.get(QueryParameter.IS_TIMELINE_OPENED) === 'true';
return {
asset,
isTimelineOpened,
meta: {
title: $t('map'),
},

View File

@ -194,7 +194,7 @@
const handleMergePeople = async (detail: PersonResponseDto) => {
await goto(
`${AppRoute.PEOPLE}/${detail.id}?${QueryParameter.ACTION}=${ActionQueryParameterValue.MERGE}&${QueryParameter.PREVIOUS_ROUTE}=${AppRoute.PEOPLE}`,
`${AppRoute.PEOPLE}/${detail.id}?${QueryParameter.ACTION}=${ActionQueryParameterValue.MERGE}&${QueryParameter.PREVIOUS_ROUTE}=${encodeURIComponent(AppRoute.PEOPLE)}`,
);
};

View File

@ -116,10 +116,10 @@
onMount(() => {
const action = $page.url.searchParams.get(QueryParameter.ACTION);
const getPreviousRoute = $page.url.searchParams.get(QueryParameter.PREVIOUS_ROUTE);
if (getPreviousRoute && !isExternalUrl(getPreviousRoute)) {
previousRoute = getPreviousRoute;
if (getPreviousRoute && !isExternalUrl(decodeURIComponent(getPreviousRoute))) {
previousRoute = decodeURIComponent(getPreviousRoute);
}
if (action == 'merge') {
if (action === 'merge') {
viewMode = ViewMode.MERGE_PEOPLE;
}

View File

@ -18,19 +18,39 @@
import MemoryLane from '$lib/components/photos-page/memory-lane.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AssetAction } from '$lib/constants';
import { AssetAction, QueryParameter } from '$lib/constants';
import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetStore } from '$lib/stores/assets.store';
import { mdiArrowLeft, mdiClose, mdiDotsVertical, mdiPlus } from '@mdi/js';
import { preferences, user } from '$lib/stores/user.store';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import type { PageData } from './$types';
import SideBarLink from '$lib/components/shared-components/side-bar/side-bar-link.svelte';
import SideBarButton from '$lib/components/shared-components/side-bar/side-bar-button.svelte';
export let data: PageData;
$: options = data.options;
$: x1 = options.coordinates ? options.coordinates.x1 : undefined;
$: x2 = options.coordinates ? options.coordinates.x2 : undefined;
$: y1 = options.coordinates ? options.coordinates.y1 : undefined;
$: y2 = options.coordinates ? options.coordinates.y2 : undefined;
// TODO: when getTimebuckets support withArchived
const isArchived = false;
const withStacked = undefined;
$: withPartners = options.assetGridOptions ? options.assetGridOptions.withPartners : true;
$: isFavorite = options.assetGridOptions?.onlyFavorites ? true : undefined;
$: showCustomSidebar = options.previousRoute !== undefined || options.coordinates !== undefined;
let { isViewing: showAssetViewer } = assetViewingStore;
const assetStore = new AssetStore({ isArchived: false, withStacked: true, withPartners: true });
$: assetStore = new AssetStore({ isArchived, withStacked, withPartners, isFavorite, x1, x2, y1, y2 });
const assetInteractionStore = createAssetInteractionStore();
const { isMultiSelectState, selectedAssets } = assetInteractionStore;
@ -63,6 +83,24 @@
onDestroy(() => {
assetStore.destroy();
});
const closePreviousRoute = async () => {
const newUrl = new URL($page.url);
if (options.previousRoute) {
newUrl.searchParams.delete(QueryParameter.PREVIOUS_ROUTE);
}
if (options.coordinates) {
newUrl.searchParams.delete(QueryParameter.COORDINATESX1);
newUrl.searchParams.delete(QueryParameter.COORDINATESX2);
newUrl.searchParams.delete(QueryParameter.COORDINATESY1);
newUrl.searchParams.delete(QueryParameter.COORDINATESY2);
}
if (options.assetGridOptions) {
newUrl.searchParams.delete(QueryParameter.ASSET_GRID_OPTIONS);
}
options = {};
await goto(newUrl);
};
</script>
{#if $isMultiSelectState}
@ -103,18 +141,30 @@
</AssetSelectControlBar>
{/if}
<UserPageLayout hideNavbar={$isMultiSelectState} showUploadButton scrollbar={false}>
<AssetGrid
enableRouting={true}
{assetStore}
{assetInteractionStore}
removeAction={AssetAction.ARCHIVE}
on:escape={handleEscape}
withStacked
>
{#if $preferences.memories.enabled}
<MemoryLane />
<UserPageLayout {showCustomSidebar} hideNavbar={$isMultiSelectState} showUploadButton scrollbar={false}>
<slot slot="customSidebar">
{#if options.previousRoute}
<a href={options.previousRoute}>
<SideBarLink title={$t('previous')} routeId={`/(user)${options.previousRoute}`} icon={mdiArrowLeft} />
</a>
{/if}
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} slot="empty" />
</AssetGrid>
{#if options.previousRoute !== undefined || options.coordinates !== undefined}
<SideBarButton title={$t('close')} icon={mdiClose} moreInformation={false} onClick={closePreviousRoute} />
{/if}
</slot>
{#key options}
<AssetGrid
enableRouting={true}
{assetStore}
{assetInteractionStore}
removeAction={AssetAction.ARCHIVE}
on:escape={handleEscape}
withStacked
>
{#if $preferences.memories.enabled}
<MemoryLane />
{/if}
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} slot="empty" />
</AssetGrid>
{/key}
</UserPageLayout>

View File

@ -1,17 +1,49 @@
import {
AssetGridOptionsValues,
createAssetGridOptionsFromArray,
QueryParameter,
type IQueryParameter,
} from '$lib/constants';
import { checkEnumInArray, parseNumberOrUndefined } from '$lib/utils';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import { getAssetInfoFromParam, isExternalUrl } from '$lib/utils/navigation';
import type { PageLoad } from './$types';
export const load = (async ({ params }) => {
export const load = (async ({ params, url }) => {
await authenticate();
const asset = await getAssetInfoFromParam(params);
const $t = await getFormatter();
const options: IQueryParameter = {};
const getPreviousRoute = url.searchParams.get(QueryParameter.PREVIOUS_ROUTE);
if (getPreviousRoute && !isExternalUrl(decodeURIComponent(getPreviousRoute))) {
options.previousRoute = decodeURIComponent(getPreviousRoute);
}
const assetGridOptionss = url.searchParams.get(QueryParameter.ASSET_GRID_OPTIONS)?.split(',');
if (assetGridOptionss && checkEnumInArray(assetGridOptionss, AssetGridOptionsValues)) {
options.assetGridOptions = createAssetGridOptionsFromArray(assetGridOptionss);
}
const x1 = parseNumberOrUndefined(url.searchParams.get(QueryParameter.COORDINATESX1));
const x2 = parseNumberOrUndefined(url.searchParams.get(QueryParameter.COORDINATESX2));
const y1 = parseNumberOrUndefined(url.searchParams.get(QueryParameter.COORDINATESY1));
const y2 = parseNumberOrUndefined(url.searchParams.get(QueryParameter.COORDINATESY2));
if (x1 && x2 && y1 && y2) {
options.coordinates = {
x1,
x2,
y1,
y2,
};
}
return {
asset,
meta: {
title: $t('photos'),
},
options,
};
}) satisfies PageLoad;