From 43ec0b77a0ebfee9547b2333406ab03fbaafd60f Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 14 Jun 2023 20:47:18 -0500 Subject: [PATCH] feat(web): Memory (#2759) * Add on this day * add query for x year * dev: add query * dev: front end * dev: styling * styling * more styling * add new page * navigating * navigate back and forth * styling * show gallery * fix test * fix test * show previous and next title * fix test * show up down scrolling button * more styling * styling * fix app bar * fix height of next/previous * autoplay * auto play * refactor * refactor * refactor * show date * Navigate * finish * pr feedback --- mobile/openapi/.openapi-generator/FILES | 3 + mobile/openapi/README.md | Bin 17700 -> 17855 bytes mobile/openapi/doc/AssetApi.md | Bin 58186 -> 60221 bytes mobile/openapi/doc/MemoryLaneResponseDto.md | Bin 0 -> 506 bytes mobile/openapi/lib/api.dart | Bin 5573 -> 5617 bytes mobile/openapi/lib/api/asset_api.dart | Bin 52471 -> 54187 bytes mobile/openapi/lib/api_client.dart | Bin 17746 -> 17840 bytes .../lib/model/memory_lane_response_dto.dart | Bin 0 -> 3627 bytes mobile/openapi/test/asset_api_test.dart | Bin 5602 -> 5746 bytes .../test/memory_lane_response_dto_test.dart | Bin 0 -> 720 bytes server/immich-openapi-specs.json | 62 +++ server/src/domain/asset/asset.repository.ts | 1 + server/src/domain/asset/asset.service.ts | 31 +- .../response-dto/memory-lane-response.dto.ts | 7 + .../immich/controllers/asset.controller.ts | 9 + .../infra/repositories/asset.repository.ts | 34 ++ .../repositories/asset.repository.mock.ts | 1 + web/src/api/open-api/api.ts | 108 ++++++ .../components/album-page/album-viewer.svelte | 2 +- .../buttons/circle-icon-button.svelte | 9 +- .../memory-page/memory-viewer.svelte | 354 ++++++++++++++++++ .../components/photos-page/asset-grid.svelte | 2 + .../components/photos-page/memory-lane.svelte | 94 +++++ .../shared-components/control-app-bar.svelte | 18 +- web/src/lib/stores/memory.store.ts | 4 + web/src/routes/(user)/memory/+page.server.ts | 16 + web/src/routes/(user)/memory/+page.svelte | 5 + 27 files changed, 750 insertions(+), 10 deletions(-) create mode 100644 mobile/openapi/doc/MemoryLaneResponseDto.md create mode 100644 mobile/openapi/lib/model/memory_lane_response_dto.dart create mode 100644 mobile/openapi/test/memory_lane_response_dto_test.dart create mode 100644 server/src/domain/asset/response-dto/memory-lane-response.dto.ts create mode 100644 web/src/lib/components/memory-page/memory-viewer.svelte create mode 100644 web/src/lib/components/photos-page/memory-lane.svelte create mode 100644 web/src/lib/stores/memory.store.ts create mode 100644 web/src/routes/(user)/memory/+page.server.ts create mode 100644 web/src/routes/(user)/memory/+page.svelte 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 7da1630df36616d5f7bdc4b104fdf103c81c0748..4da86619030ac8d36a9a5982f165cb6e24717139 100644 GIT binary patch delta 107 zcmZ3|#kjwlaYLs#mv3rreo>`QVqWUxLLpJ!^wg4Eut*L_L{Lna8^YBE^FQ#5Y;F^; iQ&vOh3Q8?5$j>WIbt%b@)kw)t*4IbKZT?_o!UX_o>?jrh delta 19 bcmdnr&A6nCaYLv0=2zm?%A4m~D{uh-Rp|%R diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index 4b6171d61ab4751fd9a986a49ffbc7703d3140be..a27740f95c41e97829d935124d35578b44fd1001 100644 GIT binary patch delta 276 zcmX?gjCt=h<_*`_)qPWQ^NT8d67y2Ev|=?Ji;GiB91Ak_a#NJkQ%iEek~u)h$qU(p zxglI#5P$O-cB!R~$eOwA6nrv^OKcEgL8-+B`FX{uE+zSP3P95kG8!eBxv5q8Kw}bZ zlv#vfJOx_?7;|#s9q~!0)WxuCn_O5N%n3Jc@`6)s9B_>bgG44vZ5E!au$ogD?l)wo Ufcy#93v@y39LCMh)})vL09Ai$3jhEB delta 19 bcmdmcjrr6u<_*`_H>+|8EZux_ONto)U)TvI diff --git a/mobile/openapi/doc/MemoryLaneResponseDto.md b/mobile/openapi/doc/MemoryLaneResponseDto.md new file mode 100644 index 0000000000000000000000000000000000000000..9aafda1424456b40f3eb754c8a4a2e6b0e993d6f GIT binary patch literal 506 zcma)2!D_=W488j+1Ua-dB)xBA(2^boZ7JPuAy5;mompZTTRjx^@sqQ{x?VOTFz@N< z3B3X+pf|x)16k~=4Gt79YvXZ`DUeTCGbvRRuqI=H-w{S31k-((6RN}EU`=G@hCq>F za^)Ycw)1SA1(U}t9i4VjnvoqQwKW66C%j!k{%TBJbb%dtL0e)`ls8Yv8&sBmVWl-Y z?on$|YW>R5`O^plOVgx;oJOM2dhHTFEi!d;`m@&U^q#B+I}-<~#^TYOdj=-xBrS%f zZIx=8rNooTO->bhv2NRGRn7OSMZH;xL1C#o59rzCbIT*eatt=j;AeWVd->h>w0NJMt^8f$< delta 12 TcmeyUeN=nH6yD7TcnvuKCr||! diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index 8b0eaa2be87577e1f4f4f7b61bbeaf50d549e308..9646640c6b4db6091a37bc2976e035896986613f 100644 GIT binary patch delta 287 zcmex9lX>-W<_+nSCtvWF<<3pb%`d9d%}LBlog9#@HCaGUouec(H?=AsC}0??%;^hO z;FFj)xj|19A+<2pj|WMw`ecKOLz6GNtBb-ktLrGhn3|fCA1+p%ENg#p@_$X~$qo)2 z2y+CdOPj;3NDWFYF38U-PIW2Cx3jlXNKY+6$Y}(Z6lLb6!%fwh{Ln&G2}MbGW=V!e xNlAfcURu5e%=XO%viBJ$7fh<=g!v?Oa)Q4a7sBMp0V`Z5&z0NMYmrvvwy1F&b4ldVGtlUbcXlNJ8Yn1v1S{Sm6gpj z|5gc&>XNPSvt$~dmTQB?wP{z+l{A(~n~6C(6br$%Q#Y;73L&}43MKDQ%r2SCe)~1c zmrR>Z2i;jv3s5DOtP~0UpL9BT!Hj_`zEXN~$t1twrbL(g%qqC=0Q9j6H{4cQ8eky# z8YZp6QxGoDoF}N&DiV=BfKUanOJNc#Y{6r<;odPCZVja15{Jo@_8xi|DZpo|g}ei& z;ubE2vF}ewqY?HHybUMuL4D_1W9?9Vrc^=MYi5;(G>yiyFFePZ*d^CJNQdBSoUEy;3I|o&5&UFT$n;yxZ@U<%q;&O|5*?_%0@ zrmtA}-dl=czCpx`dwctyZ zKm-!p*1|ZVG@4p!zChMG-0LbwjGBzln<4+L$TJ)jOEsq^`-OIr{q6#BRj#Y<0}Lg{ zm?9UR12ZebI>25toJf*0;`6hqpXc|^5*Z0?xMAxU$h z4L%JHjIu;iLgLsdI5QX^?j6)&5IVGtws@q_LcE_STnu5v@?{iZ@ViQpSglIfO+vn? z;sj&p-qfQWP!Zm=ljsolrf(?Dl`w|#A`HRB=kayY#Uhg%|JQIfAtg9S9hP3_2uA{q zaKd1X0QuAzjoRS0CXCe;4~q{`Z`b~MzcssPYsIm4K*0E$m@;16Zc1LGY8X)gV+h>t z9r&YwBJk85!0Ds;o)kFs&#_VtSQvA`H@G zv?^@_eMF0>wy)L%ZBh-6sd9eP_c(1I6A3}>0`($Hq0#c)c&H)Pv`gfmgc3HN`ytZM z^p&QuN+Imkz1Fgda*IOI1`xhED{dTz$<{bK+ABwJ+G3#Dnh>wA0ZpUE$o6^yf9UYu8X*)tk`Lgx&-%s& z4jPU6j}~LeCSK7^n$*vV!2i(M@Z9S&s&46px#KGh99JskuUczim$*d4qN`|YKaRXC zs@7*K-WpJcF6Lws;nji?Le?`R8`*PsfM z+wXa6WA+Dc16=Wz$vaNRTg0^t1oYPcN*`|V;t((q&R;q?=>~!KidBVhnQ_1yl0mn` m59Uv<9T@!2g`oV2bhpGG-n*LA_eXz1gcHO4yg2FL^!XP@7MI2V literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index 75d6e953b8ffd76c35d678ea56ae2a8e2f857cea..fdc38c14d84a164290a1f4e48f07f344e7334032 100644 GIT binary patch delta 92 zcmaE){YhuTI)O>-?33*|SxtRYbMuQTeG>ChgHnqN^7D#QT}txp>=e>dOAt~T!6ikR gdFcuznYpP|`FW|63;CqD5Ym(TxePaN5zu1=07ru%^8f$< delta 12 TcmeyQ^GJKcI)Tl0f+nm0Cs+j4 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 0000000000000000000000000000000000000000..4e25825cdbc6a431623c238599985f93a0d02c7c GIT binary patch literal 720 zcma))L2KMF5QXpl71Pu1!ZuEGDoIna;7tf@Vo0`!5{fd|W3Q+zsnR%QDf#aiX&WfS zkRB|1#QWa7u~e2tS;Feuw*GjvzFps~w(ANmuQ%%@R5jezTezvK>&x#SM3$5nEe1Y6 zIr?-`q*C|R1yY?0s?&x}VYFkUR*|8BEH7VcJ$M&*l0fqt_fh$gRiIy^4uV@8Wc3*2 z%@m@Thd7Nl&PGPdO1IKbcbY=R<76|VEk$LV?M22rwCeM*YIYc6Q5-VD%wzKtdpdQV zEP7qabCjl@oxPDTMK06wDo9OJ9YKBx+|b~{euI|oD0&G>6}#fe=@tgmcD{f=1%M!( zwrD`-Ng9IlX>z_eD@5N1H~RT3wVBq$b4 zk6HD2aKN~?O^%o-iMg$-Iy*cclH|>ej{Id6Bk?~&eg#ZB5#}v*I2wj0G^5;)d|dGG SWASd1gTenr5wjfWxp)C@+v@KC literal 0 HcmV?d00001 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 @@ + + +