diff --git a/mobile/openapi/doc/MapMarkerResponseDto.md b/mobile/openapi/doc/MapMarkerResponseDto.md index 94f253d380..81d8224dbf 100644 Binary files a/mobile/openapi/doc/MapMarkerResponseDto.md and b/mobile/openapi/doc/MapMarkerResponseDto.md differ diff --git a/mobile/openapi/lib/model/map_marker_response_dto.dart b/mobile/openapi/lib/model/map_marker_response_dto.dart index e4e099d62e..8331f0679c 100644 Binary files a/mobile/openapi/lib/model/map_marker_response_dto.dart and b/mobile/openapi/lib/model/map_marker_response_dto.dart differ diff --git a/mobile/openapi/test/map_marker_response_dto_test.dart b/mobile/openapi/test/map_marker_response_dto_test.dart index f8308116ff..9668260839 100644 Binary files a/mobile/openapi/test/map_marker_response_dto_test.dart and b/mobile/openapi/test/map_marker_response_dto_test.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 4010336fdb..8523d733a9 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 6f4937e54c..27de8a375e 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 9f077835a5..67721dc85f 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 48c7b01495..f5148883f5 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 7a2941ab9d..d0e22f676f 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 a31ee2ad44..4ed885e581 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 36f646af63..3d880143eb 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 508253c2c2..8cca2f8c01 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 e11ee18ba0..8e54af18ca 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 7f94857afc..47dc9d3e9d 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 9c82ad77b7..8e5b739001 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 8ee042a1a6..18bef1c627 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 23d1beab51..51c1e4fc67 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 19448b944f..d349c4e830 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; +}