瀏覽代碼

feat(web+server): map date filters + small changes (#2565)

Michel Heusschen 2 年之前
父節點
當前提交
062e2eca6f

+ 6 - 2
mobile/openapi/doc/AssetApi.md

@@ -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
 

+ 17 - 3
mobile/openapi/lib/api/asset_api.dart

@@ -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));
     }

+ 1 - 1
mobile/openapi/test/asset_api_test.dart

@@ -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
     });

+ 18 - 0
server/immich-openapi-specs.json

@@ -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": {

+ 2 - 0
server/libs/domain/src/asset/asset.repository.ts

@@ -15,6 +15,8 @@ export interface LivePhotoSearchOptions {
 
 export interface MapMarkerSearchOptions {
   isFavorite?: boolean;
+  fileCreatedBefore?: string;
+  fileCreatedAfter?: string;
 }
 
 export interface MapMarker {

+ 13 - 1
server/libs/domain/src/asset/dto/map-marker.dto.ts

@@ -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;
 }

+ 3 - 1
server/libs/infra/src/repositories/asset.repository.ts

@@ -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 - 0
server/libs/infra/src/utils/optional-between.util.ts

@@ -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);
+  }
+}

+ 27 - 7
web/src/api/open-api/api.ts

@@ -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));
     }
 
     /**

+ 91 - 2
web/src/lib/components/map-page/map-settings-modal.svelte

@@ -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>

+ 0 - 39
web/src/lib/components/shared-components/leaflet/asset-marker-cluster.css

@@ -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;
-}

+ 0 - 95
web/src/lib/components/shared-components/leaflet/asset-marker-cluster.svelte

@@ -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 - 1
web/src/lib/components/shared-components/leaflet/index.ts

@@ -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';

+ 32 - 0
web/src/lib/components/shared-components/leaflet/marker-cluster/asset-marker-cluster.css

@@ -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;
+}

+ 104 - 0
web/src/lib/components/shared-components/leaflet/marker-cluster/asset-marker-cluster.svelte

@@ -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>

+ 37 - 0
web/src/lib/components/shared-components/leaflet/marker-cluster/asset-marker.ts

@@ -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'
+		});
+	}
+}

+ 4 - 1
web/src/lib/stores/preferences.store.ts

@@ -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: ''
 });

+ 58 - 25
web/src/routes/(user)/map/+page.svelte

@@ -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;