diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 329dee833e75e..0d0d732762a30 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 7da1630df3661..4da86619030ac 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -109,6 +109,7 @@ Class | Method | HTTP request | Description *AssetApi* | [**getCuratedLocations**](doc//AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations | *AssetApi* | [**getCuratedObjects**](doc//AssetApi.md#getcuratedobjects) | **GET** /asset/curated-objects | *AssetApi* | [**getMapMarkers**](doc//AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | +*AssetApi* | [**getMemoryLane**](doc//AssetApi.md#getmemorylane) | **GET** /asset/memory-lane | *AssetApi* | [**getUserAssetsByDeviceId**](doc//AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | *AssetApi* | [**removeAssetsFromSharedLink**](doc//AssetApi.md#removeassetsfromsharedlink) | **PATCH** /asset/shared-link/remove | *AssetApi* | [**searchAsset**](doc//AssetApi.md#searchasset) | **POST** /asset/search | @@ -231,6 +232,7 @@ Class | Method | HTTP request | Description - [LoginResponseDto](doc//LoginResponseDto.md) - [LogoutResponseDto](doc//LogoutResponseDto.md) - [MapMarkerResponseDto](doc//MapMarkerResponseDto.md) + - [MemoryLaneResponseDto](doc//MemoryLaneResponseDto.md) - [OAuthCallbackDto](doc//OAuthCallbackDto.md) - [OAuthConfigDto](doc//OAuthConfigDto.md) - [OAuthConfigResponseDto](doc//OAuthConfigResponseDto.md) diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index 4b6171d61ab47..a27740f95c41e 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/mobile/openapi/doc/AssetApi.md @@ -29,6 +29,7 @@ Method | HTTP request | Description [**getCuratedLocations**](AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations | [**getCuratedObjects**](AssetApi.md#getcuratedobjects) | **GET** /asset/curated-objects | [**getMapMarkers**](AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | +[**getMemoryLane**](AssetApi.md#getmemorylane) | **GET** /asset/memory-lane | [**getUserAssetsByDeviceId**](AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | [**removeAssetsFromSharedLink**](AssetApi.md#removeassetsfromsharedlink) | **PATCH** /asset/shared-link/remove | [**searchAsset**](AssetApi.md#searchasset) | **POST** /asset/search | @@ -1161,6 +1162,61 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) +# **getMemoryLane** +> List getMemoryLane(timezone) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = AssetApi(); +final timezone = timezone_example; // String | + +try { + final result = api_instance.getMemoryLane(timezone); + print(result); +} catch (e) { + print('Exception when calling AssetApi->getMemoryLane: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **timezone** | **String**| | + +### Return type + +[**List**](MemoryLaneResponseDto.md) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + # **getUserAssetsByDeviceId** > List getUserAssetsByDeviceId(deviceId) diff --git a/mobile/openapi/doc/MemoryLaneResponseDto.md b/mobile/openapi/doc/MemoryLaneResponseDto.md new file mode 100644 index 0000000000000..9aafda1424456 --- /dev/null +++ b/mobile/openapi/doc/MemoryLaneResponseDto.md @@ -0,0 +1,16 @@ +# openapi.model.MemoryLaneResponseDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**title** | **String** | | +**assets** | [**List**](AssetResponseDto.md) | | [default to const []] + +[[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/api.dart b/mobile/openapi/lib/api.dart index c71747965d46e..393854e745099 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -98,6 +98,7 @@ part 'model/login_credential_dto.dart'; part 'model/login_response_dto.dart'; part 'model/logout_response_dto.dart'; part 'model/map_marker_response_dto.dart'; +part 'model/memory_lane_response_dto.dart'; part 'model/o_auth_callback_dto.dart'; part 'model/o_auth_config_dto.dart'; part 'model/o_auth_config_response_dto.dart'; diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index 8b0eaa2be8757..9646640c6b4db 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -1115,6 +1115,58 @@ class AssetApi { return null; } + /// Performs an HTTP 'GET /asset/memory-lane' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] timezone (required): + Future getMemoryLaneWithHttpInfo(String timezone,) async { + // ignore: prefer_const_declarations + final path = r'/asset/memory-lane'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + queryParams.addAll(_queryParams('', 'timezone', timezone)); + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] timezone (required): + Future?> getMemoryLane(String timezone,) async { + final response = await getMemoryLaneWithHttpInfo(timezone,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(); + + } + return null; + } + /// Get all asset of a device that are in the database, ID only. /// /// Note: This method returns the HTTP [Response]. diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index bba748549f2a0..bf0e60bc7c2f5 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -291,6 +291,8 @@ class ApiClient { return LogoutResponseDto.fromJson(value); case 'MapMarkerResponseDto': return MapMarkerResponseDto.fromJson(value); + case 'MemoryLaneResponseDto': + return MemoryLaneResponseDto.fromJson(value); case 'OAuthCallbackDto': return OAuthCallbackDto.fromJson(value); case 'OAuthConfigDto': 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 0000000000000..22987b29b807b --- /dev/null +++ b/mobile/openapi/lib/model/memory_lane_response_dto.dart @@ -0,0 +1,117 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class MemoryLaneResponseDto { + /// Returns a new [MemoryLaneResponseDto] instance. + MemoryLaneResponseDto({ + required this.title, + this.assets = const [], + }); + + String title; + + List assets; + + @override + bool operator ==(Object other) => identical(this, other) || other is MemoryLaneResponseDto && + other.title == title && + other.assets == assets; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (title.hashCode) + + (assets.hashCode); + + @override + String toString() => 'MemoryLaneResponseDto[title=$title, assets=$assets]'; + + Map toJson() { + final json = {}; + json[r'title'] = this.title; + json[r'assets'] = this.assets; + return json; + } + + /// Returns a new [MemoryLaneResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static MemoryLaneResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + // Ensure that the map contains the required keys. + // Note 1: the values aren't checked for validity beyond being non-null. + // Note 2: this code is stripped in release mode! + assert(() { + requiredKeys.forEach((key) { + assert(json.containsKey(key), 'Required key "MemoryLaneResponseDto[$key]" is missing from JSON.'); + assert(json[key] != null, 'Required key "MemoryLaneResponseDto[$key]" has a null value in JSON.'); + }); + return true; + }()); + + return MemoryLaneResponseDto( + title: mapValueOfType(json, r'title')!, + assets: AssetResponseDto.listFromJson(json[r'assets']), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = MemoryLaneResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = MemoryLaneResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of MemoryLaneResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = MemoryLaneResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'title', + 'assets', + }; +} + diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index 75d6e953b8ffd..fdc38c14d84a1 100644 --- a/mobile/openapi/test/asset_api_test.dart +++ b/mobile/openapi/test/asset_api_test.dart @@ -129,6 +129,11 @@ void main() { // TODO }); + //Future> getMemoryLane(String timezone) async + test('test getMemoryLane', () async { + // TODO + }); + // Get all asset of a device that are in the database, ID only. // //Future> getUserAssetsByDeviceId(String deviceId) async 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 0000000000000..4e25825cdbc6a --- /dev/null +++ b/mobile/openapi/test/memory_lane_response_dto_test.dart @@ -0,0 +1,32 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for MemoryLaneResponseDto +void main() { + // final instance = MemoryLaneResponseDto(); + + group('test MemoryLaneResponseDto', () { + // String title + test('to test the property `title`', () async { + // TODO + }); + + // List assets (default value: const []) + test('to test the property `assets`', () async { + // TODO + }); + + + }); + +} diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index f131f8678f5be..1f88295bf7b90 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 21f88a0c8e74a..16214931a6877 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 5ba365e784190..3f86a7930ec30 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 0000000000000..f85557943592b --- /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 ee71e814f78b1..c3fbdbbabfa9f 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 ec836623c4215..042f3e4e33de6 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 d05a5c1728d96..5418176f38ed5 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 be1fe41254034..b30ebfd23735e 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 4dccbf02ea9e0..aa4091e03e925 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 5bccde13c2857..63a5fd83f4be1 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 1f549a6c7f055..0a716a02bf5f4 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 c178abab5956b..ab105e30deb18 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 0000000000000..cf31c545038bf --- /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 0000000000000..88bba26af2174 --- /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 0000000000000..28a4f538c73bd --- /dev/null +++ b/web/src/routes/(user)/memory/+page.svelte @@ -0,0 +1,5 @@ + + +