Bladeren bron

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
Alex 2 jaren geleden
bovenliggende
commit
43ec0b77a0
27 gewijzigde bestanden met toevoegingen van 1033 en 10 verwijderingen
  1. 3 0
      mobile/openapi/.openapi-generator/FILES
  2. 2 0
      mobile/openapi/README.md
  3. 56 0
      mobile/openapi/doc/AssetApi.md
  4. 16 0
      mobile/openapi/doc/MemoryLaneResponseDto.md
  5. 1 0
      mobile/openapi/lib/api.dart
  6. 52 0
      mobile/openapi/lib/api/asset_api.dart
  7. 2 0
      mobile/openapi/lib/api_client.dart
  8. 117 0
      mobile/openapi/lib/model/memory_lane_response_dto.dart
  9. 5 0
      mobile/openapi/test/asset_api_test.dart
  10. 32 0
      mobile/openapi/test/memory_lane_response_dto_test.dart
  11. 62 0
      server/immich-openapi-specs.json
  12. 1 0
      server/src/domain/asset/asset.repository.ts
  13. 30 1
      server/src/domain/asset/asset.service.ts
  14. 7 0
      server/src/domain/asset/response-dto/memory-lane-response.dto.ts
  15. 9 0
      server/src/immich/controllers/asset.controller.ts
  16. 34 0
      server/src/infra/repositories/asset.repository.ts
  17. 1 0
      server/test/repositories/asset.repository.mock.ts
  18. 108 0
      web/src/api/open-api/api.ts
  19. 1 1
      web/src/lib/components/album-page/album-viewer.svelte
  20. 5 4
      web/src/lib/components/elements/buttons/circle-icon-button.svelte
  21. 354 0
      web/src/lib/components/memory-page/memory-viewer.svelte
  22. 2 0
      web/src/lib/components/photos-page/asset-grid.svelte
  23. 94 0
      web/src/lib/components/photos-page/memory-lane.svelte
  24. 14 4
      web/src/lib/components/shared-components/control-app-bar.svelte
  25. 4 0
      web/src/lib/stores/memory.store.ts
  26. 16 0
      web/src/routes/(user)/memory/+page.server.ts
  27. 5 0
      web/src/routes/(user)/memory/+page.svelte

+ 3 - 0
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

+ 2 - 0
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)

+ 56 - 0
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<MemoryLaneResponseDto> getMemoryLane(timezone)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure API key authorization: cookie
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
+// TODO Configure API key authorization: api_key
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('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>**](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<String> getUserAssetsByDeviceId(deviceId)
 

+ 16 - 0
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>**](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)
+
+

+ 1 - 0
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';

+ 52 - 0
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<Response> getMemoryLaneWithHttpInfo(String timezone,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/asset/memory-lane';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+      queryParams.addAll(_queryParams('', 'timezone', timezone));
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [String] timezone (required):
+  Future<List<MemoryLaneResponseDto>?> 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<MemoryLaneResponseDto>') as List)
+        .cast<MemoryLaneResponseDto>()
+        .toList();
+
+    }
+    return null;
+  }
+
   /// Get all asset of a device that are in the database, ID only.
   ///
   /// Note: This method returns the HTTP [Response].

+ 2 - 0
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':

+ 117 - 0
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<AssetResponseDto> 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<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      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<String, dynamic>();
+
+      // 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<String>(json, r'title')!,
+        assets: AssetResponseDto.listFromJson(json[r'assets']),
+      );
+    }
+    return null;
+  }
+
+  static List<MemoryLaneResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <MemoryLaneResponseDto>[];
+    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<String, MemoryLaneResponseDto> mapFromJson(dynamic json) {
+    final map = <String, MemoryLaneResponseDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // 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<String, List<MemoryLaneResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<MemoryLaneResponseDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      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 = <String>{
+    'title',
+    'assets',
+  };
+}
+

+ 5 - 0
mobile/openapi/test/asset_api_test.dart

@@ -129,6 +129,11 @@ void main() {
       // TODO
     });
 
+    //Future<List<MemoryLaneResponseDto>> getMemoryLane(String timezone) async
+    test('test getMemoryLane', () async {
+      // TODO
+    });
+
     // Get all asset of a device that are in the database, ID only.
     //
     //Future<List<String>> getUserAssetsByDeviceId(String deviceId) async

+ 32 - 0
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<AssetResponseDto> assets (default value: const [])
+    test('to test the property `assets`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 62 - 0
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": {

+ 1 - 0
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<AssetEntity[]>;
   getByIds(ids: string[]): Promise<AssetEntity[]>;
   getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity>;
   getWith(pagination: PaginationOptions, property: WithProperty): Paginated<AssetEntity>;

+ 30 - 1
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<MapMarkerResponseDto[]> {
     return this.assetRepository.getMapMarkers(authUser.id, options);
   }
+
+  async getMemoryLane(authUser: AuthUserDto, timezone: string): Promise<MemoryLaneResponseDto[]> {
+    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;
+  }
 }

+ 7 - 0
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[];
+}

+ 9 - 0
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<MapMarkerResponseDto[]> {
     return this.service.getMapMarkers(authUser, options);
   }
+
+  @Get('memory-lane')
+  getMemoryLane(
+    @GetAuthUser() authUser: AuthUserDto,
+    @Query('timezone') timezone: string,
+  ): Promise<MemoryLaneResponseDto[]> {
+    return this.service.getMemoryLane(authUser, timezone);
+  }
 }

+ 34 - 0
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<AssetEntity>) {}
 
+  getByDate(ownerId: string, date: Date): Promise<AssetEntity[]> {
+    // 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<AssetEntity[]> {
     return this.repository.find({
       where: { id: In(ids) },

+ 1 - 0
server/test/repositories/asset.repository.mock.ts

@@ -2,6 +2,7 @@ import { IAssetRepository } from '@app/domain';
 
 export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
   return {
+    getByDate: jest.fn(),
     getByIds: jest.fn().mockResolvedValue([]),
     getWithout: jest.fn(),
     getWith: jest.fn(),

+ 108 - 0
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<AssetResponseDto>}
+     * @memberof MemoryLaneResponseDto
+     */
+    'assets': Array<AssetResponseDto>;
+}
 /**
  * 
  * @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<RequestArgs> => {
+            // 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<Array<MemoryLaneResponseDto>>> {
+            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<Array<MapMarkerResponseDto>> {
             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<Array<MemoryLaneResponseDto>> {
+            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.

+ 1 - 1
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;
 		}
 	});

+ 5 - 4
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;
 </script>
 
 <button
 	{title}
 	style:background-color={backgroundColor}
 	style:--immich-icon-button-hover-color={hoverColor}
-	class="dark:text-immich-dark-fg rounded-full p-3 flex place-items-center place-content-center transition-all
-	{isOpacity
-		? 'hover:bg-immich-bg/30'
-		: 'immich-circle-icon-button hover:dark:text-immich-dark-gray'}"
+	class:dark:text-immich-dark-fg={!forceDark}
+	class="rounded-full p-3 flex place-items-center place-content-center transition-all
+	{isOpacity ? 'hover:bg-immich-bg/30' : 'immich-circle-icon-button hover:dark:text-immich-dark-gray'}
+  {forceDark && 'hover:text-black'}"
 	on:click
 >
 	<svelte:component this={logo} {size} />

+ 354 - 0
web/src/lib/components/memory-page/memory-viewer.svelte

@@ -0,0 +1,354 @@
+<script lang="ts">
+	import { memoryStore } from '$lib/stores/memory.store';
+	import { DateTime } from 'luxon';
+	import { onMount } from 'svelte';
+	import { MemoryLaneResponseDto, api } from '@api';
+	import { goto } from '$app/navigation';
+	import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
+	import Play from 'svelte-material-icons/Play.svelte';
+	import Pause from 'svelte-material-icons/Pause.svelte';
+	import ChevronDown from 'svelte-material-icons/ChevronDown.svelte';
+	import ChevronUp from 'svelte-material-icons/ChevronUp.svelte';
+	import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
+	import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
+	import { AppRoute } from '$lib/constants';
+	import { page } from '$app/stores';
+	import noThumbnailUrl from '$lib/assets/no-thumbnail.png';
+	import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
+	import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
+	import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
+	import { fade } from 'svelte/transition';
+
+	let currentIndex = 0;
+	let currentMemory: MemoryLaneResponseDto;
+	let nextMemory: MemoryLaneResponseDto;
+	let lastMemory: MemoryLaneResponseDto;
+
+	let lastIndex = 0;
+	let nextIndex = 0;
+	$: showNextMemory = nextIndex <= $memoryStore?.length - 1;
+	$: showPreviousMemory = currentIndex != 0;
+
+	let memoryGallery: HTMLElement;
+	let memoryWrapper: HTMLElement;
+	let galleryInView = false;
+
+	onMount(async () => {
+		if (!$memoryStore) {
+			const timezone = DateTime.local().zoneName;
+			const { data } = await api.assetApi.getMemoryLane({ timezone });
+			$memoryStore = data;
+		}
+
+		const queryIndex = $page.url.searchParams.get('index');
+		if (queryIndex != null) {
+			currentIndex = parseInt(queryIndex);
+			if (isNaN(currentIndex) || currentIndex > $memoryStore.length - 1) {
+				currentIndex = 0;
+			}
+		}
+
+		currentMemory = $memoryStore[currentIndex];
+
+		nextIndex = currentIndex + 1;
+		nextMemory = $memoryStore[nextIndex];
+
+		if (currentIndex > 0) {
+			lastMemory = $memoryStore[lastIndex];
+		}
+	});
+
+	const toNextMemory = (): boolean => {
+		if (showNextMemory) {
+			resetAutoPlay();
+
+			currentIndex++;
+			nextIndex = currentIndex + 1;
+			lastIndex = currentIndex - 1;
+
+			currentMemory = $memoryStore[currentIndex];
+			nextMemory = $memoryStore[nextIndex];
+			lastMemory = $memoryStore[lastIndex];
+
+			return true;
+		}
+
+		return false;
+	};
+
+	const toPreviousMemory = () => {
+		if (showPreviousMemory) {
+			resetAutoPlay();
+
+			currentIndex--;
+			nextIndex = currentIndex + 1;
+			lastIndex = currentIndex - 1;
+
+			currentMemory = $memoryStore[currentIndex];
+			nextMemory = $memoryStore[nextIndex];
+			lastMemory = $memoryStore[lastIndex];
+		}
+	};
+
+	let autoPlayInterval: NodeJS.Timeout;
+	let autoPlay = false;
+	let autoPlaySpeed = 5000;
+	let autoPlayProgress = 0;
+	let autoPlayIndex = 0;
+	let canPlayNext = true;
+
+	const toggleAutoPlay = () => {
+		autoPlay = !autoPlay;
+		if (autoPlay) {
+			autoPlayInterval = setInterval(() => {
+				if (!canPlayNext) return;
+
+				window.requestAnimationFrame(() => {
+					autoPlayProgress++;
+				});
+
+				if (autoPlayProgress > 100) {
+					autoPlayProgress = 0;
+					canPlayNext = false;
+					autoPlayTransition();
+				}
+			}, autoPlaySpeed / 100);
+		} else {
+			clearInterval(autoPlayInterval);
+		}
+	};
+
+	const autoPlayTransition = () => {
+		if (autoPlayIndex < currentMemory.assets.length - 1) {
+			autoPlayIndex++;
+		} else {
+			const canAdvance = toNextMemory();
+			if (!canAdvance) {
+				autoPlay = false;
+				clearInterval(autoPlayInterval);
+				return;
+			}
+		}
+
+		// Delay for nicer animation of the progress bar
+		setTimeout(() => {
+			canPlayNext = true;
+		}, 250);
+	};
+
+	const resetAutoPlay = () => {
+		autoPlayIndex = 0;
+		autoPlayProgress = 0;
+	};
+
+	const toNextCurrentAsset = () => {
+		autoPlayIndex++;
+
+		if (autoPlayIndex > currentMemory.assets.length - 1) {
+			toNextMemory();
+		}
+	};
+
+	const toPreviousCurrentAsset = () => {
+		autoPlayIndex--;
+
+		if (autoPlayIndex < 0) {
+			toPreviousMemory();
+		}
+	};
+</script>
+
+<section id="memory-viewer" class="w-full bg-immich-dark-gray" bind:this={memoryWrapper}>
+	{#if currentMemory}
+		<ControlAppBar on:close-button-click={() => goto(AppRoute.PHOTOS)} forceDark>
+			<svelte:fragment slot="leading">
+				<p class="text-lg">
+					{currentMemory.title}
+				</p>
+			</svelte:fragment>
+
+			{#if !galleryInView}
+				<div class="flex place-items-center place-content-center overflow-hidden gap-2">
+					<CircleIconButton logo={autoPlay ? Pause : Play} forceDark on:click={toggleAutoPlay} />
+
+					<div class="relative w-full">
+						<span class="absolute left-0 w-full h-[2px] bg-gray-500" />
+						<span
+							class="absolute left-0 h-[2px] bg-white transition-all"
+							style:width={`${autoPlayProgress}%`}
+						/>
+					</div>
+
+					<div>
+						<p class="text-small">
+							{autoPlayIndex + 1}/{currentMemory.assets.length}
+						</p>
+					</div>
+				</div>
+			{/if}
+		</ControlAppBar>
+
+		{#if galleryInView}
+			<div
+				class="sticky top-20 flex place-content-center place-items-center z-30 transition-opacity"
+				class:opacity-0={!galleryInView}
+				class:opacity-100={galleryInView}
+			>
+				<button
+					on:click={() => memoryWrapper.scrollIntoView({ behavior: 'smooth' })}
+					disabled={!galleryInView}
+				>
+					<CircleIconButton logo={ChevronUp} backgroundColor="white" forceDark />
+				</button>
+			</div>
+		{/if}
+		<!-- Viewer -->
+		<section class="pt-20 overflow-hidden">
+			<div
+				class="flex w-[300%] h-[calc(100vh_-_180px)] items-center justify-center box-border ml-[-100%] gap-10 overflow-hidden"
+			>
+				<!-- PREVIOUS MEMORY -->
+				<div
+					class="rounded-2xl w-[20vw] h-1/2"
+					class:opacity-25={showPreviousMemory}
+					class:opacity-0={!showPreviousMemory}
+					class:hover:opacity-70={showPreviousMemory}
+				>
+					<button
+						class="rounded-2xl h-full w-full relative"
+						disabled={!showPreviousMemory}
+						on:click={toPreviousMemory}
+					>
+						<img
+							class="rounded-2xl h-full w-full object-cover"
+							src={showPreviousMemory && lastMemory
+								? api.getAssetThumbnailUrl(lastMemory.assets[0].id, 'JPEG')
+								: noThumbnailUrl}
+							alt=""
+							draggable="false"
+						/>
+
+						{#if showPreviousMemory}
+							<div class="absolute right-4 bottom-4 text-white text-left">
+								<p class="font-semibold text-xs text-gray-200">PREVIOUS</p>
+								<p class="text-xl">{lastMemory.title}</p>
+							</div>
+						{/if}
+					</button>
+				</div>
+
+				<!-- CURRENT MEMORY -->
+				<div
+					class="main-view rounded-2xl h-full relative w-[70vw] bg-black flex place-items-center place-content-center"
+				>
+					<div class="bg-black w-full h-full rounded-2xl">
+						<!-- CONTROL BUTTONS -->
+						<div class="absolute h-full flex justify-between w-full">
+							<div class="flex h-full flex-col place-content-center place-items-center ml-4">
+								<div class="inline-block">
+									<CircleIconButton
+										logo={ChevronLeft}
+										backgroundColor="#202123"
+										on:click={toPreviousCurrentAsset}
+									/>
+								</div>
+							</div>
+							<div class="flex h-full flex-col place-content-center place-items-center mr-4">
+								<div class="inline-block">
+									<CircleIconButton
+										logo={ChevronRight}
+										backgroundColor="#202123"
+										on:click={toNextCurrentAsset}
+									/>
+								</div>
+							</div>
+						</div>
+
+						{#key currentMemory.assets[autoPlayIndex].id}
+							<img
+								transition:fade|local
+								class="rounded-2xl w-full h-full object-contain transition-all"
+								src={api.getAssetThumbnailUrl(currentMemory.assets[autoPlayIndex].id, 'JPEG')}
+								alt=""
+								draggable="false"
+							/>
+						{/key}
+
+						<div class="absolute top-4 left-8 text-white text-sm font-medium">
+							<p>
+								{DateTime.fromISO(currentMemory.assets[0].fileCreatedAt).toLocaleString(
+									DateTime.DATE_FULL
+								)}
+							</p>
+							<p>
+								{currentMemory.assets[autoPlayIndex].exifInfo?.city || ''}
+								{currentMemory.assets[autoPlayIndex].exifInfo?.country || ''}
+							</p>
+						</div>
+					</div>
+				</div>
+
+				<!-- NEXT MEMORY -->
+				<div
+					class="rounded-xl w-[20vw] h-1/2"
+					class:opacity-25={showNextMemory}
+					class:opacity-0={!showNextMemory}
+					class:hover:opacity-70={showNextMemory}
+				>
+					<button
+						class="rounded-2xl h-full w-full relative"
+						on:click={toNextMemory}
+						disabled={!showNextMemory}
+					>
+						<img
+							class="rounded-2xl h-full w-full object-cover"
+							src={showNextMemory
+								? api.getAssetThumbnailUrl(nextMemory.assets[0].id, 'JPEG')
+								: noThumbnailUrl}
+							alt=""
+							draggable="false"
+						/>
+
+						{#if showNextMemory}
+							<div class="absolute left-4 bottom-4 text-white text-left">
+								<p class="font-semibold text-xs text-gray-200">UP NEXT</p>
+								<p class="text-xl">{nextMemory.title}</p>
+							</div>
+						{/if}
+					</button>
+				</div>
+			</div>
+		</section>
+
+		<!-- GALERY VIEWER -->
+
+		<section class="bg-immich-dark-gray pl-4">
+			<div
+				class="sticky flex place-content-center place-items-center mb-10 mt-4 transition-all"
+				class:opacity-0={galleryInView}
+				class:opacity-100={!galleryInView}
+			>
+				<button on:click={() => memoryGallery.scrollIntoView({ behavior: 'smooth' })}>
+					<CircleIconButton logo={ChevronDown} backgroundColor="white" forceDark />
+				</button>
+			</div>
+
+			<IntersectionObserver
+				once={false}
+				on:intersected={() => (galleryInView = true)}
+				on:hidden={() => (galleryInView = false)}
+				bottom={-200}
+			>
+				<div id="gallery-memory" bind:this={memoryGallery}>
+					<GalleryViewer assets={currentMemory.assets} viewFrom="album-page" />
+				</div>
+			</IntersectionObserver>
+		</section>
+	{/if}
+</section>
+
+<style>
+	.main-view {
+		box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.3), 0 8px 12px 6px rgba(0, 0, 0, 0.15);
+	}
+</style>

+ 2 - 0
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}
+		<MemoryLane />
 		<section id="virtual-timeline" style:height={$assetGridState.timelineHeight + 'px'}>
 			{#each $assetGridState.buckets as bucket, bucketIndex (bucketIndex)}
 				<IntersectionObserver

+ 94 - 0
web/src/lib/components/photos-page/memory-lane.svelte

@@ -0,0 +1,94 @@
+<script lang="ts">
+	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'
+		});
+	}
+</script>
+
+{#if shouldRender}
+	<section
+		id="memory-lane"
+		bind:this={memoryLaneElement}
+		class="relative overflow-x-hidden whitespace-nowrap mt-5 transition-all"
+		bind:offsetWidth
+	>
+		{#if isOverflow}
+			<div class="sticky left-0 z-20">
+				<div class="absolute right-0 top-[6rem] z-20">
+					<button
+						class="rounded-full opacity-50 hover:opacity-100 p-2 border border-gray-500 bg-gray-100 text-gray-500"
+						on:click={scrollRight}
+					>
+						<ChevronRight size="36" /></button
+					>
+				</div>
+
+				<div class="absolute left-0 top-[6rem] z-20">
+					<button
+						class="rounded-full opacity-50 hover:opacity-100 p-2 border border-gray-500 bg-gray-100 text-gray-500"
+						on:click={scrollLeft}><ChevronLeft size="36" /></button
+					>
+				</div>
+			</div>
+		{/if}
+
+		<div class="inline-block" bind:offsetWidth={innerWidth}>
+			{#each memoryLane as memory, i (memory.title)}
+				<button
+					class="memory-card relative inline-block mr-8 rounded-xl aspect-video h-[215px]"
+					on:click={() => goto(`/memory?index=${i}`)}
+				>
+					<img
+						class="rounded-xl h-full w-full object-cover"
+						src={api.getAssetThumbnailUrl(memory.assets[0].id, 'JPEG')}
+						alt={memory.title}
+						draggable="false"
+					/>
+					<p class="absolute bottom-2 left-4 text-lg text-white z-10">{memory.title}</p>
+					<div
+						class="absolute top-0 left-0 w-full h-full rounded-xl bg-gradient-to-t from-black/40 via-transparent to-transparent z-0 hover:bg-black/20 transition-all"
+					/>
+				</button>
+			{/each}
+		</div>
+	</section>
+{/if}
+
+<style>
+	.memory-card {
+		box-shadow: rgba(60, 64, 67, 0.3) 0px 1px 2px 0px, rgba(60, 64, 67, 0.15) 0px 1px 3px 1px;
+	}
+</style>

+ 14 - 4
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 @@
 <div in:fly={{ y: 10, duration: 200 }} class="fixed top-0 w-full bg-transparent z-[100]">
 	<div
 		id="asset-selection-app-bar"
-		class={`flex justify-between ${appBarBorder} rounded-lg p-2 mx-2 mt-2 transition-all place-items-center ${tailwindClasses} dark:bg-immich-dark-gray`}
+		class={`grid grid-cols-3 justify-between ${appBarBorder} rounded-lg p-2 mx-2 mt-2 transition-all place-items-center ${tailwindClasses} dark:bg-immich-dark-gray ${
+			forceDark && 'bg-immich-dark-gray text-white'
+		}`}
 	>
-		<div class="flex place-items-center gap-6 dark:text-immich-dark-fg">
+		<div class="flex place-items-center gap-6 dark:text-immich-dark-fg justify-self-start">
 			{#if showBackButton}
 				<CircleIconButton
 					on:click={() => dispatch('close-button-click')}
@@ -48,14 +55,17 @@
 					backgroundColor={'transparent'}
 					hoverColor={'#e2e7e9'}
 					size={'24'}
+					forceDark
 				/>
 			{/if}
 			<slot name="leading" />
 		</div>
 
-		<slot />
+		<div class="w-full">
+			<slot />
+		</div>
 
-		<div class="flex place-items-center gap-1 mr-4">
+		<div class="flex place-items-center gap-1 mr-4 justify-self-end">
 			<slot name="trailing" />
 		</div>
 	</div>

+ 4 - 0
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<MemoryLaneResponseDto[]>();

+ 16 - 0
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;

+ 5 - 0
web/src/routes/(user)/memory/+page.svelte

@@ -0,0 +1,5 @@
+<script>
+	import MemoryViewer from '$lib/components/memory-page/memory-viewer.svelte';
+</script>
+
+<MemoryViewer />