diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 02067ef62..45537ddc3 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -55,6 +55,7 @@ doc/JobStatusDto.md doc/LoginCredentialDto.md doc/LoginResponseDto.md doc/LogoutResponseDto.md +doc/MapMarkerResponseDto.md doc/OAuthApi.md doc/OAuthCallbackDto.md doc/OAuthConfigDto.md @@ -171,6 +172,7 @@ lib/model/job_status_dto.dart lib/model/login_credential_dto.dart lib/model/login_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_config_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_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_callback_dto_test.dart test/o_auth_config_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index da4a892dc..0959d780e 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -107,6 +107,7 @@ Class | Method | HTTP request | Description *AssetApi* | [**getAssetThumbnail**](doc//AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{assetId} | *AssetApi* | [**getCuratedLocations**](doc//AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations | *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* | [**removeAssetsFromSharedLink**](doc//AssetApi.md#removeassetsfromsharedlink) | **PATCH** /asset/shared-link/remove | *AssetApi* | [**searchAsset**](doc//AssetApi.md#searchasset) | **POST** /asset/search | @@ -209,6 +210,7 @@ Class | Method | HTTP request | Description - [LoginCredentialDto](doc//LoginCredentialDto.md) - [LoginResponseDto](doc//LoginResponseDto.md) - [LogoutResponseDto](doc//LogoutResponseDto.md) + - [MapMarkerResponseDto](doc//MapMarkerResponseDto.md) - [OAuthCallbackDto](doc//OAuthCallbackDto.md) - [OAuthConfigDto](doc//OAuthConfigDto.md) - [OAuthConfigResponseDto](doc//OAuthConfigResponseDto.md) diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index 213369d79..3d12d41d8 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/mobile/openapi/doc/AssetApi.md @@ -27,6 +27,7 @@ Method | HTTP request | Description [**getAssetThumbnail**](AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{assetId} | [**getCuratedLocations**](AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations | [**getCuratedObjects**](AssetApi.md#getcuratedobjects) | **GET** /asset/curated-objects | +[**getMapMarkers**](AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | [**getUserAssetsByDeviceId**](AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | [**removeAssetsFromSharedLink**](AssetApi.md#removeassetsfromsharedlink) | **PATCH** /asset/shared-link/remove | [**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) +# **getMapMarkers** +> List 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('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('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.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** > List getUserAssetsByDeviceId(deviceId) diff --git a/mobile/openapi/doc/MapMarkerResponseDto.md b/mobile/openapi/doc/MapMarkerResponseDto.md new file mode 100644 index 000000000..5994a8ac3 --- /dev/null +++ b/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) + + diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 8b5b4f8e7..270cff8eb 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -88,6 +88,7 @@ part 'model/job_status_dto.dart'; part 'model/login_credential_dto.dart'; part 'model/login_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_config_dto.dart'; part 'model/o_auth_config_response_dto.dart'; diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index 79e853b97..997270092 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -979,6 +979,79 @@ class AssetApi { 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 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 = []; + final headerParams = {}; + final formParams = {}; + + 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 = []; + + + 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?> 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') as List) + .cast() + .toList(); + + } + return null; + } + /// Get all asset of a device that are in the database, ID only. /// /// Note: This method returns the HTTP [Response]. diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 4a9285c4c..dd8268f60 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -275,6 +275,8 @@ class ApiClient { return LoginResponseDto.fromJson(value); case 'LogoutResponseDto': return LogoutResponseDto.fromJson(value); + case 'MapMarkerResponseDto': + return MapMarkerResponseDto.fromJson(value); case 'OAuthCallbackDto': return OAuthCallbackDto.fromJson(value); case 'OAuthConfigDto': diff --git a/mobile/openapi/lib/model/map_marker_response_dto.dart b/mobile/openapi/lib/model/map_marker_response_dto.dart new file mode 100644 index 000000000..e8d0001ac --- /dev/null +++ b/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 toJson() { + final json = {}; + 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(); + + // 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(json, r'lat')!, + lon: mapValueOfType(json, r'lon')!, + id: mapValueOfType(json, r'id')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + 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 mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // 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> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + 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 = { + 'type', + 'lat', + 'lon', + 'id', + }; +} + diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index ee25a5526..b9091fbee 100644 --- a/mobile/openapi/test/asset_api_test.dart +++ b/mobile/openapi/test/asset_api_test.dart @@ -117,6 +117,13 @@ void main() { // TODO }); + // Get all assets that have GPS information embedded + // + //Future> 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. // //Future> getUserAssetsByDeviceId(String deviceId) async diff --git a/mobile/openapi/test/map_marker_response_dto_test.dart b/mobile/openapi/test/map_marker_response_dto_test.dart new file mode 100644 index 000000000..312d4052d --- /dev/null +++ b/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 + }); + + + }); + +} diff --git a/server/apps/immich/src/api-v1/asset/asset.controller.ts b/server/apps/immich/src/api-v1/asset/asset.controller.ts index 6088b8122..2a20b3dd4 100644 --- a/server/apps/immich/src/api-v1/asset/asset.controller.ts +++ b/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 { CuratedObjectsResponseDto } from './response-dto/curated-objects-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 { CreateAssetDto, mapToUploadFile } from './dto/create-asset.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); } + /** + * Get all assets that have GPS information embedded + */ + @Authenticated() + @Get('/map-marker') + getMapMarkers( + @GetAuthUser() authUser: AuthUserDto, + @Query(new ValidationPipe({ transform: true })) dto: AssetSearchDto, + ): Promise { + return this.assetService.getMapMarkers(authUser, dto); + } + /** * Get all asset of a device that are in the database, ID only. */ diff --git a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts index 0a5083296..a98349cb3 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts @@ -1,7 +1,7 @@ import { IAssetRepository } from './asset-repository'; import { AssetService } from './asset.service'; 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 { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto'; import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto'; @@ -57,6 +57,9 @@ const _getAsset_1 = () => { asset_1.webpPath = ''; asset_1.encodedVideoPath = ''; 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; }; @@ -492,4 +495,17 @@ describe('AssetService', () => { 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); + }); + }); }); diff --git a/server/apps/immich/src/api-v1/asset/asset.service.ts b/server/apps/immich/src/api-v1/asset/asset.service.ts index e9b3c7893..8fe18bb83 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.ts @@ -30,6 +30,8 @@ import { JobName, mapAsset, mapAssetWithoutExif, + MapMarkerResponseDto, + mapAssetMapMarker, } from '@app/domain'; import { CreateAssetDto, UploadFile } from './dto/create-asset.dto'; import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto'; @@ -142,6 +144,12 @@ export class AssetService { return assets.map((asset) => mapAsset(asset)); } + public async getMapMarkers(authUser: AuthUserDto, dto: AssetSearchDto): Promise { + const assets = await this._assetRepository.getAllByUserId(authUser.id, dto); + + return assets.map((asset) => mapAssetMapMarker(asset)).filter((marker) => marker != null) as MapMarkerResponseDto[]; + } + public async getAssetByTimeBucket( authUser: AuthUserDto, getAssetByTimeBucketDto: GetAssetByTimeBucketDto, diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 2a09bc6ba..788c8258e 100644 --- a/server/immich-openapi-specs.json +++ b/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}": { "get": { "operationId": "getUserAssetsByDeviceId", @@ -5426,6 +5484,31 @@ "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": { "type": "object", "properties": { diff --git a/server/libs/domain/src/asset/response-dto/index.ts b/server/libs/domain/src/asset/response-dto/index.ts index 1a274731d..7e17e324f 100644 --- a/server/libs/domain/src/asset/response-dto/index.ts +++ b/server/libs/domain/src/asset/response-dto/index.ts @@ -1,3 +1,4 @@ export * from './asset-response.dto'; export * from './exif-response.dto'; export * from './smart-info-response.dto'; +export * from './map-marker-response.dto'; diff --git a/server/libs/domain/src/asset/response-dto/map-marker-response.dto.ts b/server/libs/domain/src/asset/response-dto/map-marker-response.dto.ts new file mode 100644 index 000000000..b695b055a --- /dev/null +++ b/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, + }; +} diff --git a/web/package-lock.json b/web/package-lock.json index 7792fb095..f74c1be88 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -13,6 +13,7 @@ "handlebars": "^4.7.7", "justified-layout": "^4.1.0", "leaflet": "^1.9.3", + "leaflet.markercluster": "^1.5.3", "lodash-es": "^4.17.21", "luxon": "^3.2.1", "rxjs": "^7.8.0", @@ -31,6 +32,7 @@ "@types/cookie": "^0.5.1", "@types/justified-layout": "^4.1.0", "@types/leaflet": "^1.9.1", + "@types/leaflet.markercluster": "^1.5.1", "@types/lodash-es": "^4.17.6", "@types/luxon": "^3.2.0", "@typescript-eslint/eslint-plugin": "^5.53.0", @@ -3622,6 +3624,15 @@ "@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": { "version": "4.14.191", "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", "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": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -14055,6 +14074,15 @@ "@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": { "version": "4.14.191", "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", "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": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", diff --git a/web/package.json b/web/package.json index 2a9128542..d74f992a1 100644 --- a/web/package.json +++ b/web/package.json @@ -29,6 +29,7 @@ "@types/cookie": "^0.5.1", "@types/justified-layout": "^4.1.0", "@types/leaflet": "^1.9.1", + "@types/leaflet.markercluster": "^1.5.1", "@types/lodash-es": "^4.17.6", "@types/luxon": "^3.2.0", "@typescript-eslint/eslint-plugin": "^5.53.0", @@ -61,6 +62,7 @@ "handlebars": "^4.7.7", "justified-layout": "^4.1.0", "leaflet": "^1.9.3", + "leaflet.markercluster": "^1.5.3", "lodash-es": "^4.17.21", "luxon": "^3.2.1", "rxjs": "^7.8.0", diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 2d69e3395..0daea79b8 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -1438,6 +1438,39 @@ export interface LogoutResponseDto { */ '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 @@ -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 => { + 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); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -5321,6 +5404,18 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.getCuratedObjects(options); 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>> { + 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. * @param {string} deviceId @@ -5577,6 +5672,17 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath getCuratedObjects(options?: any): AxiosPromise> { 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> { + 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. * @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)); } + /** + * 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. * @param {string} deviceId diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index ecf6cc59b..4eec8f250 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -296,7 +296,7 @@
+ 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()(); + }; + + + + +{#if cluster} + +{/if} + + diff --git a/web/src/lib/components/shared-components/leaflet/index.ts b/web/src/lib/components/shared-components/leaflet/index.ts index ae216f501..b31e3a2ae 100644 --- a/web/src/lib/components/shared-components/leaflet/index.ts +++ b/web/src/lib/components/shared-components/leaflet/index.ts @@ -1,3 +1,4 @@ export { default as Map } from './map.svelte'; export { default as Marker } from './marker.svelte'; export { default as TileLayer } from './tile-layer.svelte'; +export { default as AssetMarkerCluster } from './asset-marker-cluster.svelte'; diff --git a/web/src/lib/components/shared-components/leaflet/tile-layer.svelte b/web/src/lib/components/shared-components/leaflet/tile-layer.svelte index c050d1c1d..bcbaff1d7 100644 --- a/web/src/lib/components/shared-components/leaflet/tile-layer.svelte +++ b/web/src/lib/components/shared-components/leaflet/tile-layer.svelte @@ -5,15 +5,30 @@ export let urlTemplate: string; export let options: TileLayerOptions | undefined = undefined; + export let allowDarkMode = false; + let tileLayer: TileLayer; const map = getMapContext(); onMount(() => { - tileLayer = new TileLayer(urlTemplate, options).addTo(map); + tileLayer = new TileLayer(urlTemplate, { + className: allowDarkMode ? 'leaflet-layer-dynamic' : 'leaflet-layer', + ...options + }).addTo(map); }); onDestroy(() => { if (tileLayer) tileLayer.remove(); }); + + diff --git a/web/src/lib/components/shared-components/side-bar/side-bar.svelte b/web/src/lib/components/shared-components/side-bar/side-bar.svelte index db437ba9a..66df696ae 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar.svelte @@ -6,6 +6,7 @@ import ImageOutline from 'svelte-material-icons/ImageOutline.svelte'; import ArchiveArrowDownOutline from 'svelte-material-icons/ArchiveArrowDownOutline.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 { AppRoute } from '../../../constants'; import LoadingSpinner from '../loading-spinner.svelte'; @@ -108,6 +109,9 @@ isSelected={$page.route.id === '/(user)/explore'} /> + + + { - 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); isViewingAssetStoreState.set(true); }; @@ -140,6 +144,7 @@ function createAssetInteractionStore() { return { setViewingAsset, + setViewingAssetId, setIsViewingAsset, navigateAsset, addAssetToMultiselectGroup, diff --git a/web/src/routes/(user)/map/+page.server.ts b/web/src/routes/(user)/map/+page.server.ts new file mode 100644 index 000000000..d4a7ff2e8 --- /dev/null +++ b/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; diff --git a/web/src/routes/(user)/map/+page.svelte b/web/src/routes/(user)/map/+page.svelte new file mode 100644 index 000000000..db9320cd1 --- /dev/null +++ b/web/src/routes/(user)/map/+page.svelte @@ -0,0 +1,80 @@ + + + +
+ + + + + + {#if $isViewingAssetStoreState} + 1} + on:navigate-next={navigateNext} + on:navigate-previous={navigatePrevious} + on:close={() => { + assetInteractionStore.setIsViewingAsset(false); + }} + /> + {/if} +