Просмотр исходного кода

feat(web): Global map showing all assets with geo information (#2355)

* First crude implementation of the global asset map in web

* Use single DOM element for all markers

* Minor layout changes

* Refactor

* Add asset viewer

* Add API endpoint that returns only assets with location information (Thanks @EPP100)

* Remove sidebar icon flip

* Add dark theme support

* Center map to most recent asset

* Allow cluster viewing

* Fix linter errors

* Add newlines

* Fix ts errors

* Fix eslint error

* Run prettier

* Server code style

* Fix openapi mobile code generation issues

* Map markers test

* fix: Support video thumbnails

* Update API

* Review suggestions

* Review suggestions

* Linter error

* Chage mapMarker endpoint to map-marker

* Clean up leaflet imports
Matthias Rupp 2 лет назад
Родитель
Сommit
65daf342df
28 измененных файлов с 902 добавлено и 5 удалено
  1. 3 0
      mobile/openapi/.openapi-generator/FILES
  2. 2 0
      mobile/openapi/README.md
  3. 58 0
      mobile/openapi/doc/AssetApi.md
  4. 18 0
      mobile/openapi/doc/MapMarkerResponseDto.md
  5. 1 0
      mobile/openapi/lib/api.dart
  6. 73 0
      mobile/openapi/lib/api/asset_api.dart
  7. 2 0
      mobile/openapi/lib/api_client.dart
  8. 133 0
      mobile/openapi/lib/model/map_marker_response_dto.dart
  9. 7 0
      mobile/openapi/test/asset_api_test.dart
  10. 42 0
      mobile/openapi/test/map_marker_response_dto_test.dart
  11. 13 1
      server/apps/immich/src/api-v1/asset/asset.controller.ts
  12. 17 1
      server/apps/immich/src/api-v1/asset/asset.service.spec.ts
  13. 8 0
      server/apps/immich/src/api-v1/asset/asset.service.ts
  14. 83 0
      server/immich-openapi-specs.json
  15. 1 0
      server/libs/domain/src/asset/response-dto/index.ts
  16. 35 0
      server/libs/domain/src/asset/response-dto/map-marker-response.dto.ts
  17. 34 0
      web/package-lock.json
  18. 2 0
      web/package.json
  19. 119 0
      web/src/api/open-api/api.ts
  20. 1 1
      web/src/lib/components/asset-viewer/asset-viewer.svelte
  21. 119 0
      web/src/lib/components/shared-components/leaflet/asset-marker-cluster.svelte
  22. 1 0
      web/src/lib/components/shared-components/leaflet/index.ts
  23. 16 1
      web/src/lib/components/shared-components/leaflet/tile-layer.svelte
  24. 4 0
      web/src/lib/components/shared-components/side-bar/side-bar.svelte
  25. 1 0
      web/src/lib/constants.ts
  26. 6 1
      web/src/lib/stores/asset-interaction.store.ts
  27. 23 0
      web/src/routes/(user)/map/+page.server.ts
  28. 80 0
      web/src/routes/(user)/map/+page.svelte

+ 3 - 0
mobile/openapi/.openapi-generator/FILES

@@ -55,6 +55,7 @@ doc/JobStatusDto.md
 doc/LoginCredentialDto.md
 doc/LoginCredentialDto.md
 doc/LoginResponseDto.md
 doc/LoginResponseDto.md
 doc/LogoutResponseDto.md
 doc/LogoutResponseDto.md
+doc/MapMarkerResponseDto.md
 doc/OAuthApi.md
 doc/OAuthApi.md
 doc/OAuthCallbackDto.md
 doc/OAuthCallbackDto.md
 doc/OAuthConfigDto.md
 doc/OAuthConfigDto.md
@@ -171,6 +172,7 @@ lib/model/job_status_dto.dart
 lib/model/login_credential_dto.dart
 lib/model/login_credential_dto.dart
 lib/model/login_response_dto.dart
 lib/model/login_response_dto.dart
 lib/model/logout_response_dto.dart
 lib/model/logout_response_dto.dart
+lib/model/map_marker_response_dto.dart
 lib/model/o_auth_callback_dto.dart
 lib/model/o_auth_callback_dto.dart
 lib/model/o_auth_config_dto.dart
 lib/model/o_auth_config_dto.dart
 lib/model/o_auth_config_response_dto.dart
 lib/model/o_auth_config_response_dto.dart
@@ -264,6 +266,7 @@ test/job_status_dto_test.dart
 test/login_credential_dto_test.dart
 test/login_credential_dto_test.dart
 test/login_response_dto_test.dart
 test/login_response_dto_test.dart
 test/logout_response_dto_test.dart
 test/logout_response_dto_test.dart
+test/map_marker_response_dto_test.dart
 test/o_auth_api_test.dart
 test/o_auth_api_test.dart
 test/o_auth_callback_dto_test.dart
 test/o_auth_callback_dto_test.dart
 test/o_auth_config_dto_test.dart
 test/o_auth_config_dto_test.dart

+ 2 - 0
mobile/openapi/README.md

@@ -107,6 +107,7 @@ Class | Method | HTTP request | Description
 *AssetApi* | [**getAssetThumbnail**](doc//AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{assetId} | 
 *AssetApi* | [**getAssetThumbnail**](doc//AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{assetId} | 
 *AssetApi* | [**getCuratedLocations**](doc//AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations | 
 *AssetApi* | [**getCuratedLocations**](doc//AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations | 
 *AssetApi* | [**getCuratedObjects**](doc//AssetApi.md#getcuratedobjects) | **GET** /asset/curated-objects | 
 *AssetApi* | [**getCuratedObjects**](doc//AssetApi.md#getcuratedobjects) | **GET** /asset/curated-objects | 
+*AssetApi* | [**getMapMarkers**](doc//AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | 
 *AssetApi* | [**getUserAssetsByDeviceId**](doc//AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | 
 *AssetApi* | [**getUserAssetsByDeviceId**](doc//AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | 
 *AssetApi* | [**removeAssetsFromSharedLink**](doc//AssetApi.md#removeassetsfromsharedlink) | **PATCH** /asset/shared-link/remove | 
 *AssetApi* | [**removeAssetsFromSharedLink**](doc//AssetApi.md#removeassetsfromsharedlink) | **PATCH** /asset/shared-link/remove | 
 *AssetApi* | [**searchAsset**](doc//AssetApi.md#searchasset) | **POST** /asset/search | 
 *AssetApi* | [**searchAsset**](doc//AssetApi.md#searchasset) | **POST** /asset/search | 
@@ -209,6 +210,7 @@ Class | Method | HTTP request | Description
  - [LoginCredentialDto](doc//LoginCredentialDto.md)
  - [LoginCredentialDto](doc//LoginCredentialDto.md)
  - [LoginResponseDto](doc//LoginResponseDto.md)
  - [LoginResponseDto](doc//LoginResponseDto.md)
  - [LogoutResponseDto](doc//LogoutResponseDto.md)
  - [LogoutResponseDto](doc//LogoutResponseDto.md)
+ - [MapMarkerResponseDto](doc//MapMarkerResponseDto.md)
  - [OAuthCallbackDto](doc//OAuthCallbackDto.md)
  - [OAuthCallbackDto](doc//OAuthCallbackDto.md)
  - [OAuthConfigDto](doc//OAuthConfigDto.md)
  - [OAuthConfigDto](doc//OAuthConfigDto.md)
  - [OAuthConfigResponseDto](doc//OAuthConfigResponseDto.md)
  - [OAuthConfigResponseDto](doc//OAuthConfigResponseDto.md)

+ 58 - 0
mobile/openapi/doc/AssetApi.md

@@ -27,6 +27,7 @@ Method | HTTP request | Description
 [**getAssetThumbnail**](AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{assetId} | 
 [**getAssetThumbnail**](AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{assetId} | 
 [**getCuratedLocations**](AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations | 
 [**getCuratedLocations**](AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations | 
 [**getCuratedObjects**](AssetApi.md#getcuratedobjects) | **GET** /asset/curated-objects | 
 [**getCuratedObjects**](AssetApi.md#getcuratedobjects) | **GET** /asset/curated-objects | 
+[**getMapMarkers**](AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | 
 [**getUserAssetsByDeviceId**](AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | 
 [**getUserAssetsByDeviceId**](AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | 
 [**removeAssetsFromSharedLink**](AssetApi.md#removeassetsfromsharedlink) | **PATCH** /asset/shared-link/remove | 
 [**removeAssetsFromSharedLink**](AssetApi.md#removeassetsfromsharedlink) | **PATCH** /asset/shared-link/remove | 
 [**searchAsset**](AssetApi.md#searchasset) | **POST** /asset/search | 
 [**searchAsset**](AssetApi.md#searchasset) | **POST** /asset/search | 
@@ -1039,6 +1040,63 @@ 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)
 [[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, isArchived, skip)
+
+
+
+Get all assets that have GPS information embedded
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure API key authorization: cookie
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = AssetApi();
+final isFavorite = true; // bool | 
+final isArchived = true; // bool | 
+final skip = 8.14; // num | 
+
+try {
+    final result = api_instance.getMapMarkers(isFavorite, isArchived, skip);
+    print(result);
+} catch (e) {
+    print('Exception when calling AssetApi->getMapMarkers: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **isFavorite** | **bool**|  | [optional] 
+ **isArchived** | **bool**|  | [optional] 
+ **skip** | **num**|  | [optional] 
+
+### Return type
+
+[**List<MapMarkerResponseDto>**](MapMarkerResponseDto.md)
+
+### Authorization
+
+[cookie](../README.md#cookie), [bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: Not defined
+ - **Accept**: application/json
+
+[[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)
+
 # **getUserAssetsByDeviceId**
 # **getUserAssetsByDeviceId**
 > List<String> getUserAssetsByDeviceId(deviceId)
 > List<String> getUserAssetsByDeviceId(deviceId)
 
 

+ 18 - 0
mobile/openapi/doc/MapMarkerResponseDto.md

@@ -0,0 +1,18 @@
+# openapi.model.MapMarkerResponseDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**type** | [**AssetTypeEnum**](AssetTypeEnum.md) |  | 
+**lat** | **double** |  | 
+**lon** | **double** |  | 
+**id** | **String** |  | 
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 1 - 0
mobile/openapi/lib/api.dart

@@ -88,6 +88,7 @@ part 'model/job_status_dto.dart';
 part 'model/login_credential_dto.dart';
 part 'model/login_credential_dto.dart';
 part 'model/login_response_dto.dart';
 part 'model/login_response_dto.dart';
 part 'model/logout_response_dto.dart';
 part 'model/logout_response_dto.dart';
+part 'model/map_marker_response_dto.dart';
 part 'model/o_auth_callback_dto.dart';
 part 'model/o_auth_callback_dto.dart';
 part 'model/o_auth_config_dto.dart';
 part 'model/o_auth_config_dto.dart';
 part 'model/o_auth_config_response_dto.dart';
 part 'model/o_auth_config_response_dto.dart';

+ 73 - 0
mobile/openapi/lib/api/asset_api.dart

@@ -979,6 +979,79 @@ class AssetApi {
     return null;
     return null;
   }
   }
 
 
+  /// Get all assets that have GPS information embedded
+  ///
+  /// Note: This method returns the HTTP [Response].
+  ///
+  /// Parameters:
+  ///
+  /// * [bool] isFavorite:
+  ///
+  /// * [bool] isArchived:
+  ///
+  /// * [num] skip:
+  Future<Response> getMapMarkersWithHttpInfo({ bool? isFavorite, bool? isArchived, num? skip, }) async {
+    // ignore: prefer_const_declarations
+    final path = r'/asset/map-marker';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    if (isFavorite != null) {
+      queryParams.addAll(_queryParams('', 'isFavorite', isFavorite));
+    }
+    if (isArchived != null) {
+      queryParams.addAll(_queryParams('', 'isArchived', isArchived));
+    }
+    if (skip != null) {
+      queryParams.addAll(_queryParams('', 'skip', skip));
+    }
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Get all assets that have GPS information embedded
+  ///
+  /// Parameters:
+  ///
+  /// * [bool] isFavorite:
+  ///
+  /// * [bool] isArchived:
+  ///
+  /// * [num] skip:
+  Future<List<MapMarkerResponseDto>?> getMapMarkers({ bool? isFavorite, bool? isArchived, num? skip, }) async {
+    final response = await getMapMarkersWithHttpInfo( isFavorite: isFavorite, isArchived: isArchived, skip: skip, );
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+      final responseBody = await _decodeBodyBytes(response);
+      return (await apiClient.deserializeAsync(responseBody, 'List<MapMarkerResponseDto>') as List)
+        .cast<MapMarkerResponseDto>()
+        .toList();
+
+    }
+    return null;
+  }
+
   /// Get all asset of a device that are in the database, ID only.
   /// Get all asset of a device that are in the database, ID only.
   ///
   ///
   /// Note: This method returns the HTTP [Response].
   /// Note: This method returns the HTTP [Response].

+ 2 - 0
mobile/openapi/lib/api_client.dart

@@ -275,6 +275,8 @@ class ApiClient {
           return LoginResponseDto.fromJson(value);
           return LoginResponseDto.fromJson(value);
         case 'LogoutResponseDto':
         case 'LogoutResponseDto':
           return LogoutResponseDto.fromJson(value);
           return LogoutResponseDto.fromJson(value);
+        case 'MapMarkerResponseDto':
+          return MapMarkerResponseDto.fromJson(value);
         case 'OAuthCallbackDto':
         case 'OAuthCallbackDto':
           return OAuthCallbackDto.fromJson(value);
           return OAuthCallbackDto.fromJson(value);
         case 'OAuthConfigDto':
         case 'OAuthConfigDto':

+ 133 - 0
mobile/openapi/lib/model/map_marker_response_dto.dart

@@ -0,0 +1,133 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class MapMarkerResponseDto {
+  /// Returns a new [MapMarkerResponseDto] instance.
+  MapMarkerResponseDto({
+    required this.type,
+    required this.lat,
+    required this.lon,
+    required this.id,
+  });
+
+  AssetTypeEnum type;
+
+  double lat;
+
+  double lon;
+
+  String id;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is MapMarkerResponseDto &&
+     other.type == type &&
+     other.lat == lat &&
+     other.lon == lon &&
+     other.id == id;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (type.hashCode) +
+    (lat.hashCode) +
+    (lon.hashCode) +
+    (id.hashCode);
+
+  @override
+  String toString() => 'MapMarkerResponseDto[type=$type, lat=$lat, lon=$lon, id=$id]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'type'] = this.type;
+      json[r'lat'] = this.lat;
+      json[r'lon'] = this.lon;
+      json[r'id'] = this.id;
+    return json;
+  }
+
+  /// Returns a new [MapMarkerResponseDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static MapMarkerResponseDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      // Ensure that the map contains the required keys.
+      // Note 1: the values aren't checked for validity beyond being non-null.
+      // Note 2: this code is stripped in release mode!
+      assert(() {
+        requiredKeys.forEach((key) {
+          assert(json.containsKey(key), 'Required key "MapMarkerResponseDto[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "MapMarkerResponseDto[$key]" has a null value in JSON.');
+        });
+        return true;
+      }());
+
+      return MapMarkerResponseDto(
+        type: AssetTypeEnum.fromJson(json[r'type'])!,
+        lat: mapValueOfType<double>(json, r'lat')!,
+        lon: mapValueOfType<double>(json, r'lon')!,
+        id: mapValueOfType<String>(json, r'id')!,
+      );
+    }
+    return null;
+  }
+
+  static List<MapMarkerResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <MapMarkerResponseDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = MapMarkerResponseDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, MapMarkerResponseDto> mapFromJson(dynamic json) {
+    final map = <String, MapMarkerResponseDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = MapMarkerResponseDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of MapMarkerResponseDto-objects as value to a dart map
+  static Map<String, List<MapMarkerResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<MapMarkerResponseDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = MapMarkerResponseDto.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'type',
+    'lat',
+    'lon',
+    'id',
+  };
+}
+

+ 7 - 0
mobile/openapi/test/asset_api_test.dart

@@ -117,6 +117,13 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
+    // Get all assets that have GPS information embedded
+    //
+    //Future<List<MapMarkerResponseDto>> getMapMarkers({ bool isFavorite, bool isArchived, num skip }) async
+    test('test getMapMarkers', () async {
+      // TODO
+    });
+
     // Get all asset of a device that are in the database, ID only.
     // Get all asset of a device that are in the database, ID only.
     //
     //
     //Future<List<String>> getUserAssetsByDeviceId(String deviceId) async
     //Future<List<String>> getUserAssetsByDeviceId(String deviceId) async

+ 42 - 0
mobile/openapi/test/map_marker_response_dto_test.dart

@@ -0,0 +1,42 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for MapMarkerResponseDto
+void main() {
+  // final instance = MapMarkerResponseDto();
+
+  group('test MapMarkerResponseDto', () {
+    // AssetTypeEnum type
+    test('to test the property `type`', () async {
+      // TODO
+    });
+
+    // double lat
+    test('to test the property `lat`', () async {
+      // TODO
+    });
+
+    // double lon
+    test('to test the property `lon`', () async {
+      // TODO
+    });
+
+    // String id
+    test('to test the property `id`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 13 - 1
server/apps/immich/src/api-v1/asset/asset.controller.ts

@@ -31,7 +31,7 @@ import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
 import { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger';
 import { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger';
 import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
 import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
 import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
 import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
-import { AssetResponseDto, ImmichReadStream } from '@app/domain';
+import { AssetResponseDto, ImmichReadStream, MapMarkerResponseDto } from '@app/domain';
 import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
 import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
 import { CreateAssetDto, mapToUploadFile } from './dto/create-asset.dto';
 import { CreateAssetDto, mapToUploadFile } from './dto/create-asset.dto';
 import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
 import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
@@ -260,6 +260,18 @@ export class AssetController {
     return await this.assetService.getAssetByTimeBucket(authUser, getAssetByTimeBucketDto);
     return await this.assetService.getAssetByTimeBucket(authUser, getAssetByTimeBucketDto);
   }
   }
 
 
+  /**
+   * Get all assets that have GPS information embedded
+   */
+  @Authenticated()
+  @Get('/map-marker')
+  getMapMarkers(
+    @GetAuthUser() authUser: AuthUserDto,
+    @Query(new ValidationPipe({ transform: true })) dto: AssetSearchDto,
+  ): Promise<MapMarkerResponseDto[]> {
+    return this.assetService.getMapMarkers(authUser, dto);
+  }
+
   /**
   /**
    * Get all asset of a device that are in the database, ID only.
    * Get all asset of a device that are in the database, ID only.
    */
    */

+ 17 - 1
server/apps/immich/src/api-v1/asset/asset.service.spec.ts

@@ -1,7 +1,7 @@
 import { IAssetRepository } from './asset-repository';
 import { IAssetRepository } from './asset-repository';
 import { AssetService } from './asset.service';
 import { AssetService } from './asset.service';
 import { QueryFailedError, Repository } from 'typeorm';
 import { QueryFailedError, Repository } from 'typeorm';
-import { AssetEntity, AssetType } from '@app/infra/entities';
+import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
 import { CreateAssetDto } from './dto/create-asset.dto';
 import { CreateAssetDto } from './dto/create-asset.dto';
 import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
 import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
 import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
 import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
@@ -57,6 +57,9 @@ const _getAsset_1 = () => {
   asset_1.webpPath = '';
   asset_1.webpPath = '';
   asset_1.encodedVideoPath = '';
   asset_1.encodedVideoPath = '';
   asset_1.duration = '0:00:00.000000';
   asset_1.duration = '0:00:00.000000';
+  asset_1.exifInfo = new ExifEntity();
+  asset_1.exifInfo.latitude = 49.533547;
+  asset_1.exifInfo.longitude = 10.703075;
   return asset_1;
   return asset_1;
 };
 };
 
 
@@ -492,4 +495,17 @@ describe('AssetService', () => {
       expect(storageMock.createReadStream).toHaveBeenCalledWith('fake_path/asset_1.jpeg', 'image/jpeg');
       expect(storageMock.createReadStream).toHaveBeenCalledWith('fake_path/asset_1.jpeg', 'image/jpeg');
     });
     });
   });
   });
+
+  describe('get map markers', () => {
+    it('should get geo information of assets', async () => {
+      assetRepositoryMock.getAllByUserId.mockResolvedValue(_getAssets());
+
+      const markers = await sut.getMapMarkers(authStub.admin, {});
+
+      expect(markers).toHaveLength(1);
+      expect(markers[0].lat).toBe(_getAsset_1().exifInfo?.latitude);
+      expect(markers[0].lon).toBe(_getAsset_1().exifInfo?.longitude);
+      expect(markers[0].id).toBe(_getAsset_1().id);
+    });
+  });
 });
 });

+ 8 - 0
server/apps/immich/src/api-v1/asset/asset.service.ts

@@ -30,6 +30,8 @@ import {
   JobName,
   JobName,
   mapAsset,
   mapAsset,
   mapAssetWithoutExif,
   mapAssetWithoutExif,
+  MapMarkerResponseDto,
+  mapAssetMapMarker,
 } from '@app/domain';
 } from '@app/domain';
 import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
 import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
 import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
 import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
@@ -142,6 +144,12 @@ export class AssetService {
     return assets.map((asset) => mapAsset(asset));
     return assets.map((asset) => mapAsset(asset));
   }
   }
 
 
+  public async getMapMarkers(authUser: AuthUserDto, dto: AssetSearchDto): Promise<MapMarkerResponseDto[]> {
+    const assets = await this._assetRepository.getAllByUserId(authUser.id, dto);
+
+    return assets.map((asset) => mapAssetMapMarker(asset)).filter((marker) => marker != null) as MapMarkerResponseDto[];
+  }
+
   public async getAssetByTimeBucket(
   public async getAssetByTimeBucket(
     authUser: AuthUserDto,
     authUser: AuthUserDto,
     getAssetByTimeBucketDto: GetAssetByTimeBucketDto,
     getAssetByTimeBucketDto: GetAssetByTimeBucketDto,

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

@@ -2601,6 +2601,64 @@
         ]
         ]
       }
       }
     },
     },
+    "/asset/map-marker": {
+      "get": {
+        "operationId": "getMapMarkers",
+        "description": "Get all assets that have GPS information embedded",
+        "parameters": [
+          {
+            "name": "isFavorite",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "type": "boolean"
+            }
+          },
+          {
+            "name": "isArchived",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "type": "boolean"
+            }
+          },
+          {
+            "name": "skip",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "type": "number"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "array",
+                  "items": {
+                    "$ref": "#/components/schemas/MapMarkerResponseDto"
+                  }
+                }
+              }
+            }
+          }
+        },
+        "tags": [
+          "Asset"
+        ],
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          }
+        ]
+      }
+    },
     "/asset/{deviceId}": {
     "/asset/{deviceId}": {
       "get": {
       "get": {
         "operationId": "getUserAssetsByDeviceId",
         "operationId": "getUserAssetsByDeviceId",
@@ -5426,6 +5484,31 @@
           "timeBucket"
           "timeBucket"
         ]
         ]
       },
       },
+      "MapMarkerResponseDto": {
+        "type": "object",
+        "properties": {
+          "type": {
+            "$ref": "#/components/schemas/AssetTypeEnum"
+          },
+          "lat": {
+            "type": "number",
+            "format": "double"
+          },
+          "lon": {
+            "type": "number",
+            "format": "double"
+          },
+          "id": {
+            "type": "string"
+          }
+        },
+        "required": [
+          "type",
+          "lat",
+          "lon",
+          "id"
+        ]
+      },
       "UpdateAssetDto": {
       "UpdateAssetDto": {
         "type": "object",
         "type": "object",
         "properties": {
         "properties": {

+ 1 - 0
server/libs/domain/src/asset/response-dto/index.ts

@@ -1,3 +1,4 @@
 export * from './asset-response.dto';
 export * from './asset-response.dto';
 export * from './exif-response.dto';
 export * from './exif-response.dto';
 export * from './smart-info-response.dto';
 export * from './smart-info-response.dto';
+export * from './map-marker-response.dto';

+ 35 - 0
server/libs/domain/src/asset/response-dto/map-marker-response.dto.ts

@@ -0,0 +1,35 @@
+import { AssetEntity, AssetType } from '@app/infra/entities';
+import { ApiProperty } from '@nestjs/swagger';
+
+export class MapMarkerResponseDto {
+  id!: string;
+
+  @ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType })
+  type!: AssetType;
+
+  @ApiProperty({ type: 'number', format: 'double' })
+  lat!: number;
+
+  @ApiProperty({ type: 'number', format: 'double' })
+  lon!: number;
+}
+
+export function mapAssetMapMarker(entity: AssetEntity): MapMarkerResponseDto | null {
+  if (!entity.exifInfo) {
+    return null;
+  }
+
+  const lat = entity.exifInfo.latitude;
+  const lon = entity.exifInfo.longitude;
+
+  if (!lat || !lon) {
+    return null;
+  }
+
+  return {
+    id: entity.id,
+    type: entity.type,
+    lon,
+    lat,
+  };
+}

+ 34 - 0
web/package-lock.json

@@ -13,6 +13,7 @@
 				"handlebars": "^4.7.7",
 				"handlebars": "^4.7.7",
 				"justified-layout": "^4.1.0",
 				"justified-layout": "^4.1.0",
 				"leaflet": "^1.9.3",
 				"leaflet": "^1.9.3",
+				"leaflet.markercluster": "^1.5.3",
 				"lodash-es": "^4.17.21",
 				"lodash-es": "^4.17.21",
 				"luxon": "^3.2.1",
 				"luxon": "^3.2.1",
 				"rxjs": "^7.8.0",
 				"rxjs": "^7.8.0",
@@ -31,6 +32,7 @@
 				"@types/cookie": "^0.5.1",
 				"@types/cookie": "^0.5.1",
 				"@types/justified-layout": "^4.1.0",
 				"@types/justified-layout": "^4.1.0",
 				"@types/leaflet": "^1.9.1",
 				"@types/leaflet": "^1.9.1",
+				"@types/leaflet.markercluster": "^1.5.1",
 				"@types/lodash-es": "^4.17.6",
 				"@types/lodash-es": "^4.17.6",
 				"@types/luxon": "^3.2.0",
 				"@types/luxon": "^3.2.0",
 				"@typescript-eslint/eslint-plugin": "^5.53.0",
 				"@typescript-eslint/eslint-plugin": "^5.53.0",
@@ -3622,6 +3624,15 @@
 				"@types/geojson": "*"
 				"@types/geojson": "*"
 			}
 			}
 		},
 		},
+		"node_modules/@types/leaflet.markercluster": {
+			"version": "1.5.1",
+			"resolved": "https://registry.npmjs.org/@types/leaflet.markercluster/-/leaflet.markercluster-1.5.1.tgz",
+			"integrity": "sha512-gzJzP10qO6Zkts5QNVmSAEDLYicQHTEBLT9HZpFrJiSww9eDAs5OWHvIskldf41MvDv1gbMukuEBQEawHn+wtA==",
+			"dev": true,
+			"dependencies": {
+				"@types/leaflet": "*"
+			}
+		},
 		"node_modules/@types/lodash": {
 		"node_modules/@types/lodash": {
 			"version": "4.14.191",
 			"version": "4.14.191",
 			"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz",
 			"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz",
@@ -9044,6 +9055,14 @@
 			"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.3.tgz",
 			"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.3.tgz",
 			"integrity": "sha512-iB2cR9vAkDOu5l3HAay2obcUHZ7xwUBBjph8+PGtmW/2lYhbLizWtG7nTeYht36WfOslixQF9D/uSIzhZgGMfQ=="
 			"integrity": "sha512-iB2cR9vAkDOu5l3HAay2obcUHZ7xwUBBjph8+PGtmW/2lYhbLizWtG7nTeYht36WfOslixQF9D/uSIzhZgGMfQ=="
 		},
 		},
+		"node_modules/leaflet.markercluster": {
+			"version": "1.5.3",
+			"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
+			"integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==",
+			"peerDependencies": {
+				"leaflet": "^1.3.1"
+			}
+		},
 		"node_modules/leven": {
 		"node_modules/leven": {
 			"version": "3.1.0",
 			"version": "3.1.0",
 			"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
 			"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@@ -14055,6 +14074,15 @@
 				"@types/geojson": "*"
 				"@types/geojson": "*"
 			}
 			}
 		},
 		},
+		"@types/leaflet.markercluster": {
+			"version": "1.5.1",
+			"resolved": "https://registry.npmjs.org/@types/leaflet.markercluster/-/leaflet.markercluster-1.5.1.tgz",
+			"integrity": "sha512-gzJzP10qO6Zkts5QNVmSAEDLYicQHTEBLT9HZpFrJiSww9eDAs5OWHvIskldf41MvDv1gbMukuEBQEawHn+wtA==",
+			"dev": true,
+			"requires": {
+				"@types/leaflet": "*"
+			}
+		},
 		"@types/lodash": {
 		"@types/lodash": {
 			"version": "4.14.191",
 			"version": "4.14.191",
 			"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz",
 			"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz",
@@ -18045,6 +18073,12 @@
 			"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.3.tgz",
 			"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.3.tgz",
 			"integrity": "sha512-iB2cR9vAkDOu5l3HAay2obcUHZ7xwUBBjph8+PGtmW/2lYhbLizWtG7nTeYht36WfOslixQF9D/uSIzhZgGMfQ=="
 			"integrity": "sha512-iB2cR9vAkDOu5l3HAay2obcUHZ7xwUBBjph8+PGtmW/2lYhbLizWtG7nTeYht36WfOslixQF9D/uSIzhZgGMfQ=="
 		},
 		},
+		"leaflet.markercluster": {
+			"version": "1.5.3",
+			"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
+			"integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==",
+			"requires": {}
+		},
 		"leven": {
 		"leven": {
 			"version": "3.1.0",
 			"version": "3.1.0",
 			"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
 			"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",

+ 2 - 0
web/package.json

@@ -29,6 +29,7 @@
 		"@types/cookie": "^0.5.1",
 		"@types/cookie": "^0.5.1",
 		"@types/justified-layout": "^4.1.0",
 		"@types/justified-layout": "^4.1.0",
 		"@types/leaflet": "^1.9.1",
 		"@types/leaflet": "^1.9.1",
+		"@types/leaflet.markercluster": "^1.5.1",
 		"@types/lodash-es": "^4.17.6",
 		"@types/lodash-es": "^4.17.6",
 		"@types/luxon": "^3.2.0",
 		"@types/luxon": "^3.2.0",
 		"@typescript-eslint/eslint-plugin": "^5.53.0",
 		"@typescript-eslint/eslint-plugin": "^5.53.0",
@@ -61,6 +62,7 @@
 		"handlebars": "^4.7.7",
 		"handlebars": "^4.7.7",
 		"justified-layout": "^4.1.0",
 		"justified-layout": "^4.1.0",
 		"leaflet": "^1.9.3",
 		"leaflet": "^1.9.3",
+		"leaflet.markercluster": "^1.5.3",
 		"lodash-es": "^4.17.21",
 		"lodash-es": "^4.17.21",
 		"luxon": "^3.2.1",
 		"luxon": "^3.2.1",
 		"rxjs": "^7.8.0",
 		"rxjs": "^7.8.0",

+ 119 - 0
web/src/api/open-api/api.ts

@@ -1438,6 +1438,39 @@ export interface LogoutResponseDto {
      */
      */
     'redirectUri': string;
     'redirectUri': string;
 }
 }
+/**
+ * 
+ * @export
+ * @interface MapMarkerResponseDto
+ */
+export interface MapMarkerResponseDto {
+    /**
+     * 
+     * @type {AssetTypeEnum}
+     * @memberof MapMarkerResponseDto
+     */
+    'type': AssetTypeEnum;
+    /**
+     * 
+     * @type {number}
+     * @memberof MapMarkerResponseDto
+     */
+    'lat': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof MapMarkerResponseDto
+     */
+    'lon': number;
+    /**
+     * 
+     * @type {string}
+     * @memberof MapMarkerResponseDto
+     */
+    'id': string;
+}
+
+
 /**
 /**
  * 
  * 
  * @export
  * @export
@@ -4752,6 +4785,56 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
 
 
 
 
     
     
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+        /**
+         * Get all assets that have GPS information embedded
+         * @param {boolean} [isFavorite] 
+         * @param {boolean} [isArchived] 
+         * @param {number} [skip] 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getMapMarkers: async (isFavorite?: boolean, isArchived?: boolean, skip?: number, 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);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // authentication cookie required
+
+            // authentication bearer required
+            // http bearer authentication required
+            await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+            if (isFavorite !== undefined) {
+                localVarQueryParameter['isFavorite'] = isFavorite;
+            }
+
+            if (isArchived !== undefined) {
+                localVarQueryParameter['isArchived'] = isArchived;
+            }
+
+            if (skip !== undefined) {
+                localVarQueryParameter['skip'] = skip;
+            }
+
+
+    
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -5321,6 +5404,18 @@ export const AssetApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.getCuratedObjects(options);
             const localVarAxiosArgs = await localVarAxiosParamCreator.getCuratedObjects(options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         },
+        /**
+         * Get all assets that have GPS information embedded
+         * @param {boolean} [isFavorite] 
+         * @param {boolean} [isArchived] 
+         * @param {number} [skip] 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async getMapMarkers(isFavorite?: boolean, isArchived?: boolean, skip?: number, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<MapMarkerResponseDto>>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getMapMarkers(isFavorite, isArchived, skip, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
         /**
          * Get all asset of a device that are in the database, ID only.
          * Get all asset of a device that are in the database, ID only.
          * @param {string} deviceId 
          * @param {string} deviceId 
@@ -5577,6 +5672,17 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
         getCuratedObjects(options?: any): AxiosPromise<Array<CuratedObjectsResponseDto>> {
         getCuratedObjects(options?: any): AxiosPromise<Array<CuratedObjectsResponseDto>> {
             return localVarFp.getCuratedObjects(options).then((request) => request(axios, basePath));
             return localVarFp.getCuratedObjects(options).then((request) => request(axios, basePath));
         },
         },
+        /**
+         * Get all assets that have GPS information embedded
+         * @param {boolean} [isFavorite] 
+         * @param {boolean} [isArchived] 
+         * @param {number} [skip] 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getMapMarkers(isFavorite?: boolean, isArchived?: boolean, skip?: number, options?: any): AxiosPromise<Array<MapMarkerResponseDto>> {
+            return localVarFp.getMapMarkers(isFavorite, isArchived, skip, options).then((request) => request(axios, basePath));
+        },
         /**
         /**
          * Get all asset of a device that are in the database, ID only.
          * Get all asset of a device that are in the database, ID only.
          * @param {string} deviceId 
          * @param {string} deviceId 
@@ -5863,6 +5969,19 @@ export class AssetApi extends BaseAPI {
         return AssetApiFp(this.configuration).getCuratedObjects(options).then((request) => request(this.axios, this.basePath));
         return AssetApiFp(this.configuration).getCuratedObjects(options).then((request) => request(this.axios, this.basePath));
     }
     }
 
 
+    /**
+     * Get all assets that have GPS information embedded
+     * @param {boolean} [isFavorite] 
+     * @param {boolean} [isArchived] 
+     * @param {number} [skip] 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof AssetApi
+     */
+    public getMapMarkers(isFavorite?: boolean, isArchived?: boolean, skip?: number, options?: AxiosRequestConfig) {
+        return AssetApiFp(this.configuration).getMapMarkers(isFavorite, isArchived, skip, options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
     /**
      * Get all asset of a device that are in the database, ID only.
      * Get all asset of a device that are in the database, ID only.
      * @param {string} deviceId 
      * @param {string} deviceId 

+ 1 - 1
web/src/lib/components/asset-viewer/asset-viewer.svelte

@@ -296,7 +296,7 @@
 
 
 <section
 <section
 	id="immich-asset-viewer"
 	id="immich-asset-viewer"
-	class="fixed h-screen w-screen left-0 top-0 overflow-y-hidden bg-black z-[999] grid grid-rows-[64px_1fr] grid-cols-4"
+	class="fixed h-screen w-screen left-0 top-0 overflow-y-hidden bg-black z-[1001] grid grid-rows-[64px_1fr] grid-cols-4"
 >
 >
 	<div class="col-start-1 col-span-4 row-start-1 row-span-1 z-[1000] transition-transform">
 	<div class="col-start-1 col-span-4 row-start-1 row-span-1 z-[1000] transition-transform">
 		<AssetViewerNavBar
 		<AssetViewerNavBar

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

@@ -0,0 +1,119 @@
+<script lang="ts" context="module">
+	import { createContext } from '$lib/utils/context';
+	import { MarkerClusterGroup, Marker, Icon, LeafletEvent } from 'leaflet';
+
+	const { get: getContext, set: setClusterContext } = createContext<() => MarkerClusterGroup>();
+
+	export const getClusterContext = () => {
+		return getContext()();
+	};
+</script>
+
+<script lang="ts">
+	import 'leaflet.markercluster';
+	import { onDestroy, onMount } from 'svelte';
+	import { getMapContext } from './map.svelte';
+	import { MapMarkerResponseDto, api } from '@api';
+	import { createEventDispatcher } from 'svelte';
+
+	class AssetMarker extends Marker {
+		marker: MapMarkerResponseDto;
+
+		constructor(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]
+				})
+			});
+
+			this.marker = marker;
+		}
+
+		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 });
+		});
+
+		for (let marker of markers) {
+			const leafletMarker = new AssetMarker(marker);
+
+			leafletMarker.on('click', () => {
+				dispatch('view', { assets: [marker.id] });
+			});
+
+			cluster.addLayer(leafletMarker);
+		}
+
+		map.addLayer(cluster);
+	});
+
+	onDestroy(() => {
+		if (cluster) cluster.remove();
+	});
+</script>
+
+{#if cluster}
+	<slot />
+{/if}
+
+<style>
+	:global(.leaflet-marker-icon) {
+		border-radius: 25%;
+	}
+
+	:global(.marker-cluster) {
+		background-clip: padding-box;
+		border-radius: 20px;
+	}
+
+	:global(.marker-cluster div) {
+		width: 40px;
+		height: 40px;
+		margin-left: 5px;
+		margin-top: 5px;
+
+		text-align: center;
+		border-radius: 20px;
+		font-weight: bold;
+
+		background-color: rgb(236, 237, 246);
+		color: rgb(69, 80, 169);
+	}
+
+	:global(.marker-cluster span) {
+		line-height: 40px;
+	}
+</style>

+ 1 - 0
web/src/lib/components/shared-components/leaflet/index.ts

@@ -1,3 +1,4 @@
 export { default as Map } from './map.svelte';
 export { default as Map } from './map.svelte';
 export { default as Marker } from './marker.svelte';
 export { default as Marker } from './marker.svelte';
 export { default as TileLayer } from './tile-layer.svelte';
 export { default as TileLayer } from './tile-layer.svelte';
+export { default as AssetMarkerCluster } from './asset-marker-cluster.svelte';

+ 16 - 1
web/src/lib/components/shared-components/leaflet/tile-layer.svelte

@@ -5,15 +5,30 @@
 
 
 	export let urlTemplate: string;
 	export let urlTemplate: string;
 	export let options: TileLayerOptions | undefined = undefined;
 	export let options: TileLayerOptions | undefined = undefined;
+	export let allowDarkMode = false;
+
 	let tileLayer: TileLayer;
 	let tileLayer: TileLayer;
 
 
 	const map = getMapContext();
 	const map = getMapContext();
 
 
 	onMount(() => {
 	onMount(() => {
-		tileLayer = new TileLayer(urlTemplate, options).addTo(map);
+		tileLayer = new TileLayer(urlTemplate, {
+			className: allowDarkMode ? 'leaflet-layer-dynamic' : 'leaflet-layer',
+			...options
+		}).addTo(map);
 	});
 	});
 
 
 	onDestroy(() => {
 	onDestroy(() => {
 		if (tileLayer) tileLayer.remove();
 		if (tileLayer) tileLayer.remove();
 	});
 	});
 </script>
 </script>
+
+<style>
+	:global(.leaflet-layer-dynamic) {
+		filter: brightness(100%) contrast(100%) saturate(80%);
+	}
+
+	:global(.dark .leaflet-layer-dynamic) {
+		filter: invert(100%) brightness(130%) saturate(0%);
+	}
+</style>

+ 4 - 0
web/src/lib/components/shared-components/side-bar/side-bar.svelte

@@ -6,6 +6,7 @@
 	import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
 	import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
 	import ArchiveArrowDownOutline from 'svelte-material-icons/ArchiveArrowDownOutline.svelte';
 	import ArchiveArrowDownOutline from 'svelte-material-icons/ArchiveArrowDownOutline.svelte';
 	import Magnify from 'svelte-material-icons/Magnify.svelte';
 	import Magnify from 'svelte-material-icons/Magnify.svelte';
+	import Map from 'svelte-material-icons/Map.svelte';
 	import StarOutline from 'svelte-material-icons/StarOutline.svelte';
 	import StarOutline from 'svelte-material-icons/StarOutline.svelte';
 	import { AppRoute } from '../../../constants';
 	import { AppRoute } from '../../../constants';
 	import LoadingSpinner from '../loading-spinner.svelte';
 	import LoadingSpinner from '../loading-spinner.svelte';
@@ -108,6 +109,9 @@
 			isSelected={$page.route.id === '/(user)/explore'}
 			isSelected={$page.route.id === '/(user)/explore'}
 		/>
 		/>
 	</a>
 	</a>
+	<a data-sveltekit-preload-data="hover" href={AppRoute.MAP} draggable="false">
+		<SideBarButton title="Map" logo={Map} isSelected={$page.route.id === '/(user)/map'} />
+	</a>
 	<a data-sveltekit-preload-data="hover" href={AppRoute.SHARING} draggable="false">
 	<a data-sveltekit-preload-data="hover" href={AppRoute.SHARING} draggable="false">
 		<SideBarButton
 		<SideBarButton
 			title="Sharing"
 			title="Sharing"

+ 1 - 0
web/src/lib/constants.ts

@@ -14,6 +14,7 @@ export enum AppRoute {
 	EXPLORE = '/explore',
 	EXPLORE = '/explore',
 	SHARING = '/sharing',
 	SHARING = '/sharing',
 	SEARCH = '/search',
 	SEARCH = '/search',
+	MAP = '/map',
 
 
 	AUTH_LOGIN = '/auth/login',
 	AUTH_LOGIN = '/auth/login',
 	AUTH_LOGOUT = '/auth/logout',
 	AUTH_LOGOUT = '/auth/logout',

+ 6 - 1
web/src/lib/stores/asset-interaction.store.ts

@@ -53,7 +53,11 @@ function createAssetInteractionStore() {
 	 * Asset Viewer
 	 * Asset Viewer
 	 */
 	 */
 	const setViewingAsset = async (asset: AssetResponseDto) => {
 	const setViewingAsset = async (asset: AssetResponseDto) => {
-		const { data } = await api.assetApi.getAssetById(asset.id);
+		setViewingAssetId(asset.id);
+	};
+
+	const setViewingAssetId = async (id: string) => {
+		const { data } = await api.assetApi.getAssetById(id);
 		viewingAssetStoreState.set(data);
 		viewingAssetStoreState.set(data);
 		isViewingAssetStoreState.set(true);
 		isViewingAssetStoreState.set(true);
 	};
 	};
@@ -140,6 +144,7 @@ function createAssetInteractionStore() {
 
 
 	return {
 	return {
 		setViewingAsset,
 		setViewingAsset,
+		setViewingAssetId,
 		setIsViewingAsset,
 		setIsViewingAsset,
 		navigateAsset,
 		navigateAsset,
 		addAssetToMultiselectGroup,
 		addAssetToMultiselectGroup,

+ 23 - 0
web/src/routes/(user)/map/+page.server.ts

@@ -0,0 +1,23 @@
+import { AppRoute } from '$lib/constants';
+import { redirect } from '@sveltejs/kit';
+import type { PageServerLoad } from './$types';
+
+export const load = (async ({ locals: { api, user } }) => {
+	if (!user) {
+		throw redirect(302, AppRoute.AUTH_LOGIN);
+	}
+
+	try {
+		const { data: mapMarkers } = await api.assetApi.getMapMarkers();
+
+		return {
+			user,
+			mapMarkers,
+			meta: {
+				title: 'Map'
+			}
+		};
+	} catch (e) {
+		throw redirect(302, AppRoute.AUTH_LOGIN);
+	}
+}) satisfies PageServerLoad;

+ 80 - 0
web/src/routes/(user)/map/+page.svelte

@@ -0,0 +1,80 @@
+<script lang="ts">
+	import type { PageData } from '../map/$types';
+	import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
+	import Portal from '$lib/components/shared-components/portal/portal.svelte';
+	import AssetViewer from '$lib/components/asset-viewer/asset-viewer.svelte';
+	import {
+		assetInteractionStore,
+		isViewingAssetStoreState,
+		viewingAssetStoreState
+	} from '$lib/stores/asset-interaction.store';
+
+	export let data: PageData;
+
+	let initialMapCenter: [number, number] = [48, 11];
+
+	$: {
+		if (data.mapMarkers.length) {
+			let firstMarker = data.mapMarkers[0];
+			initialMapCenter = [firstMarker.lat, firstMarker.lon];
+		}
+	}
+
+	let viewingAssets: string[] = [];
+	let viewingAssetCursor = 0;
+
+	function onViewAssets(assets: string[]) {
+		assetInteractionStore.setViewingAssetId(assets[0]);
+		viewingAssets = assets;
+		viewingAssetCursor = 0;
+	}
+
+	function navigateNext() {
+		if (viewingAssetCursor < viewingAssets.length - 1) {
+			assetInteractionStore.setViewingAssetId(viewingAssets[++viewingAssetCursor]);
+		}
+	}
+
+	function navigatePrevious() {
+		if (viewingAssetCursor > 0) {
+			assetInteractionStore.setViewingAssetId(viewingAssets[--viewingAssetCursor]);
+		}
+	}
+</script>
+
+<UserPageLayout user={data.user} title={data.meta.title}>
+	<div slot="buttons" />
+
+	<div class="h-[90%] w-full">
+		{#await import('$lib/components/shared-components/leaflet') then { Map, TileLayer, AssetMarkerCluster }}
+			<Map latlng={initialMapCenter} zoom={7}>
+				<TileLayer
+					allowDarkMode={true}
+					urlTemplate={'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'}
+					options={{
+						attribution:
+							'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
+					}}
+				/>
+				<AssetMarkerCluster
+					markers={data.mapMarkers}
+					on:view={(event) => onViewAssets(event.detail.assets)}
+				/>
+			</Map>
+		{/await}
+	</div>
+</UserPageLayout>
+
+<Portal target="body">
+	{#if $isViewingAssetStoreState}
+		<AssetViewer
+			asset={$viewingAssetStoreState}
+			showNavigation={viewingAssets.length > 1}
+			on:navigate-next={navigateNext}
+			on:navigate-previous={navigatePrevious}
+			on:close={() => {
+				assetInteractionStore.setIsViewingAsset(false);
+			}}
+		/>
+	{/if}
+</Portal>