From 062e2eca6fbeb4fe781839b15f25f5dfc87e322a Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Thu, 25 May 2023 18:47:52 +0200 Subject: [PATCH] feat(web+server): map date filters + small changes (#2565) --- mobile/openapi/doc/AssetApi.md | 8 +- mobile/openapi/lib/api/asset_api.dart | 20 +++- mobile/openapi/test/asset_api_test.dart | 2 +- server/immich-openapi-specs.json | 18 +++ .../libs/domain/src/asset/asset.repository.ts | 2 + .../domain/src/asset/dto/map-marker.dto.ts | 14 ++- .../src/repositories/asset.repository.ts | 4 +- .../infra/src/utils/optional-between.util.ts | 15 +++ web/src/api/open-api/api.ts | 34 ++++-- .../map-page/map-settings-modal.svelte | 93 +++++++++++++++- .../leaflet/asset-marker-cluster.css | 39 ------- .../leaflet/asset-marker-cluster.svelte | 95 ---------------- .../shared-components/leaflet/index.ts | 2 +- .../marker-cluster/asset-marker-cluster.css | 32 ++++++ .../asset-marker-cluster.svelte | 104 ++++++++++++++++++ .../leaflet/marker-cluster/asset-marker.ts | 37 +++++++ web/src/lib/stores/preferences.store.ts | 5 +- web/src/routes/(user)/map/+page.svelte | 83 +++++++++----- 18 files changed, 429 insertions(+), 178 deletions(-) create mode 100644 server/libs/infra/src/utils/optional-between.util.ts delete mode 100644 web/src/lib/components/shared-components/leaflet/asset-marker-cluster.css delete mode 100644 web/src/lib/components/shared-components/leaflet/asset-marker-cluster.svelte create mode 100644 web/src/lib/components/shared-components/leaflet/marker-cluster/asset-marker-cluster.css create mode 100644 web/src/lib/components/shared-components/leaflet/marker-cluster/asset-marker-cluster.svelte create mode 100644 web/src/lib/components/shared-components/leaflet/marker-cluster/asset-marker.ts diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index 2e8010331..9bee8759a 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/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 getMapMarkers(isFavorite) +> List 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 diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index dfe61149b..609a6536c 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -1042,7 +1042,11 @@ class AssetApi { /// Parameters: /// /// * [bool] isFavorite: - Future getMapMarkersWithHttpInfo({ bool? isFavorite, }) async { + /// + /// * [DateTime] fileCreatedAfter: + /// + /// * [DateTime] fileCreatedBefore: + Future 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 = []; @@ -1074,8 +1084,12 @@ class AssetApi { /// Parameters: /// /// * [bool] isFavorite: - Future?> getMapMarkers({ bool? isFavorite, }) async { - final response = await getMapMarkersWithHttpInfo( isFavorite: isFavorite, ); + /// + /// * [DateTime] fileCreatedAfter: + /// + /// * [DateTime] fileCreatedBefore: + Future?> 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)); } diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index cbbd403bb..e9615e2d5 100644 --- a/mobile/openapi/test/asset_api_test.dart +++ b/mobile/openapi/test/asset_api_test.dart @@ -124,7 +124,7 @@ void main() { // TODO }); - //Future> getMapMarkers({ bool isFavorite }) async + //Future> getMapMarkers({ bool isFavorite, DateTime fileCreatedAfter, DateTime fileCreatedBefore }) async test('test getMapMarkers', () async { // TODO }); diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 48181fe67..1c5ecdf1b 100644 --- a/server/immich-openapi-specs.json +++ b/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": { diff --git a/server/libs/domain/src/asset/asset.repository.ts b/server/libs/domain/src/asset/asset.repository.ts index 929701879..efdfe2eca 100644 --- a/server/libs/domain/src/asset/asset.repository.ts +++ b/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 { diff --git a/server/libs/domain/src/asset/dto/map-marker.dto.ts b/server/libs/domain/src/asset/dto/map-marker.dto.ts index 8d39ebf22..1fef60c16 100644 --- a/server/libs/domain/src/asset/dto/map-marker.dto.ts +++ b/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; } diff --git a/server/libs/infra/src/repositories/asset.repository.ts b/server/libs/infra/src/repositories/asset.repository.ts index 2653a5671..f1fb308f1 100644 --- a/server/libs/infra/src/repositories/asset.repository.ts +++ b/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 { - 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, diff --git a/server/libs/infra/src/utils/optional-between.util.ts b/server/libs/infra/src/utils/optional-between.util.ts new file mode 100644 index 000000000..627af28b3 --- /dev/null +++ b/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(from?: T, to?: T) { + if (from && to) { + return Between(from, to); + } else if (from) { + return MoreThanOrEqual(from); + } else if (to) { + return LessThanOrEqual(to); + } +} diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index e2a4ebe69..66ffb7ad8 100644 --- a/web/src/api/open-api/api.ts +++ b/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 => { + getMapMarkers: async (isFavorite?: boolean, fileCreatedAfter?: string, fileCreatedBefore?: string, 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); @@ -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>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getMapMarkers(isFavorite, options); + async getMapMarkers(isFavorite?: boolean, fileCreatedAfter?: string, fileCreatedBefore?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + 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> { - return localVarFp.getMapMarkers(isFavorite, options).then((request) => request(axios, basePath)); + getMapMarkers(isFavorite?: boolean, fileCreatedAfter?: string, fileCreatedBefore?: string, options?: any): AxiosPromise> { + 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)); } /** diff --git a/web/src/lib/components/map-page/map-settings-modal.svelte b/web/src/lib/components/map-page/map-settings-modal.svelte index 9f14163d1..823536fc7 100644 --- a/web/src/lib/components/map-page/map-settings-modal.svelte +++ b/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; } - - diff --git a/web/src/lib/components/shared-components/leaflet/index.ts b/web/src/lib/components/shared-components/leaflet/index.ts index 53de7d296..73248651a 100644 --- a/web/src/lib/components/shared-components/leaflet/index.ts +++ b/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'; diff --git a/web/src/lib/components/shared-components/leaflet/marker-cluster/asset-marker-cluster.css b/web/src/lib/components/shared-components/leaflet/marker-cluster/asset-marker-cluster.css new file mode 100644 index 000000000..4998aa363 --- /dev/null +++ b/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; +} diff --git a/web/src/lib/components/shared-components/leaflet/marker-cluster/asset-marker-cluster.svelte b/web/src/lib/components/shared-components/leaflet/marker-cluster/asset-marker-cluster.svelte new file mode 100644 index 000000000..dcd5ddece --- /dev/null +++ b/web/src/lib/components/shared-components/leaflet/marker-cluster/asset-marker-cluster.svelte @@ -0,0 +1,104 @@ + + + diff --git a/web/src/lib/components/shared-components/leaflet/marker-cluster/asset-marker.ts b/web/src/lib/components/shared-components/leaflet/marker-cluster/asset-marker.ts new file mode 100644 index 000000000..38e6299b9 --- /dev/null +++ b/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' + }); + } +} diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts index cda82d181..e5c6a154f 100644 --- a/web/src/lib/stores/preferences.store.ts +++ b/web/src/lib/stores/preferences.store.ts @@ -23,5 +23,8 @@ export const locale = persisted('locale', undefined, { export const mapSettings = persisted('map-settings', { allowDarkMode: true, - onlyFavorites: false + onlyFavorites: false, + relativeDate: '', + dateAfter: '', + dateBefore: '' }); diff --git a/web/src/routes/(user)/map/+page.svelte b/web/src/routes/(user)/map/+page.svelte index 94ba41d99..3b1744ffa 100644 --- a/web/src/routes/(user)/map/+page.svelte +++ b/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]; - }
- {#if leaflet && mapMarkers} + {#if leaflet} {@const { Map, TileLayer, AssetMarkerCluster, Control } = leaflet} onViewAssets(event.detail.assets)} + on:view={({ detail }) => onViewAssets(detail.assetIds, detail.activeAssetIndex)} />