diff --git a/mobile/openapi/doc/MapMarkerResponseDto.md b/mobile/openapi/doc/MapMarkerResponseDto.md index 94f253d3802..81d8224dbf8 100644 --- a/mobile/openapi/doc/MapMarkerResponseDto.md +++ b/mobile/openapi/doc/MapMarkerResponseDto.md @@ -8,9 +8,12 @@ import 'package:openapi/api.dart'; ## Properties Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- +**city** | **String** | | +**country** | **String** | | **id** | **String** | | **lat** | **double** | | **lon** | **double** | | +**state** | **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/model/map_marker_response_dto.dart b/mobile/openapi/lib/model/map_marker_response_dto.dart index e4e099d62ea..8331f0679c6 100644 --- a/mobile/openapi/lib/model/map_marker_response_dto.dart +++ b/mobile/openapi/lib/model/map_marker_response_dto.dart @@ -13,38 +13,68 @@ part of openapi.api; class MapMarkerResponseDto { /// Returns a new [MapMarkerResponseDto] instance. MapMarkerResponseDto({ + required this.city, + required this.country, required this.id, required this.lat, required this.lon, + required this.state, }); + String? city; + + String? country; + String id; double lat; double lon; + String? state; + @override bool operator ==(Object other) => identical(this, other) || other is MapMarkerResponseDto && + other.city == city && + other.country == country && other.id == id && other.lat == lat && - other.lon == lon; + other.lon == lon && + other.state == state; @override int get hashCode => // ignore: unnecessary_parenthesis + (city == null ? 0 : city!.hashCode) + + (country == null ? 0 : country!.hashCode) + (id.hashCode) + (lat.hashCode) + - (lon.hashCode); + (lon.hashCode) + + (state == null ? 0 : state!.hashCode); @override - String toString() => 'MapMarkerResponseDto[id=$id, lat=$lat, lon=$lon]'; + String toString() => 'MapMarkerResponseDto[city=$city, country=$country, id=$id, lat=$lat, lon=$lon, state=$state]'; Map toJson() { final json = {}; + if (this.city != null) { + json[r'city'] = this.city; + } else { + // json[r'city'] = null; + } + if (this.country != null) { + json[r'country'] = this.country; + } else { + // json[r'country'] = null; + } json[r'id'] = this.id; json[r'lat'] = this.lat; json[r'lon'] = this.lon; + if (this.state != null) { + json[r'state'] = this.state; + } else { + // json[r'state'] = null; + } return json; } @@ -56,9 +86,12 @@ class MapMarkerResponseDto { final json = value.cast(); return MapMarkerResponseDto( + city: mapValueOfType(json, r'city'), + country: mapValueOfType(json, r'country'), id: mapValueOfType(json, r'id')!, lat: (mapValueOfType(json, r'lat')!).toDouble(), lon: (mapValueOfType(json, r'lon')!).toDouble(), + state: mapValueOfType(json, r'state'), ); } return null; @@ -106,9 +139,12 @@ class MapMarkerResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'city', + 'country', 'id', 'lat', 'lon', + 'state', }; } diff --git a/mobile/openapi/test/map_marker_response_dto_test.dart b/mobile/openapi/test/map_marker_response_dto_test.dart index f8308116ffd..9668260839f 100644 --- a/mobile/openapi/test/map_marker_response_dto_test.dart +++ b/mobile/openapi/test/map_marker_response_dto_test.dart @@ -16,6 +16,16 @@ void main() { // final instance = MapMarkerResponseDto(); group('test MapMarkerResponseDto', () { + // String city + test('to test the property `city`', () async { + // TODO + }); + + // String country + test('to test the property `country`', () async { + // TODO + }); + // String id test('to test the property `id`', () async { // TODO @@ -31,6 +41,11 @@ void main() { // TODO }); + // String state + test('to test the property `state`', () async { + // TODO + }); + }); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 4010336fdb5..8523d733a95 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8291,6 +8291,14 @@ }, "MapMarkerResponseDto": { "properties": { + "city": { + "nullable": true, + "type": "string" + }, + "country": { + "nullable": true, + "type": "string" + }, "id": { "type": "string" }, @@ -8301,12 +8309,19 @@ "lon": { "format": "double", "type": "number" + }, + "state": { + "nullable": true, + "type": "string" } }, "required": [ + "city", + "country", "id", "lat", - "lon" + "lon", + "state" ], "type": "object" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 6f4937e54c7..27de8a375e4 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -260,9 +260,12 @@ export type AssetJobsDto = { name: AssetJobName; }; export type MapMarkerResponseDto = { + city: string | null; + country: string | null; id: string; lat: number; lon: number; + state: string | null; }; export type MemoryLaneResponseDto = { assets: AssetResponseDto[]; diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index 9f077835a50..67721dc85f6 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -286,27 +286,22 @@ describe(AssetService.name, () => { describe('getMapMarkers', () => { it('should get geo information of assets', async () => { + const asset = assetStub.withLocation; + const marker = { + id: asset.id, + lat: asset.exifInfo!.latitude!, + lon: asset.exifInfo!.longitude!, + city: asset.exifInfo!.city, + state: asset.exifInfo!.state, + country: asset.exifInfo!.country, + }; partnerMock.getAll.mockResolvedValue([]); - assetMock.getMapMarkers.mockResolvedValue( - [assetStub.withLocation].map((asset) => ({ - id: asset.id, - - /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ - lat: asset.exifInfo!.latitude!, - - /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ - lon: asset.exifInfo!.longitude!, - })), - ); + assetMock.getMapMarkers.mockResolvedValue([marker]); const markers = await sut.getMapMarkers(authStub.user1, {}); expect(markers).toHaveLength(1); - expect(markers[0]).toEqual({ - id: assetStub.withLocation.id, - lat: 100, - lon: 100, - }); + expect(markers[0]).toEqual(marker); }); }); diff --git a/server/src/domain/asset/response-dto/map-marker-response.dto.ts b/server/src/domain/asset/response-dto/map-marker-response.dto.ts index 48c7b014951..f5148883f50 100644 --- a/server/src/domain/asset/response-dto/map-marker-response.dto.ts +++ b/server/src/domain/asset/response-dto/map-marker-response.dto.ts @@ -9,4 +9,13 @@ export class MapMarkerResponseDto { @ApiProperty({ format: 'double' }) lon!: number; + + @ApiProperty() + city!: string | null; + + @ApiProperty() + state!: string | null; + + @ApiProperty() + country!: string | null; } diff --git a/server/src/domain/repositories/asset.repository.ts b/server/src/domain/repositories/asset.repository.ts index 7a2941ab9d7..d0e22f676fc 100644 --- a/server/src/domain/repositories/asset.repository.ts +++ b/server/src/domain/repositories/asset.repository.ts @@ -1,4 +1,9 @@ -import { AssetSearchOneToOneRelationOptions, AssetSearchOptions, SearchExploreItem } from '@app/domain'; +import { + AssetSearchOneToOneRelationOptions, + AssetSearchOptions, + ReverseGeocodeResult, + SearchExploreItem, +} from '@app/domain'; import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity } from '@app/infra/entities'; import { FindOptionsRelations, FindOptionsSelect } from 'typeorm'; import { Paginated, PaginationOptions } from '../domain.util'; @@ -25,7 +30,7 @@ export interface MapMarkerSearchOptions { fileCreatedAfter?: Date; } -export interface MapMarker { +export interface MapMarker extends ReverseGeocodeResult { id: string; lat: number; lon: number; diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index a31ee2ad445..4ed885e5818 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -507,6 +507,9 @@ export class AssetRepository implements IAssetRepository { select: { id: true, exifInfo: { + city: true, + state: true, + country: true, latitude: true, longitude: true, }, @@ -532,12 +535,11 @@ export class AssetRepository implements IAssetRepository { return assets.map((asset) => ({ id: asset.id, - - /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ lat: asset.exifInfo!.latitude!, - - /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ lon: asset.exifInfo!.longitude!, + city: asset.exifInfo!.city, + state: asset.exifInfo!.state, + country: asset.exifInfo!.country, })); } diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 36f646af639..3d880143eb9 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -482,6 +482,9 @@ export const assetStub = { latitude: 100, longitude: 100, fileSizeInByte: 23_456, + city: 'test-city', + state: 'test-state', + country: 'test-country', } as ExifEntity, deletedAt: null, }), diff --git a/web/src/lib/components/album-page/__tests__/album-card.spec.ts b/web/src/lib/components/album-page/__tests__/album-card.spec.ts index 508253c2c2f..8cca2f8c011 100644 --- a/web/src/lib/components/album-page/__tests__/album-card.spec.ts +++ b/web/src/lib/components/album-page/__tests__/album-card.spec.ts @@ -47,11 +47,11 @@ describe('AlbumCard component', () => { const detailsText = `${count} items` + (shared ? ' . Shared' : ''); expect(albumImgElement).toHaveAttribute('src'); - expect(albumImgElement).toHaveAttribute('alt', album.id); + expect(albumImgElement).toHaveAttribute('alt', album.albumName); await waitFor(() => expect(albumImgElement).toHaveAttribute('src')); - expect(albumImgElement).toHaveAttribute('alt', album.id); + expect(albumImgElement).toHaveAttribute('alt', album.albumName); expect(sdkMock.getAssetThumbnail).not.toHaveBeenCalled(); expect(albumNameElement).toHaveTextContent(album.albumName); @@ -74,11 +74,11 @@ describe('AlbumCard component', () => { const albumImgElement = sut.getByTestId('album-image'); const albumNameElement = sut.getByTestId('album-name'); const albumDetailsElement = sut.getByTestId('album-details'); - expect(albumImgElement).toHaveAttribute('alt', album.id); + expect(albumImgElement).toHaveAttribute('alt', album.albumName); await waitFor(() => expect(albumImgElement).toHaveAttribute('src', thumbnailUrl)); - expect(albumImgElement).toHaveAttribute('alt', album.id); + expect(albumImgElement).toHaveAttribute('alt', album.albumName); expect(sdkMock.getAssetThumbnail).toHaveBeenCalledTimes(1); expect(sdkMock.getAssetThumbnail).toHaveBeenCalledWith({ id: 'thumbnailIdOne', diff --git a/web/src/lib/components/album-page/album-card.svelte b/web/src/lib/components/album-page/album-card.svelte index e11ee18ba00..8e54af18ca5 100644 --- a/web/src/lib/components/album-page/album-card.svelte +++ b/web/src/lib/components/album-page/album-card.svelte @@ -72,7 +72,7 @@ {album.id} {/if} @@ -241,7 +241,7 @@ like-thumbnail {/if} diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 7f94857afcd..47dc9d3e9d6 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -616,7 +616,16 @@ {:then component} diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index 9c82ad77b7b..8e5b7390010 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -8,7 +8,7 @@ import Icon from '$lib/components/elements/icon.svelte'; export let url: string; - export let altText: string; + export let altText: string | undefined; export let title: string | null = null; export let heightStyle: string | undefined = undefined; export let widthStyle: string; diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 8ee042a1a60..18bef1c627f 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -3,6 +3,7 @@ import Icon from '$lib/components/elements/icon.svelte'; import { ProjectionType } from '$lib/constants'; import { getAssetFileUrl, getAssetThumbnailUrl, isSharedLink } from '$lib/utils'; + import { getAltText } from '$lib/utils/thumbnail-util'; import { timeToSeconds } from '$lib/utils/date-time'; import { AssetTypeEnum, ThumbnailFormat, type AssetResponseDto } from '@immich/sdk'; import { @@ -177,7 +178,7 @@ {#if asset.resized} {:else} @@ -179,7 +179,7 @@ class="h-full w-full rounded-2xl object-cover" src="$lib/assets/no-thumbnail.png" sizes="min(271px,186px)" - alt="" + alt="Previous memory" draggable="false" /> {/if} @@ -203,7 +203,7 @@ transition:fade class="h-full w-full rounded-2xl object-contain transition-all" src={getAssetThumbnailUrl(currentAsset.id, ThumbnailFormat.Jpeg)} - alt="" + alt={currentAsset.exifInfo?.description} draggable="false" /> {/key} @@ -244,7 +244,7 @@ {:else} @@ -252,7 +252,7 @@ class="h-full w-full rounded-2xl object-cover" src="$lib/assets/no-thumbnail.png" sizes="min(271px,186px)" - alt="" + alt="Next memory" draggable="false" /> {/if} diff --git a/web/src/lib/components/photos-page/memory-lane.svelte b/web/src/lib/components/photos-page/memory-lane.svelte index 23d1beab515..51c1e4fc673 100644 --- a/web/src/lib/components/photos-page/memory-lane.svelte +++ b/web/src/lib/components/photos-page/memory-lane.svelte @@ -4,6 +4,7 @@ import { AppRoute, QueryParameter } from '$lib/constants'; import { memoryStore } from '$lib/stores/memory.store'; import { getAssetThumbnailUrl } from '$lib/utils'; + import { getAltText } from '$lib/utils/thumbnail-util'; import { ThumbnailFormat, getMemoryLane } from '@immich/sdk'; import { mdiChevronLeft, mdiChevronRight } from '@mdi/js'; import { onMount } from 'svelte'; @@ -64,7 +65,6 @@ {/if} {/if} -
{#each $memoryStore as memory, index (memory.title)}
@@ -134,7 +134,7 @@ type="reset" class="rounded-full p-2 hover:bg-immich-primary/5 active:bg-immich-primary/10 dark:text-immich-dark-fg/75 dark:hover:bg-immich-dark-primary/25 dark:active:bg-immich-dark-primary/[.35]" > - + {/if} diff --git a/web/src/lib/utils/thumbnail-util.ts b/web/src/lib/utils/thumbnail-util.ts index 19448b944f9..d349c4e8307 100644 --- a/web/src/lib/utils/thumbnail-util.ts +++ b/web/src/lib/utils/thumbnail-util.ts @@ -1,3 +1,6 @@ +import type { AssetResponseDto } from '@immich/sdk'; +import { fromLocalDateTime } from './timeline-util'; + /** * Calculate thumbnail size based on number of assets and viewport width * @param assetCount Number of assets in the view @@ -31,3 +34,30 @@ export function getThumbnailSize(assetCount: number, viewWidth: number): number return 300; } + +export function getAltText(asset: AssetResponseDto) { + if (asset.exifInfo?.description) { + return asset.exifInfo.description; + } + + let altText = 'Image taken'; + if (asset.exifInfo?.city && asset.exifInfo.country) { + altText += ` in ${asset.exifInfo.city}, ${asset.exifInfo.country}`; + } + + const names = asset.people?.filter((p) => p.name).map((p) => p.name) ?? []; + if (names.length == 1) { + altText += ` with ${names[0]}`; + } + if (names.length > 1 && names.length <= 3) { + altText += ` with ${names.slice(0, -1).join(', ')} and ${names.at(-1)}`; + } + if (names.length > 3) { + altText += ` with ${names.slice(0, 2).join(', ')}, and ${names.length - 2} others`; + } + + const date = fromLocalDateTime(asset.localDateTime).toLocaleString({ dateStyle: 'long' }); + altText += ` on ${date}`; + + return altText; +}