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 | Bin 57799 -> 58115 bytes mobile/openapi/lib/api/asset_api.dart | Bin 51714 -> 52327 bytes mobile/openapi/test/asset_api_test.dart | Bin 5548 -> 5603 bytes 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, 405 insertions(+), 172 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 2e8010331712a52b760a1e6809a44f542ff3875c..9bee8759acd91d967ae8442302808117aa042139 100644 GIT binary patch delta 328 zcmX?pn7R2F^M)6TRdp27GILU$i&7IyQd1n$N>YnZM4VF7@{3Y8Pn;^tGMROSN_|>p zUSbZ076n@cBLhQYT|)z1BZCk_ODiJ-D`Nv~14AnV18W6+eFYbw=^>f9sR}g;To^_` z>>$bv7LXy686CAJ2dt1F&h3*A-U*nzW5q5d1uZRfuWD%l9i*iNbKT^LL1M^$gE$OR Pk?`btMbXXeD;+EVj*f0b delta 43 zcmV+`0M!43#skO01F-0$vyqh@1e2iA29q$T9+TCmKa=n%P?L$Nx|4q)6|=*tKsexd B6-xjB diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index dfe61149b41d896159895deb213dc7ba218de67a..609a6536cdd95648bafffb38df082fb4f1d43e17 100644 GIT binary patch delta 516 zcmZpg!u)&&^M-4kljXg5U0o7OQbRIxQ)3mN*(4YfhfHPm~v`6KW_>s~VD4h$yOFh?wT&g&uz=Z&)ZjdBG8m$!mLfs2D0+dc4UF t6+>_B$r`-|9!Q>m#0rM@Q33?wDJx_}5TVVAa~?2G&RJ5mc|(7Y6##2YzODcO delta 64 zcmV-G0Kfm|m;-{81F+VKlZ9gxlS7XelURxrlT*7+v-65G0h7DDBa`5G?2|B#6qB2d WMw2s3DU(BwFtai;-T||lk<2>JCmp8% diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index cbbd403bb24e09b9f2ec2f9ca5d51646bec8ee14..e9615e2d50a140b999dad9536ab59f8b3d14b9b3 100644 GIT binary patch delta 67 zcmZ3Z{aAa0qJWu>f=gmaYDi{oszO?3PO5WJDv+Dvm{yWngk8!hH7&nrvw}bmD*!K* B7xMrB delta 16 XcmaE?y+(V3qQGQ(cFE0!0!6F { - 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)} />