diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 329dee833e..0d0d732762 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -63,6 +63,7 @@ doc/LoginCredentialDto.md doc/LoginResponseDto.md doc/LogoutResponseDto.md doc/MapMarkerResponseDto.md +doc/MemoryLaneResponseDto.md doc/OAuthApi.md doc/OAuthCallbackDto.md doc/OAuthConfigDto.md @@ -194,6 +195,7 @@ 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/memory_lane_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 @@ -298,6 +300,7 @@ 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/memory_lane_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 7da1630df3..4da8661903 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index 4b6171d61a..a27740f95c 100644 Binary files a/mobile/openapi/doc/AssetApi.md and b/mobile/openapi/doc/AssetApi.md differ diff --git a/mobile/openapi/doc/MemoryLaneResponseDto.md b/mobile/openapi/doc/MemoryLaneResponseDto.md new file mode 100644 index 0000000000..9aafda1424 Binary files /dev/null and b/mobile/openapi/doc/MemoryLaneResponseDto.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index c71747965d..393854e745 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index 8b0eaa2be8..9646640c6b 100644 Binary files a/mobile/openapi/lib/api/asset_api.dart and b/mobile/openapi/lib/api/asset_api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index bba748549f..bf0e60bc7c 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ diff --git a/mobile/openapi/lib/model/memory_lane_response_dto.dart b/mobile/openapi/lib/model/memory_lane_response_dto.dart new file mode 100644 index 0000000000..22987b29b8 Binary files /dev/null and b/mobile/openapi/lib/model/memory_lane_response_dto.dart differ diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index 75d6e953b8..fdc38c14d8 100644 Binary files a/mobile/openapi/test/asset_api_test.dart and b/mobile/openapi/test/asset_api_test.dart differ diff --git a/mobile/openapi/test/memory_lane_response_dto_test.dart b/mobile/openapi/test/memory_lane_response_dto_test.dart new file mode 100644 index 0000000000..4e25825cdb Binary files /dev/null and b/mobile/openapi/test/memory_lane_response_dto_test.dart differ diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index f131f8678f..1f88295bf7 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -1534,6 +1534,50 @@ ] } }, + "/asset/memory-lane": { + "get": { + "operationId": "getMemoryLane", + "parameters": [ + { + "name": "timezone", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemoryLaneResponseDto" + } + } + } + } + } + }, + "tags": [ + "Asset" + ], + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ] + } + }, "/asset/search": { "post": { "operationId": "searchAsset", @@ -5709,6 +5753,24 @@ "lon" ] }, + "MemoryLaneResponseDto": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "assets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + } + } + }, + "required": [ + "title", + "assets" + ] + }, "OAuthCallbackDto": { "type": "object", "properties": { diff --git a/server/src/domain/asset/asset.repository.ts b/server/src/domain/asset/asset.repository.ts index 21f88a0c8e..16214931a6 100644 --- a/server/src/domain/asset/asset.repository.ts +++ b/server/src/domain/asset/asset.repository.ts @@ -42,6 +42,7 @@ export enum WithProperty { export const IAssetRepository = 'IAssetRepository'; export interface IAssetRepository { + getByDate(ownerId: string, date: Date): Promise; getByIds(ids: string[]): Promise; getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated; getWith(pagination: PaginationOptions, property: WithProperty): Paginated; diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 5ba365e784..3f86a7930e 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -2,7 +2,9 @@ import { Inject } from '@nestjs/common'; import { AuthUserDto } from '../auth'; import { IAssetRepository } from './asset.repository'; import { MapMarkerDto } from './dto/map-marker.dto'; -import { MapMarkerResponseDto } from './response-dto'; +import { MapMarkerResponseDto, mapAsset } from './response-dto'; +import { MemoryLaneResponseDto } from './response-dto/memory-lane-response.dto'; +import { DateTime } from 'luxon'; export class AssetService { constructor(@Inject(IAssetRepository) private assetRepository: IAssetRepository) {} @@ -10,4 +12,31 @@ export class AssetService { getMapMarkers(authUser: AuthUserDto, options: MapMarkerDto): Promise { return this.assetRepository.getMapMarkers(authUser.id, options); } + + async getMemoryLane(authUser: AuthUserDto, timezone: string): Promise { + const result: MemoryLaneResponseDto[] = []; + + const luxonDate = DateTime.fromISO(new Date().toISOString(), { zone: timezone }); + const today = new Date(luxonDate.year, luxonDate.month - 1, luxonDate.day); + + const years = Array.from({ length: 30 }, (_, i) => { + const year = today.getFullYear() - i - 1; + return new Date(year, today.getMonth(), today.getDate()); + }); + + for (const year of years) { + const assets = await this.assetRepository.getByDate(authUser.id, year); + + if (assets.length > 0) { + const yearGap = today.getFullYear() - year.getFullYear(); + const memory = new MemoryLaneResponseDto(); + memory.title = `${yearGap} year${yearGap > 1 && 's'} since...`; + memory.assets = assets.map((a) => mapAsset(a)); + + result.push(memory); + } + } + + return result; + } } diff --git a/server/src/domain/asset/response-dto/memory-lane-response.dto.ts b/server/src/domain/asset/response-dto/memory-lane-response.dto.ts new file mode 100644 index 0000000000..f855579435 --- /dev/null +++ b/server/src/domain/asset/response-dto/memory-lane-response.dto.ts @@ -0,0 +1,7 @@ +import { AssetResponseDto } from './asset-response.dto'; + +export class MemoryLaneResponseDto { + title!: string; + + assets!: AssetResponseDto[]; +} diff --git a/server/src/immich/controllers/asset.controller.ts b/server/src/immich/controllers/asset.controller.ts index ee71e814f7..c3fbdbbabf 100644 --- a/server/src/immich/controllers/asset.controller.ts +++ b/server/src/immich/controllers/asset.controller.ts @@ -5,6 +5,7 @@ import { ApiTags } from '@nestjs/swagger'; import { GetAuthUser } from '../decorators/auth-user.decorator'; import { Authenticated } from '../decorators/authenticated.decorator'; import { UseValidation } from '../decorators/use-validation.decorator'; +import { MemoryLaneResponseDto } from '@app/domain/asset/response-dto/memory-lane-response.dto'; @ApiTags('Asset') @Controller('asset') @@ -17,4 +18,12 @@ export class AssetController { getMapMarkers(@GetAuthUser() authUser: AuthUserDto, @Query() options: MapMarkerDto): Promise { return this.service.getMapMarkers(authUser, options); } + + @Get('memory-lane') + getMemoryLane( + @GetAuthUser() authUser: AuthUserDto, + @Query('timezone') timezone: string, + ): Promise { + return this.service.getMemoryLane(authUser, timezone); + } } diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index ec836623c4..042f3e4e33 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -20,6 +20,40 @@ import { paginate } from '../utils/pagination.util'; export class AssetRepository implements IAssetRepository { constructor(@InjectRepository(AssetEntity) private repository: Repository) {} + getByDate(ownerId: string, date: Date): Promise { + // For reference of a correct approach althought slower + + // let builder = this.repository + // .createQueryBuilder('asset') + // .leftJoin('asset.exifInfo', 'exifInfo') + // .where('asset.ownerId = :ownerId', { ownerId }) + // .andWhere( + // `coalesce(date_trunc('day', asset."fileCreatedAt", "exifInfo"."timeZone") at TIME ZONE "exifInfo"."timeZone", date_trunc('day', asset."fileCreatedAt")) IN (:date)`, + // { date }, + // ) + // .andWhere('asset.isVisible = true') + // .andWhere('asset.isArchived = false') + // .orderBy('asset.fileCreatedAt', 'DESC'); + + // return builder.getMany(); + const tomorrow = new Date(date.getTime() + 24 * 60 * 60 * 1000); + + return this.repository.find({ + where: { + ownerId, + isVisible: true, + isArchived: false, + fileCreatedAt: OptionalBetween(date, tomorrow), + }, + relations: { + exifInfo: true, + }, + order: { + fileCreatedAt: 'DESC', + }, + }); + } + getByIds(ids: string[]): Promise { return this.repository.find({ where: { id: In(ids) }, diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index d05a5c1728..5418176f38 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -2,6 +2,7 @@ import { IAssetRepository } from '@app/domain'; export const newAssetRepositoryMock = (): jest.Mocked => { return { + getByDate: jest.fn(), getByIds: jest.fn().mockResolvedValue([]), getWithout: jest.fn(), getWith: jest.fn(), diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index be1fe41254..b30ebfd237 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -1657,6 +1657,25 @@ export interface MapMarkerResponseDto { */ 'lon': number; } +/** + * + * @export + * @interface MemoryLaneResponseDto + */ +export interface MemoryLaneResponseDto { + /** + * + * @type {string} + * @memberof MemoryLaneResponseDto + */ + 'title': string; + /** + * + * @type {Array} + * @memberof MemoryLaneResponseDto + */ + 'assets': Array; +} /** * * @export @@ -5493,6 +5512,51 @@ 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, + }; + }, + /** + * + * @param {string} timezone + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getMemoryLane: async (timezone: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'timezone' is not null or undefined + assertParamExists('getMemoryLane', 'timezone', timezone) + const localVarPath = `/asset/memory-lane`; + // 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 api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + if (timezone !== undefined) { + localVarQueryParameter['timezone'] = timezone; + } + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -6091,6 +6155,16 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.getMapMarkers(isFavorite, fileCreatedAfter, fileCreatedBefore, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {string} timezone + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getMemoryLane(timezone: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getMemoryLane(timezone, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * Get all asset of a device that are in the database, ID only. * @param {string} deviceId @@ -6370,6 +6444,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath getMapMarkers(isFavorite?: boolean, fileCreatedAfter?: string, fileCreatedBefore?: string, options?: any): AxiosPromise> { return localVarFp.getMapMarkers(isFavorite, fileCreatedAfter, fileCreatedBefore, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {string} timezone + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getMemoryLane(timezone: string, options?: any): AxiosPromise> { + return localVarFp.getMemoryLane(timezone, options).then((request) => request(axios, basePath)); + }, /** * Get all asset of a device that are in the database, ID only. * @param {string} deviceId @@ -6767,6 +6850,20 @@ export interface AssetApiGetMapMarkersRequest { readonly fileCreatedBefore?: string } +/** + * Request parameters for getMemoryLane operation in AssetApi. + * @export + * @interface AssetApiGetMemoryLaneRequest + */ +export interface AssetApiGetMemoryLaneRequest { + /** + * + * @type {string} + * @memberof AssetApiGetMemoryLane + */ + readonly timezone: string +} + /** * Request parameters for getUserAssetsByDeviceId operation in AssetApi. * @export @@ -7199,6 +7296,17 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).getMapMarkers(requestParameters.isFavorite, requestParameters.fileCreatedAfter, requestParameters.fileCreatedBefore, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {AssetApiGetMemoryLaneRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public getMemoryLane(requestParameters: AssetApiGetMemoryLaneRequest, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).getMemoryLane(requestParameters.timezone, options).then((request) => request(this.axios, this.basePath)); + } + /** * Get all asset of a device that are in the database, ID only. * @param {AssetApiGetUserAssetsByDeviceIdRequest} requestParameters Request parameters. diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 4dccbf02ea..aa4091e03e 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -86,7 +86,7 @@ afterNavigate(({ from }) => { backUrl = from?.url.pathname ?? '/albums'; - if (from?.url.pathname === '/sharing') { + if (from?.url.pathname === '/sharing' && album.sharedUsers.length === 0) { isCreatingSharedAlbum = true; } }); diff --git a/web/src/lib/components/elements/buttons/circle-icon-button.svelte b/web/src/lib/components/elements/buttons/circle-icon-button.svelte index 5bccde13c2..63a5fd83f4 100644 --- a/web/src/lib/components/elements/buttons/circle-icon-button.svelte +++ b/web/src/lib/components/elements/buttons/circle-icon-button.svelte @@ -7,16 +7,17 @@ export let size = '24'; export let title = ''; export let isOpacity = false; + export let forceDark = false; + + {/if} + +
+
+ +
+ +
+ + +
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ + {#key currentMemory.assets[autoPlayIndex].id} + + {/key} + +
+

+ {DateTime.fromISO(currentMemory.assets[0].fileCreatedAt).toLocaleString( + DateTime.DATE_FULL + )} +

+

+ {currentMemory.assets[autoPlayIndex].exifInfo?.city || ''} + {currentMemory.assets[autoPlayIndex].exifInfo?.country || ''} +

+
+
+
+ + +
+ +
+
+
+ + + +
+
+ +
+ + (galleryInView = true)} + on:hidden={() => (galleryInView = false)} + bottom={-200} + > + + +
+ {/if} + + + diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index 1f549a6c7f..0a716a02bf 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -17,6 +17,7 @@ } from '../shared-components/scrollbar/scrollbar.svelte'; import AssetDateGroup from './asset-date-group.svelte'; import { BucketPosition } from '$lib/models/asset-grid-state'; + import MemoryLane from './memory-lane.svelte'; export let user: UserResponseDto | undefined = undefined; export let isAlbumSelectionMode = false; @@ -130,6 +131,7 @@ on:scroll={handleTimelineScroll} > {#if assetGridElement} +
{#each $assetGridState.buckets as bucket, bucketIndex (bucketIndex)} + import { onMount } from 'svelte'; + import { DateTime } from 'luxon'; + import { MemoryLaneResponseDto, api } from '@api'; + import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte'; + import ChevronRight from 'svelte-material-icons/ChevronRight.svelte'; + import { memoryStore } from '$lib/stores/memory.store'; + import { goto } from '$app/navigation'; + + let memoryLane: MemoryLaneResponseDto[] = []; + $: shouldRender = memoryLane.length > 0; + + onMount(async () => { + const timezone = DateTime.local().zoneName; + const { data } = await api.assetApi.getMemoryLane({ timezone }); + + memoryLane = data; + $memoryStore = data; + }); + + let memoryLaneElement: HTMLElement; + let offsetWidth = 0; + let innerWidth = 0; + $: isOverflow = offsetWidth < innerWidth; + + function scrollLeft() { + memoryLaneElement.scrollTo({ + left: memoryLaneElement.scrollLeft - 400, + behavior: 'smooth' + }); + } + + function scrollRight() { + memoryLaneElement.scrollTo({ + left: memoryLaneElement.scrollLeft + 400, + behavior: 'smooth' + }); + } + + +{#if shouldRender} +
+ {#if isOverflow} +
+
+ +
+ +
+ +
+
+ {/if} + +
+ {#each memoryLane as memory, i (memory.title)} + + {/each} +
+
+{/if} + + diff --git a/web/src/lib/components/shared-components/control-app-bar.svelte b/web/src/lib/components/shared-components/control-app-bar.svelte index c178abab59..ab105e30de 100644 --- a/web/src/lib/components/shared-components/control-app-bar.svelte +++ b/web/src/lib/components/shared-components/control-app-bar.svelte @@ -9,6 +9,7 @@ export let showBackButton = true; export let backIcon = Close; export let tailwindClasses = ''; + export let forceDark = false; let appBarBorder = 'bg-immich-bg border border-transparent'; @@ -17,6 +18,10 @@ const onScroll = () => { if (window.pageYOffset > 80) { appBarBorder = 'border border-gray-200 bg-gray-50 dark:border-gray-600'; + + if (forceDark) { + appBarBorder = 'border border-gray-600'; + } } else { appBarBorder = 'bg-immich-bg border border-transparent'; } @@ -38,9 +43,11 @@
-
+
{#if showBackButton} dispatch('close-button-click')} @@ -48,14 +55,17 @@ backgroundColor={'transparent'} hoverColor={'#e2e7e9'} size={'24'} + forceDark /> {/if}
- +
+ +
-
+
diff --git a/web/src/lib/stores/memory.store.ts b/web/src/lib/stores/memory.store.ts new file mode 100644 index 0000000000..cf31c54503 --- /dev/null +++ b/web/src/lib/stores/memory.store.ts @@ -0,0 +1,4 @@ +import { writable } from 'svelte/store'; +import type { MemoryLaneResponseDto } from '../../api/open-api'; + +export const memoryStore = writable(); diff --git a/web/src/routes/(user)/memory/+page.server.ts b/web/src/routes/(user)/memory/+page.server.ts new file mode 100644 index 0000000000..88bba26af2 --- /dev/null +++ b/web/src/routes/(user)/memory/+page.server.ts @@ -0,0 +1,16 @@ +import type { PageServerLoad } from './$types'; +import { redirect } from '@sveltejs/kit'; +import { AppRoute } from '$lib/constants'; + +export const load = (async ({ locals: { user } }) => { + if (!user) { + throw redirect(302, AppRoute.AUTH_LOGIN); + } + + return { + user, + meta: { + title: 'Memory' + } + }; +}) satisfies PageServerLoad; diff --git a/web/src/routes/(user)/memory/+page.svelte b/web/src/routes/(user)/memory/+page.svelte new file mode 100644 index 0000000000..28a4f538c7 --- /dev/null +++ b/web/src/routes/(user)/memory/+page.svelte @@ -0,0 +1,5 @@ + + +