You've already forked immich
							
							
				mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 00:18:28 +02:00 
			
		
		
		
	feat(web+server): map date filters + small changes (#2565)
This commit is contained in:
		
							
								
								
									
										8
									
								
								mobile/openapi/doc/AssetApi.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										8
									
								
								mobile/openapi/doc/AssetApi.md
									
									
									
										generated
									
									
									
								
							| @@ -1101,7 +1101,7 @@ This endpoint does not need any parameter. | ||||
| [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) | ||||
| 
 | ||||
| # **getMapMarkers** | ||||
| > List<MapMarkerResponseDto> getMapMarkers(isFavorite) | ||||
| > List<MapMarkerResponseDto> getMapMarkers(isFavorite, fileCreatedAfter, fileCreatedBefore) | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| @@ -1125,9 +1125,11 @@ import 'package:openapi/api.dart'; | ||||
| 
 | ||||
| final api_instance = AssetApi(); | ||||
| final isFavorite = true; // bool |  | ||||
| final fileCreatedAfter = 2013-10-20T19:20:30+01:00; // DateTime |  | ||||
| final fileCreatedBefore = 2013-10-20T19:20:30+01:00; // DateTime |  | ||||
| 
 | ||||
| try { | ||||
|     final result = api_instance.getMapMarkers(isFavorite); | ||||
|     final result = api_instance.getMapMarkers(isFavorite, fileCreatedAfter, fileCreatedBefore); | ||||
|     print(result); | ||||
| } catch (e) { | ||||
|     print('Exception when calling AssetApi->getMapMarkers: $e\n'); | ||||
| @@ -1139,6 +1141,8 @@ try { | ||||
| Name | Type | Description  | Notes | ||||
| ------------- | ------------- | ------------- | ------------- | ||||
|  **isFavorite** | **bool**|  | [optional]  | ||||
|  **fileCreatedAfter** | **DateTime**|  | [optional]  | ||||
|  **fileCreatedBefore** | **DateTime**|  | [optional]  | ||||
| 
 | ||||
| ### Return type | ||||
| 
 | ||||
|   | ||||
							
								
								
									
										20
									
								
								mobile/openapi/lib/api/asset_api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										20
									
								
								mobile/openapi/lib/api/asset_api.dart
									
									
									
										generated
									
									
									
								
							| @@ -1042,7 +1042,11 @@ class AssetApi { | ||||
|   /// Parameters: | ||||
|   /// | ||||
|   /// * [bool] isFavorite: | ||||
|   Future<Response> getMapMarkersWithHttpInfo({ bool? isFavorite, }) async { | ||||
|   /// | ||||
|   /// * [DateTime] fileCreatedAfter: | ||||
|   /// | ||||
|   /// * [DateTime] fileCreatedBefore: | ||||
|   Future<Response> getMapMarkersWithHttpInfo({ bool? isFavorite, DateTime? fileCreatedAfter, DateTime? fileCreatedBefore, }) async { | ||||
|     // ignore: prefer_const_declarations | ||||
|     final path = r'/asset/map-marker'; | ||||
| 
 | ||||
| @@ -1056,6 +1060,12 @@ class AssetApi { | ||||
|     if (isFavorite != null) { | ||||
|       queryParams.addAll(_queryParams('', 'isFavorite', isFavorite)); | ||||
|     } | ||||
|     if (fileCreatedAfter != null) { | ||||
|       queryParams.addAll(_queryParams('', 'fileCreatedAfter', fileCreatedAfter)); | ||||
|     } | ||||
|     if (fileCreatedBefore != null) { | ||||
|       queryParams.addAll(_queryParams('', 'fileCreatedBefore', fileCreatedBefore)); | ||||
|     } | ||||
| 
 | ||||
|     const contentTypes = <String>[]; | ||||
| 
 | ||||
| @@ -1074,8 +1084,12 @@ class AssetApi { | ||||
|   /// Parameters: | ||||
|   /// | ||||
|   /// * [bool] isFavorite: | ||||
|   Future<List<MapMarkerResponseDto>?> getMapMarkers({ bool? isFavorite, }) async { | ||||
|     final response = await getMapMarkersWithHttpInfo( isFavorite: isFavorite, ); | ||||
|   /// | ||||
|   /// * [DateTime] fileCreatedAfter: | ||||
|   /// | ||||
|   /// * [DateTime] fileCreatedBefore: | ||||
|   Future<List<MapMarkerResponseDto>?> getMapMarkers({ bool? isFavorite, DateTime? fileCreatedAfter, DateTime? fileCreatedBefore, }) async { | ||||
|     final response = await getMapMarkersWithHttpInfo( isFavorite: isFavorite, fileCreatedAfter: fileCreatedAfter, fileCreatedBefore: fileCreatedBefore, ); | ||||
|     if (response.statusCode >= HttpStatus.badRequest) { | ||||
|       throw ApiException(response.statusCode, await _decodeBodyBytes(response)); | ||||
|     } | ||||
|   | ||||
							
								
								
									
										2
									
								
								mobile/openapi/test/asset_api_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/test/asset_api_test.dart
									
									
									
										generated
									
									
									
								
							| @@ -124,7 +124,7 @@ void main() { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     //Future<List<MapMarkerResponseDto>> getMapMarkers({ bool isFavorite }) async | ||||
|     //Future<List<MapMarkerResponseDto>> getMapMarkers({ bool isFavorite, DateTime fileCreatedAfter, DateTime fileCreatedBefore }) async | ||||
|     test('test getMapMarkers', () async { | ||||
|       // TODO | ||||
|     }); | ||||
|   | ||||
| @@ -306,6 +306,24 @@ | ||||
|             "schema": { | ||||
|               "type": "boolean" | ||||
|             } | ||||
|           }, | ||||
|           { | ||||
|             "name": "fileCreatedAfter", | ||||
|             "required": false, | ||||
|             "in": "query", | ||||
|             "schema": { | ||||
|               "format": "date-time", | ||||
|               "type": "string" | ||||
|             } | ||||
|           }, | ||||
|           { | ||||
|             "name": "fileCreatedBefore", | ||||
|             "required": false, | ||||
|             "in": "query", | ||||
|             "schema": { | ||||
|               "format": "date-time", | ||||
|               "type": "string" | ||||
|             } | ||||
|           } | ||||
|         ], | ||||
|         "responses": { | ||||
|   | ||||
| @@ -15,6 +15,8 @@ export interface LivePhotoSearchOptions { | ||||
|  | ||||
| export interface MapMarkerSearchOptions { | ||||
|   isFavorite?: boolean; | ||||
|   fileCreatedBefore?: string; | ||||
|   fileCreatedAfter?: string; | ||||
| } | ||||
|  | ||||
| export interface MapMarker { | ||||
|   | ||||
| @@ -1,10 +1,22 @@ | ||||
| import { ApiProperty } from '@nestjs/swagger'; | ||||
| import { toBoolean } from 'apps/immich/src/utils/transform.util'; | ||||
| import { Transform } from 'class-transformer'; | ||||
| import { IsBoolean, IsOptional } from 'class-validator'; | ||||
| import { IsBoolean, IsISO8601, IsOptional } from 'class-validator'; | ||||
|  | ||||
| export class MapMarkerDto { | ||||
|   @ApiProperty() | ||||
|   @IsOptional() | ||||
|   @IsBoolean() | ||||
|   @Transform(toBoolean) | ||||
|   isFavorite?: boolean; | ||||
|  | ||||
|   @ApiProperty({ format: 'date-time' }) | ||||
|   @IsOptional() | ||||
|   @IsISO8601({ strict: true, strictSeparator: true }) | ||||
|   fileCreatedAfter?: string; | ||||
|  | ||||
|   @ApiProperty({ format: 'date-time' }) | ||||
|   @IsOptional() | ||||
|   @IsISO8601({ strict: true, strictSeparator: true }) | ||||
|   fileCreatedBefore?: string; | ||||
| } | ||||
|   | ||||
| @@ -14,6 +14,7 @@ import { InjectRepository } from '@nestjs/typeorm'; | ||||
| import { FindOptionsRelations, FindOptionsWhere, In, IsNull, Not, Repository } from 'typeorm'; | ||||
| import { AssetEntity, AssetType } from '../entities'; | ||||
| import { paginate } from '../utils/pagination.util'; | ||||
| import OptionalBetween from '../utils/optional-between.util'; | ||||
|  | ||||
| @Injectable() | ||||
| export class AssetRepository implements IAssetRepository { | ||||
| @@ -212,7 +213,7 @@ export class AssetRepository implements IAssetRepository { | ||||
|   } | ||||
|  | ||||
|   async getMapMarkers(ownerId: string, options: MapMarkerSearchOptions = {}): Promise<MapMarker[]> { | ||||
|     const { isFavorite } = options; | ||||
|     const { isFavorite, fileCreatedAfter, fileCreatedBefore } = options; | ||||
|  | ||||
|     const assets = await this.repository.find({ | ||||
|       select: { | ||||
| @@ -231,6 +232,7 @@ export class AssetRepository implements IAssetRepository { | ||||
|           longitude: Not(IsNull()), | ||||
|         }, | ||||
|         isFavorite, | ||||
|         fileCreatedAt: OptionalBetween(fileCreatedAfter, fileCreatedBefore), | ||||
|       }, | ||||
|       relations: { | ||||
|         exifInfo: true, | ||||
|   | ||||
							
								
								
									
										15
									
								
								server/libs/infra/src/utils/optional-between.util.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								server/libs/infra/src/utils/optional-between.util.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| import { Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; | ||||
|  | ||||
| /** | ||||
|  * Allows optional values unlike the regular Between and uses MoreThanOrEqual | ||||
|  * or LessThanOrEqual when only one parameter is specified. | ||||
|  */ | ||||
| export default function OptionalBetween<T>(from?: T, to?: T) { | ||||
|   if (from && to) { | ||||
|     return Between(from, to); | ||||
|   } else if (from) { | ||||
|     return MoreThanOrEqual(from); | ||||
|   } else if (to) { | ||||
|     return LessThanOrEqual(to); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										34
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										34
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							| @@ -5040,10 +5040,12 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration | ||||
|         /** | ||||
|          *  | ||||
|          * @param {boolean} [isFavorite]  | ||||
|          * @param {string} [fileCreatedAfter]  | ||||
|          * @param {string} [fileCreatedBefore]  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         getMapMarkers: async (isFavorite?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|         getMapMarkers: async (isFavorite?: boolean, fileCreatedAfter?: string, fileCreatedBefore?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|             const localVarPath = `/asset/map-marker`; | ||||
|             // use dummy base URL string because the URL constructor only accepts absolute URLs.
 | ||||
|             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); | ||||
| @@ -5069,6 +5071,18 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration | ||||
|                 localVarQueryParameter['isFavorite'] = isFavorite; | ||||
|             } | ||||
| 
 | ||||
|             if (fileCreatedAfter !== undefined) { | ||||
|                 localVarQueryParameter['fileCreatedAfter'] = (fileCreatedAfter as any instanceof Date) ? | ||||
|                     (fileCreatedAfter as any).toISOString() : | ||||
|                     fileCreatedAfter; | ||||
|             } | ||||
| 
 | ||||
|             if (fileCreatedBefore !== undefined) { | ||||
|                 localVarQueryParameter['fileCreatedBefore'] = (fileCreatedBefore as any instanceof Date) ? | ||||
|                     (fileCreatedBefore as any).toISOString() : | ||||
|                     fileCreatedBefore; | ||||
|             } | ||||
| 
 | ||||
| 
 | ||||
|      | ||||
|             setSearchParams(localVarUrlObj, localVarQueryParameter); | ||||
| @@ -5659,11 +5673,13 @@ export const AssetApiFp = function(configuration?: Configuration) { | ||||
|         /** | ||||
|          *  | ||||
|          * @param {boolean} [isFavorite]  | ||||
|          * @param {string} [fileCreatedAfter]  | ||||
|          * @param {string} [fileCreatedBefore]  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         async getMapMarkers(isFavorite?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<MapMarkerResponseDto>>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.getMapMarkers(isFavorite, options); | ||||
|         async getMapMarkers(isFavorite?: boolean, fileCreatedAfter?: string, fileCreatedBefore?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<MapMarkerResponseDto>>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.getMapMarkers(isFavorite, fileCreatedAfter, fileCreatedBefore, options); | ||||
|             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); | ||||
|         }, | ||||
|         /** | ||||
| @@ -5936,11 +5952,13 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath | ||||
|         /** | ||||
|          *  | ||||
|          * @param {boolean} [isFavorite]  | ||||
|          * @param {string} [fileCreatedAfter]  | ||||
|          * @param {string} [fileCreatedBefore]  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         getMapMarkers(isFavorite?: boolean, options?: any): AxiosPromise<Array<MapMarkerResponseDto>> { | ||||
|             return localVarFp.getMapMarkers(isFavorite, options).then((request) => request(axios, basePath)); | ||||
|         getMapMarkers(isFavorite?: boolean, fileCreatedAfter?: string, fileCreatedBefore?: string, options?: any): AxiosPromise<Array<MapMarkerResponseDto>> { | ||||
|             return localVarFp.getMapMarkers(isFavorite, fileCreatedAfter, fileCreatedBefore, options).then((request) => request(axios, basePath)); | ||||
|         }, | ||||
|         /** | ||||
|          * Get all asset of a device that are in the database, ID only. | ||||
| @@ -6244,12 +6262,14 @@ export class AssetApi extends BaseAPI { | ||||
|     /** | ||||
|      *  | ||||
|      * @param {boolean} [isFavorite]  | ||||
|      * @param {string} [fileCreatedAfter]  | ||||
|      * @param {string} [fileCreatedBefore]  | ||||
|      * @param {*} [options] Override http request option. | ||||
|      * @throws {RequiredError} | ||||
|      * @memberof AssetApi | ||||
|      */ | ||||
|     public getMapMarkers(isFavorite?: boolean, options?: AxiosRequestConfig) { | ||||
|         return AssetApiFp(this.configuration).getMapMarkers(isFavorite, options).then((request) => request(this.axios, this.basePath)); | ||||
|     public getMapMarkers(isFavorite?: boolean, fileCreatedAfter?: string, fileCreatedBefore?: string, options?: AxiosRequestConfig) { | ||||
|         return AssetApiFp(this.configuration).getMapMarkers(isFavorite, fileCreatedAfter, fileCreatedBefore, options).then((request) => request(this.axios, this.basePath)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|   | ||||
| @@ -2,16 +2,24 @@ | ||||
| 	export interface MapSettings { | ||||
| 		allowDarkMode: boolean; | ||||
| 		onlyFavorites: boolean; | ||||
| 		relativeDate: string; | ||||
| 		dateAfter: string; | ||||
| 		dateBefore: string; | ||||
| 	} | ||||
| </script> | ||||
|  | ||||
| <script lang="ts"> | ||||
| 	import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; | ||||
| 	import { Duration } from 'luxon'; | ||||
| 	import { createEventDispatcher } from 'svelte'; | ||||
| 	import { fly } from 'svelte/transition'; | ||||
| 	import SettingSelect from '../admin-page/settings/setting-select.svelte'; | ||||
| 	import SettingSwitch from '../admin-page/settings/setting-switch.svelte'; | ||||
| 	import Button from '../elements/buttons/button.svelte'; | ||||
| 	import LinkButton from '../elements/buttons/link-button.svelte'; | ||||
|  | ||||
| 	export let settings: MapSettings; | ||||
| 	let customDateRange = !!settings.dateAfter || !!settings.dateBefore; | ||||
|  | ||||
| 	const dispatch = createEventDispatcher<{ | ||||
| 		close: void; | ||||
| @@ -27,9 +35,90 @@ | ||||
| 			Map Settings | ||||
| 		</h1> | ||||
|  | ||||
| 		<form on:submit|preventDefault={() => dispatch('save', settings)} class="flex flex-col gap-4"> | ||||
| 		<form | ||||
| 			on:submit|preventDefault={() => dispatch('save', settings)} | ||||
| 			class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary" | ||||
| 		> | ||||
| 			<SettingSwitch title="Allow dark mode" bind:checked={settings.allowDarkMode} /> | ||||
| 			<SettingSwitch title="Show only favorites" bind:checked={settings.onlyFavorites} /> | ||||
| 			<SettingSwitch title="Only favorites" bind:checked={settings.onlyFavorites} /> | ||||
| 			{#if customDateRange} | ||||
| 				<div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4"> | ||||
| 					<div class="flex justify-between items-center gap-8"> | ||||
| 						<label class="immich-form-label text-sm shrink-0" for="date-after">Date after</label> | ||||
| 						<input | ||||
| 							class="immich-form-input w-40" | ||||
| 							type="date" | ||||
| 							id="date-after" | ||||
| 							max={settings.dateBefore} | ||||
| 							bind:value={settings.dateAfter} | ||||
| 						/> | ||||
| 					</div> | ||||
| 					<div class="flex justify-between items-center gap-8"> | ||||
| 						<label class="immich-form-label text-sm shrink-0" for="date-before">Date before</label> | ||||
| 						<input | ||||
| 							class="immich-form-input w-40" | ||||
| 							type="date" | ||||
| 							id="date-before" | ||||
| 							bind:value={settings.dateBefore} | ||||
| 						/> | ||||
| 					</div> | ||||
| 					<div class="flex justify-center text-xs"> | ||||
| 						<LinkButton | ||||
| 							on:click={() => { | ||||
| 								customDateRange = false; | ||||
| 								settings.dateAfter = ''; | ||||
| 								settings.dateBefore = ''; | ||||
| 							}} | ||||
| 						> | ||||
| 							Remove custom date range | ||||
| 						</LinkButton> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			{:else} | ||||
| 				<div in:fly={{ y: -10, duration: 200 }} class="flex flex-col gap-1"> | ||||
| 					<SettingSelect | ||||
| 						label="Date range" | ||||
| 						name="date-range" | ||||
| 						bind:value={settings.relativeDate} | ||||
| 						options={[ | ||||
| 							{ | ||||
| 								value: '', | ||||
| 								text: 'All' | ||||
| 							}, | ||||
| 							{ | ||||
| 								value: Duration.fromObject({ hours: 24 }).toISO(), | ||||
| 								text: 'Past 24 hours' | ||||
| 							}, | ||||
| 							{ | ||||
| 								value: Duration.fromObject({ days: 7 }).toISO(), | ||||
| 								text: 'Past 7 days' | ||||
| 							}, | ||||
| 							{ | ||||
| 								value: Duration.fromObject({ days: 30 }).toISO(), | ||||
| 								text: 'Past 30 days' | ||||
| 							}, | ||||
| 							{ | ||||
| 								value: Duration.fromObject({ years: 1 }).toISO(), | ||||
| 								text: 'Past year' | ||||
| 							}, | ||||
| 							{ | ||||
| 								value: Duration.fromObject({ years: 3 }).toISO(), | ||||
| 								text: 'Past 3 years' | ||||
| 							} | ||||
| 						]} | ||||
| 					/> | ||||
| 					<div class="text-xs"> | ||||
| 						<LinkButton | ||||
| 							on:click={() => { | ||||
| 								customDateRange = true; | ||||
| 								settings.relativeDate = ''; | ||||
| 							}} | ||||
| 						> | ||||
| 							Use custom date range instead | ||||
| 						</LinkButton> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			{/if} | ||||
|  | ||||
| 			<div class="flex w-full gap-4 mt-4"> | ||||
| 				<Button color="gray" size="sm" fullwidth on:click={() => dispatch('close')}>Cancel</Button> | ||||
|   | ||||
| @@ -1,39 +0,0 @@ | ||||
| .marker-cluster { | ||||
| 	background-clip: padding-box; | ||||
| } | ||||
|  | ||||
| .asset-marker-icon { | ||||
| 	@apply rounded-full; | ||||
| 	object-fit: cover; | ||||
| 	border: 1px solid rgb(69, 80, 169); | ||||
| 	box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, rgba(0, 0, 0, 0.07) 0px 2px 4px, | ||||
| 		rgba(0, 0, 0, 0.07) 0px 4px 8px, rgba(0, 0, 0, 0.07) 0px 8px 16px, | ||||
| 		rgba(0, 0, 0, 0.07) 0px 16px 32px, rgba(0, 0, 0, 0.07) 0px 32px 64px; | ||||
| } | ||||
|  | ||||
| .marker-cluster div { | ||||
| 	width: 40px; | ||||
| 	height: 40px; | ||||
| 	margin-left: 5px; | ||||
| 	margin-top: 5px; | ||||
|  | ||||
| 	text-align: center; | ||||
| 	@apply rounded-full; | ||||
| 	font-weight: bold; | ||||
|  | ||||
| 	background-color: rgb(236, 237, 246); | ||||
| 	border: 1px solid rgb(69, 80, 169); | ||||
|  | ||||
| 	color: rgb(69, 80, 169); | ||||
| 	box-shadow: rgba(5, 5, 122, 0.12) 0px 2px 4px 0px, rgba(4, 4, 230, 0.32) 0px 2px 16px 0px; | ||||
| } | ||||
|  | ||||
| .dark .marker-cluster div { | ||||
| 	background-color: #adcbfa; | ||||
| 	border: 1px solid black; | ||||
| 	color: black; | ||||
| } | ||||
|  | ||||
| .marker-cluster span { | ||||
| 	line-height: 40px; | ||||
| } | ||||
| @@ -1,95 +0,0 @@ | ||||
| <script lang="ts" context="module"> | ||||
| 	import { createContext } from '$lib/utils/context'; | ||||
| 	import { Icon, LeafletEvent, Marker, MarkerClusterGroup } from 'leaflet'; | ||||
|  | ||||
| 	const { get: getContext, set: setClusterContext } = createContext<() => MarkerClusterGroup>(); | ||||
|  | ||||
| 	export const getClusterContext = () => { | ||||
| 		return getContext()(); | ||||
| 	}; | ||||
| </script> | ||||
|  | ||||
| <script lang="ts"> | ||||
| 	import { MapMarkerResponseDto, api } from '@api'; | ||||
| 	import 'leaflet.markercluster'; | ||||
| 	import { createEventDispatcher, onDestroy, onMount } from 'svelte'; | ||||
| 	import './asset-marker-cluster.css'; | ||||
| 	import { getMapContext } from './map.svelte'; | ||||
|  | ||||
| 	class AssetMarker extends Marker { | ||||
| 		constructor(private marker: MapMarkerResponseDto) { | ||||
| 			super([marker.lat, marker.lon], { | ||||
| 				icon: new Icon({ | ||||
| 					iconUrl: api.getAssetThumbnailUrl(marker.id), | ||||
| 					iconRetinaUrl: api.getAssetThumbnailUrl(marker.id), | ||||
| 					iconSize: [60, 60], | ||||
| 					iconAnchor: [12, 41], | ||||
| 					popupAnchor: [1, -34], | ||||
| 					tooltipAnchor: [16, -28], | ||||
| 					shadowSize: [41, 41], | ||||
| 					className: 'asset-marker-icon' | ||||
| 				}) | ||||
| 			}); | ||||
|  | ||||
| 			this.on('click', this.onClick); | ||||
| 		} | ||||
|  | ||||
| 		onClick() { | ||||
| 			dispatch('view', { assets: [this.marker.id] }); | ||||
| 		} | ||||
|  | ||||
| 		getAssetId(): string { | ||||
| 			return this.marker.id; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	const dispatch = createEventDispatcher<{ view: { assets: string[] } }>(); | ||||
|  | ||||
| 	export let markers: MapMarkerResponseDto[]; | ||||
|  | ||||
| 	const map = getMapContext(); | ||||
|  | ||||
| 	let cluster: MarkerClusterGroup; | ||||
|  | ||||
| 	setClusterContext(() => cluster); | ||||
|  | ||||
| 	onMount(() => { | ||||
| 		cluster = new MarkerClusterGroup({ | ||||
| 			showCoverageOnHover: false, | ||||
| 			zoomToBoundsOnClick: false, | ||||
| 			spiderfyOnMaxZoom: false, | ||||
| 			maxClusterRadius: 30, | ||||
| 			spiderLegPolylineOptions: { opacity: 0 }, | ||||
| 			spiderfyDistanceMultiplier: 3 | ||||
| 		}); | ||||
|  | ||||
| 		cluster.on('clusterclick', (event: LeafletEvent) => { | ||||
| 			const ids = event.sourceTarget | ||||
| 				.getAllChildMarkers() | ||||
| 				.map((marker: AssetMarker) => marker.getAssetId()); | ||||
| 			dispatch('view', { assets: ids }); | ||||
| 		}); | ||||
|  | ||||
| 		cluster.on('clustermouseover', (event: LeafletEvent) => { | ||||
| 			if (event.sourceTarget.getChildCount() <= 10) { | ||||
| 				event.sourceTarget.spiderfy(); | ||||
| 			} | ||||
| 		}); | ||||
|  | ||||
| 		cluster.on('clustermouseout', (event: LeafletEvent) => { | ||||
| 			event.sourceTarget.unspiderfy(); | ||||
| 		}); | ||||
| 		map.addLayer(cluster); | ||||
| 	}); | ||||
|  | ||||
| 	$: if (cluster) { | ||||
| 		const leafletMarkers = markers.map((marker) => new AssetMarker(marker)); | ||||
|  | ||||
| 		cluster.clearLayers(); | ||||
| 		cluster.addLayers(leafletMarkers); | ||||
| 	} | ||||
|  | ||||
| 	onDestroy(() => { | ||||
| 		if (cluster) cluster.remove(); | ||||
| 	}); | ||||
| </script> | ||||
| @@ -1,4 +1,4 @@ | ||||
| export { default as AssetMarkerCluster } from './asset-marker-cluster.svelte'; | ||||
| export { default as AssetMarkerCluster } from './marker-cluster/asset-marker-cluster.svelte'; | ||||
| export { default as Control } from './control.svelte'; | ||||
| export { default as Map } from './map.svelte'; | ||||
| export { default as Marker } from './marker.svelte'; | ||||
|   | ||||
| @@ -0,0 +1,32 @@ | ||||
| .asset-marker-icon { | ||||
| 	@apply rounded-full; | ||||
| 	@apply object-cover; | ||||
| 	@apply border; | ||||
| 	@apply border-immich-primary; | ||||
| 	@apply transition-all; | ||||
| 	box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, rgba(0, 0, 0, 0.07) 0px 2px 4px, | ||||
| 		rgba(0, 0, 0, 0.07) 0px 4px 8px, rgba(0, 0, 0, 0.07) 0px 8px 16px, | ||||
| 		rgba(0, 0, 0, 0.07) 0px 16px 32px, rgba(0, 0, 0, 0.07) 0px 32px 64px; | ||||
| } | ||||
|  | ||||
| .marker-cluster-icon { | ||||
| 	@apply h-full; | ||||
| 	@apply w-full; | ||||
| 	@apply flex; | ||||
| 	@apply justify-center; | ||||
| 	@apply items-center; | ||||
| 	@apply rounded-full; | ||||
| 	@apply font-bold; | ||||
| 	@apply bg-violet-50; | ||||
| 	@apply border; | ||||
| 	@apply border-immich-primary; | ||||
| 	@apply text-immich-primary; | ||||
| 	box-shadow: rgba(5, 5, 122, 0.12) 0px 2px 4px 0px, rgba(4, 4, 230, 0.32) 0px 2px 16px 0px; | ||||
| } | ||||
|  | ||||
| .dark .map-dark .marker-cluster-icon { | ||||
| 	@apply bg-blue-200; | ||||
| 	@apply text-black; | ||||
| 	@apply border-blue-200; | ||||
| 	box-shadow: none; | ||||
| } | ||||
| @@ -0,0 +1,104 @@ | ||||
| <script lang="ts" context="module"> | ||||
| 	import { createContext } from '$lib/utils/context'; | ||||
| 	import { MarkerClusterGroup } from 'leaflet'; | ||||
|  | ||||
| 	const { get: getContext, set: setClusterContext } = createContext<() => MarkerClusterGroup>(); | ||||
|  | ||||
| 	export const getClusterContext = () => { | ||||
| 		return getContext()(); | ||||
| 	}; | ||||
| </script> | ||||
|  | ||||
| <script lang="ts"> | ||||
| 	import { MapMarkerResponseDto } from '@api'; | ||||
| 	import { DivIcon, LeafletEvent, LeafletMouseEvent, MarkerCluster, Point } from 'leaflet'; | ||||
| 	import 'leaflet.markercluster'; | ||||
| 	import { createEventDispatcher, onDestroy, onMount } from 'svelte'; | ||||
| 	import { getMapContext } from '../map.svelte'; | ||||
| 	import AssetMarker from './asset-marker'; | ||||
| 	import './asset-marker-cluster.css'; | ||||
|  | ||||
| 	export let markers: MapMarkerResponseDto[]; | ||||
| 	export let spiderfyLimit = 10; | ||||
| 	let cluster: MarkerClusterGroup; | ||||
|  | ||||
| 	const map = getMapContext(); | ||||
| 	const dispatch = createEventDispatcher<{ | ||||
| 		view: { assetIds: string[]; activeAssetIndex: number }; | ||||
| 	}>(); | ||||
|  | ||||
| 	setClusterContext(() => cluster); | ||||
|  | ||||
| 	onMount(() => { | ||||
| 		cluster = new MarkerClusterGroup({ | ||||
| 			showCoverageOnHover: false, | ||||
| 			zoomToBoundsOnClick: false, | ||||
| 			spiderfyOnMaxZoom: false, | ||||
| 			maxClusterRadius: (zoom) => 80 - zoom * 2, | ||||
| 			spiderLegPolylineOptions: { opacity: 0 }, | ||||
| 			spiderfyDistanceMultiplier: 3, | ||||
| 			iconCreateFunction: (options) => { | ||||
| 				const childCount = options.getChildCount(); | ||||
| 				const iconSize = childCount > spiderfyLimit ? 45 : 40; | ||||
|  | ||||
| 				return new DivIcon({ | ||||
| 					html: `<div class="marker-cluster-icon">${childCount}</div>`, | ||||
| 					className: '', | ||||
| 					iconSize: new Point(iconSize, iconSize) | ||||
| 				}); | ||||
| 			} | ||||
| 		}); | ||||
|  | ||||
| 		cluster.on('clusterclick', (event: LeafletEvent) => { | ||||
| 			const markerCluster: MarkerCluster = event.sourceTarget; | ||||
| 			const childCount = markerCluster.getChildCount(); | ||||
|  | ||||
| 			if (childCount > spiderfyLimit) { | ||||
| 				const markers = markerCluster.getAllChildMarkers() as AssetMarker[]; | ||||
| 				onView(markers, markers[0].id); | ||||
| 			} else { | ||||
| 				markerCluster.spiderfy(); | ||||
| 			} | ||||
| 		}); | ||||
|  | ||||
| 		cluster.on('click', (event: LeafletMouseEvent) => { | ||||
| 			const marker: AssetMarker = event.sourceTarget; | ||||
| 			const markerCluster = getClusterByMarker(marker); | ||||
| 			const markers = markerCluster | ||||
| 				? (markerCluster.getAllChildMarkers() as AssetMarker[]) | ||||
| 				: [marker]; | ||||
|  | ||||
| 			onView(markers, marker.id); | ||||
| 		}); | ||||
|  | ||||
| 		map.addLayer(cluster); | ||||
| 	}); | ||||
|  | ||||
| 	/* eslint-disable-next-line  @typescript-eslint/no-explicit-any */ | ||||
| 	const getClusterByMarker = (marker: any): MarkerCluster | undefined => { | ||||
| 		const mapZoom = map.getZoom(); | ||||
|  | ||||
| 		while (marker && marker._zoom !== mapZoom) { | ||||
| 			marker = marker.__parent; | ||||
| 		} | ||||
|  | ||||
| 		return marker; | ||||
| 	}; | ||||
|  | ||||
| 	const onView = (markers: AssetMarker[], activeAssetId: string) => { | ||||
| 		const assetIds = markers.map((marker) => marker.id); | ||||
| 		const activeAssetIndex = assetIds.indexOf(activeAssetId) || 0; | ||||
| 		dispatch('view', { assetIds, activeAssetIndex }); | ||||
| 	}; | ||||
|  | ||||
| 	$: if (cluster) { | ||||
| 		const leafletMarkers = markers.map((marker) => new AssetMarker(marker)); | ||||
|  | ||||
| 		cluster.clearLayers(); | ||||
| 		cluster.addLayers(leafletMarkers); | ||||
| 	} | ||||
|  | ||||
| 	onDestroy(() => { | ||||
| 		if (cluster) cluster.remove(); | ||||
| 	}); | ||||
| </script> | ||||
| @@ -0,0 +1,37 @@ | ||||
| import { MapMarkerResponseDto, api } from '@api'; | ||||
| import { Marker, Map, Icon } from 'leaflet'; | ||||
|  | ||||
| export default class AssetMarker extends Marker { | ||||
| 	id: string; | ||||
| 	private iconCreated = false; | ||||
|  | ||||
| 	constructor(marker: MapMarkerResponseDto) { | ||||
| 		super([marker.lat, marker.lon]); | ||||
| 		this.id = marker.id; | ||||
| 	} | ||||
|  | ||||
| 	onAdd(map: Map) { | ||||
| 		// Set icon when the marker gets actually added to the map. This only | ||||
| 		// gets called for individual assets and when selecting a cluster, so | ||||
| 		// creating an icon for every marker in advance is pretty wasteful. | ||||
| 		if (!this.iconCreated) { | ||||
| 			this.iconCreated = true; | ||||
| 			this.setIcon(this.getIcon()); | ||||
| 		} | ||||
|  | ||||
| 		return super.onAdd(map); | ||||
| 	} | ||||
|  | ||||
| 	getIcon() { | ||||
| 		return new Icon({ | ||||
| 			iconUrl: api.getAssetThumbnailUrl(this.id), | ||||
| 			iconRetinaUrl: api.getAssetThumbnailUrl(this.id), | ||||
| 			iconSize: [60, 60], | ||||
| 			iconAnchor: [12, 41], | ||||
| 			popupAnchor: [1, -34], | ||||
| 			tooltipAnchor: [16, -28], | ||||
| 			shadowSize: [41, 41], | ||||
| 			className: 'asset-marker-icon' | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
| @@ -23,5 +23,8 @@ export const locale = persisted<string | undefined>('locale', undefined, { | ||||
|  | ||||
| export const mapSettings = persisted<MapSettings>('map-settings', { | ||||
| 	allowDarkMode: true, | ||||
| 	onlyFavorites: false | ||||
| 	onlyFavorites: false, | ||||
| 	relativeDate: '', | ||||
| 	dateAfter: '', | ||||
| 	dateBefore: '' | ||||
| }); | ||||
|   | ||||
| @@ -10,15 +10,17 @@ | ||||
| 	} from '$lib/stores/asset-interaction.store'; | ||||
| 	import { mapSettings } from '$lib/stores/preferences.store'; | ||||
| 	import { MapMarkerResponseDto, api } from '@api'; | ||||
| 	import { isEqual, omit } from 'lodash-es'; | ||||
| 	import { onDestroy, onMount } from 'svelte'; | ||||
| 	import Cog from 'svelte-material-icons/Cog.svelte'; | ||||
| 	import type { PageData } from './$types'; | ||||
| 	import { DateTime, Duration } from 'luxon'; | ||||
|  | ||||
| 	export let data: PageData; | ||||
|  | ||||
| 	let leaflet: typeof import('$lib/components/shared-components/leaflet'); | ||||
| 	let mapMarkers: MapMarkerResponseDto[]; | ||||
| 	let abortController = new AbortController(); | ||||
| 	let mapMarkers: MapMarkerResponseDto[] = []; | ||||
| 	let abortController: AbortController; | ||||
| 	let viewingAssets: string[] = []; | ||||
| 	let viewingAssetCursor = 0; | ||||
| 	let showSettingsModal = false; | ||||
| @@ -29,22 +31,59 @@ | ||||
| 	}); | ||||
|  | ||||
| 	onDestroy(() => { | ||||
| 		abortController.abort(); | ||||
| 		if (abortController) { | ||||
| 			abortController.abort(); | ||||
| 		} | ||||
| 		assetInteractionStore.clearMultiselect(); | ||||
| 		assetInteractionStore.setIsViewingAsset(false); | ||||
| 	}); | ||||
|  | ||||
| 	async function loadMapMarkers() { | ||||
| 		const { data } = await api.assetApi.getMapMarkers($mapSettings.onlyFavorites || undefined, { | ||||
| 			signal: abortController.signal | ||||
| 		}); | ||||
| 		if (abortController) { | ||||
| 			abortController.abort(); | ||||
| 		} | ||||
| 		abortController = new AbortController(); | ||||
|  | ||||
| 		const { onlyFavorites } = $mapSettings; | ||||
| 		const { fileCreatedAfter, fileCreatedBefore } = getFileCreatedDates(); | ||||
|  | ||||
| 		const { data } = await api.assetApi.getMapMarkers( | ||||
| 			onlyFavorites || undefined, | ||||
| 			fileCreatedAfter, | ||||
| 			fileCreatedBefore, | ||||
| 			{ | ||||
| 				signal: abortController.signal | ||||
| 			} | ||||
| 		); | ||||
| 		return data; | ||||
| 	} | ||||
|  | ||||
| 	function onViewAssets(assets: string[]) { | ||||
| 		assetInteractionStore.setViewingAssetId(assets[0]); | ||||
| 		viewingAssets = assets; | ||||
| 		viewingAssetCursor = 0; | ||||
| 	function getFileCreatedDates() { | ||||
| 		const { relativeDate, dateAfter, dateBefore } = $mapSettings; | ||||
|  | ||||
| 		if (relativeDate) { | ||||
| 			const duration = Duration.fromISO(relativeDate); | ||||
| 			return { | ||||
| 				fileCreatedAfter: duration.isValid ? DateTime.now().minus(duration).toISO() : undefined | ||||
| 			}; | ||||
| 		} | ||||
|  | ||||
| 		try { | ||||
| 			return { | ||||
| 				fileCreatedAfter: dateAfter ? new Date(dateAfter).toISOString() : undefined, | ||||
| 				fileCreatedBefore: dateBefore ? new Date(dateBefore).toISOString() : undefined | ||||
| 			}; | ||||
| 		} catch { | ||||
| 			$mapSettings.dateAfter = ''; | ||||
| 			$mapSettings.dateBefore = ''; | ||||
| 			return {}; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	function onViewAssets(assetIds: string[], activeAssetIndex: number) { | ||||
| 		assetInteractionStore.setViewingAssetId(assetIds[activeAssetIndex]); | ||||
| 		viewingAssets = assetIds; | ||||
| 		viewingAssetCursor = activeAssetIndex; | ||||
| 	} | ||||
|  | ||||
| 	function navigateNext() { | ||||
| @@ -58,31 +97,22 @@ | ||||
| 			assetInteractionStore.setViewingAssetId(viewingAssets[--viewingAssetCursor]); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	function getMapCenter(mapMarkers: MapMarkerResponseDto[]): [number, number] { | ||||
| 		const marker = mapMarkers[0]; | ||||
| 		if (marker) { | ||||
| 			return [marker.lat, marker.lon]; | ||||
| 		} | ||||
|  | ||||
| 		return [48, 11]; | ||||
| 	} | ||||
| </script> | ||||
|  | ||||
| <UserPageLayout user={data.user} title={data.meta.title}> | ||||
| 	<div class="h-full w-full isolate"> | ||||
| 		{#if leaflet && mapMarkers} | ||||
| 		{#if leaflet} | ||||
| 			{@const { Map, TileLayer, AssetMarkerCluster, Control } = leaflet} | ||||
| 			<Map | ||||
| 				center={getMapCenter(mapMarkers)} | ||||
| 				zoom={7} | ||||
| 				center={[30, 0]} | ||||
| 				zoom={3} | ||||
| 				allowDarkMode={$mapSettings.allowDarkMode} | ||||
| 				options={{ | ||||
| 					maxBounds: [ | ||||
| 						[-90, -180], | ||||
| 						[90, 180] | ||||
| 					], | ||||
| 					minZoom: 3 | ||||
| 					minZoom: 2.5 | ||||
| 				}} | ||||
| 			> | ||||
| 				<TileLayer | ||||
| @@ -94,7 +124,7 @@ | ||||
| 				/> | ||||
| 				<AssetMarkerCluster | ||||
| 					markers={mapMarkers} | ||||
| 					on:view={(event) => onViewAssets(event.detail.assets)} | ||||
| 					on:view={({ detail }) => onViewAssets(detail.assetIds, detail.activeAssetIndex)} | ||||
| 				/> | ||||
| 				<Control> | ||||
| 					<button | ||||
| @@ -129,7 +159,10 @@ | ||||
| 		settings={{ ...$mapSettings }} | ||||
| 		on:close={() => (showSettingsModal = false)} | ||||
| 		on:save={async ({ detail }) => { | ||||
| 			const shouldUpdate = detail.onlyFavorites !== $mapSettings.onlyFavorites; | ||||
| 			const shouldUpdate = !isEqual( | ||||
| 				omit(detail, 'allowDarkMode'), | ||||
| 				omit($mapSettings, 'allowDarkMode') | ||||
| 			); | ||||
| 			showSettingsModal = false; | ||||
| 			$mapSettings = detail; | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user