1
0
forked from Cutlery/immich

Merge branch 'main' of https://github.com/immich-app/immich into feat/offline-files-job

This commit is contained in:
Jonathan Jogenfors 2024-03-14 20:54:27 +01:00
commit d8dd1fbff0
43 changed files with 708 additions and 548 deletions

View File

@ -67,12 +67,6 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.2.0 uses: docker/setup-buildx-action@v3.2.0
# Workaround to fix error:
# failed to push: failed to copy: io: read/write on closed pipe
# See https://github.com/docker/build-push-action/issues/761
with:
driver-opts: |
image=moby/buildkit:v0.10.6
- name: Login to Docker Hub - name: Login to Docker Hub
# Only push to Docker Hub when making a release # Only push to Docker Hub when making a release

View File

@ -91,17 +91,13 @@ jobs:
with: with:
node-version: 20 node-version: 20
- name: Run setup typescript-sdk - name: Setup typescript-sdk
run: npm ci && npm run build run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk working-directory: ./open-api/typescript-sdk
- name: Run npm install (cli) - name: Install deps
run: npm ci run: npm ci
- name: Run npm install (server)
run: npm ci
working-directory: ./server
- name: Run linter - name: Run linter
run: npm run lint run: npm run lint
if: ${{ !cancelled() }} if: ${{ !cancelled() }}

View File

@ -1,11 +1,15 @@
**/*.spec.js **/*.spec.js
coverage/**
src/**
upload/** upload/**
.editorconfig .editorconfig
.eslintignore .eslintignore
.eslintrc.js .eslintrc.cjs
.gitignore
.prettierignore .prettierignore
.prettierrc .prettierrc
Dockerfile
package-lock.json package-lock.json
testSetup.js
tsconfig.json tsconfig.json
tsconfig.build.json vite.config.ts
vitest.config.ts

View File

@ -15,19 +15,7 @@
"incremental": true, "incremental": true,
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true, "esModuleInterop": true,
"rootDirs": ["src", "../server/src"],
"baseUrl": "./", "baseUrl": "./",
"paths": {
"@test": ["../server/test"],
"@test/*": ["../server/test/*"],
"@test-utils": ["../server/src/test-utils/utils"],
"@app/immich": ["../server/src/immich"],
"@app/immich/*": ["../server/src/immich/*"],
"@app/infra": ["../server/src/infra"],
"@app/infra/*": ["../server/src/infra/*"],
"@app/domain": ["../server/src/domain"],
"@app/domain/*": ["../server/src/domain/*"]
},
"types": ["vitest/globals"] "types": ["vitest/globals"]
}, },
"exclude": ["dist", "node_modules"] "exclude": ["dist", "node_modules"]

View File

@ -1,6 +1,7 @@
import { import {
AlbumResponseDto, AlbumResponseDto,
AssetFileUploadResponseDto, AssetFileUploadResponseDto,
AssetOrder,
LoginResponseDto, LoginResponseDto,
SharedLinkType, SharedLinkType,
deleteUser, deleteUser,
@ -353,6 +354,7 @@ describe('/album', () => {
assetCount: 0, assetCount: 0,
owner: expect.objectContaining({ email: user1.userEmail }), owner: expect.objectContaining({ email: user1.userEmail }),
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.Desc,
}); });
}); });
}); });

View File

@ -19,6 +19,7 @@ Name | Type | Description | Notes
**id** | **String** | | **id** | **String** | |
**isActivityEnabled** | **bool** | | **isActivityEnabled** | **bool** | |
**lastModifiedAssetTimestamp** | [**DateTime**](DateTime.md) | | [optional] **lastModifiedAssetTimestamp** | [**DateTime**](DateTime.md) | | [optional]
**order** | [**AssetOrder**](AssetOrder.md) | | [optional]
**owner** | [**UserResponseDto**](UserResponseDto.md) | | **owner** | [**UserResponseDto**](UserResponseDto.md) | |
**ownerId** | **String** | | **ownerId** | **String** | |
**shared** | **bool** | | **shared** | **bool** | |

View File

@ -834,7 +834,7 @@ 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) [[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)
# **getTimeBucket** # **getTimeBucket**
> List<AssetResponseDto> getTimeBucket(size, timeBucket, albumId, isArchived, isFavorite, isTrashed, key, personId, userId, withPartners, withStacked) > List<AssetResponseDto> getTimeBucket(size, timeBucket, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked)
@ -864,13 +864,14 @@ final isArchived = true; // bool |
final isFavorite = true; // bool | final isFavorite = true; // bool |
final isTrashed = true; // bool | final isTrashed = true; // bool |
final key = key_example; // String | final key = key_example; // String |
final order = ; // AssetOrder |
final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final withPartners = true; // bool | final withPartners = true; // bool |
final withStacked = true; // bool | final withStacked = true; // bool |
try { try {
final result = api_instance.getTimeBucket(size, timeBucket, albumId, isArchived, isFavorite, isTrashed, key, personId, userId, withPartners, withStacked); final result = api_instance.getTimeBucket(size, timeBucket, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked);
print(result); print(result);
} catch (e) { } catch (e) {
print('Exception when calling AssetApi->getTimeBucket: $e\n'); print('Exception when calling AssetApi->getTimeBucket: $e\n');
@ -888,6 +889,7 @@ Name | Type | Description | Notes
**isFavorite** | **bool**| | [optional] **isFavorite** | **bool**| | [optional]
**isTrashed** | **bool**| | [optional] **isTrashed** | **bool**| | [optional]
**key** | **String**| | [optional] **key** | **String**| | [optional]
**order** | [**AssetOrder**](.md)| | [optional]
**personId** | **String**| | [optional] **personId** | **String**| | [optional]
**userId** | **String**| | [optional] **userId** | **String**| | [optional]
**withPartners** | **bool**| | [optional] **withPartners** | **bool**| | [optional]
@ -909,7 +911,7 @@ 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) [[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)
# **getTimeBuckets** # **getTimeBuckets**
> List<TimeBucketResponseDto> getTimeBuckets(size, albumId, isArchived, isFavorite, isTrashed, key, personId, userId, withPartners, withStacked) > List<TimeBucketResponseDto> getTimeBuckets(size, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked)
@ -938,13 +940,14 @@ final isArchived = true; // bool |
final isFavorite = true; // bool | final isFavorite = true; // bool |
final isTrashed = true; // bool | final isTrashed = true; // bool |
final key = key_example; // String | final key = key_example; // String |
final order = ; // AssetOrder |
final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final withPartners = true; // bool | final withPartners = true; // bool |
final withStacked = true; // bool | final withStacked = true; // bool |
try { try {
final result = api_instance.getTimeBuckets(size, albumId, isArchived, isFavorite, isTrashed, key, personId, userId, withPartners, withStacked); final result = api_instance.getTimeBuckets(size, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked);
print(result); print(result);
} catch (e) { } catch (e) {
print('Exception when calling AssetApi->getTimeBuckets: $e\n'); print('Exception when calling AssetApi->getTimeBuckets: $e\n');
@ -961,6 +964,7 @@ Name | Type | Description | Notes
**isFavorite** | **bool**| | [optional] **isFavorite** | **bool**| | [optional]
**isTrashed** | **bool**| | [optional] **isTrashed** | **bool**| | [optional]
**key** | **String**| | [optional] **key** | **String**| | [optional]
**order** | [**AssetOrder**](.md)| | [optional]
**personId** | **String**| | [optional] **personId** | **String**| | [optional]
**userId** | **String**| | [optional] **userId** | **String**| | [optional]
**withPartners** | **bool**| | [optional] **withPartners** | **bool**| | [optional]

View File

@ -12,6 +12,7 @@ Name | Type | Description | Notes
**albumThumbnailAssetId** | **String** | | [optional] **albumThumbnailAssetId** | **String** | | [optional]
**description** | **String** | | [optional] **description** | **String** | | [optional]
**isActivityEnabled** | **bool** | | [optional] **isActivityEnabled** | **bool** | | [optional]
**order** | [**AssetOrder**](AssetOrder.md) | | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -852,6 +852,8 @@ class AssetApi {
/// ///
/// * [String] key: /// * [String] key:
/// ///
/// * [AssetOrder] order:
///
/// * [String] personId: /// * [String] personId:
/// ///
/// * [String] userId: /// * [String] userId:
@ -859,7 +861,7 @@ class AssetApi {
/// * [bool] withPartners: /// * [bool] withPartners:
/// ///
/// * [bool] withStacked: /// * [bool] withStacked:
Future<Response> getTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { Future<Response> getTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final path = r'/asset/time-bucket'; final path = r'/asset/time-bucket';
@ -885,6 +887,9 @@ class AssetApi {
if (key != null) { if (key != null) {
queryParams.addAll(_queryParams('', 'key', key)); queryParams.addAll(_queryParams('', 'key', key));
} }
if (order != null) {
queryParams.addAll(_queryParams('', 'order', order));
}
if (personId != null) { if (personId != null) {
queryParams.addAll(_queryParams('', 'personId', personId)); queryParams.addAll(_queryParams('', 'personId', personId));
} }
@ -930,6 +935,8 @@ class AssetApi {
/// ///
/// * [String] key: /// * [String] key:
/// ///
/// * [AssetOrder] order:
///
/// * [String] personId: /// * [String] personId:
/// ///
/// * [String] userId: /// * [String] userId:
@ -937,8 +944,8 @@ class AssetApi {
/// * [bool] withPartners: /// * [bool] withPartners:
/// ///
/// * [bool] withStacked: /// * [bool] withStacked:
Future<List<AssetResponseDto>?> getTimeBucket(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { Future<List<AssetResponseDto>?> getTimeBucket(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async {
final response = await getTimeBucketWithHttpInfo(size, timeBucket, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); final response = await getTimeBucketWithHttpInfo(size, timeBucket, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, );
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }
@ -970,6 +977,8 @@ class AssetApi {
/// ///
/// * [String] key: /// * [String] key:
/// ///
/// * [AssetOrder] order:
///
/// * [String] personId: /// * [String] personId:
/// ///
/// * [String] userId: /// * [String] userId:
@ -977,7 +986,7 @@ class AssetApi {
/// * [bool] withPartners: /// * [bool] withPartners:
/// ///
/// * [bool] withStacked: /// * [bool] withStacked:
Future<Response> getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { Future<Response> getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final path = r'/asset/time-buckets'; final path = r'/asset/time-buckets';
@ -1003,6 +1012,9 @@ class AssetApi {
if (key != null) { if (key != null) {
queryParams.addAll(_queryParams('', 'key', key)); queryParams.addAll(_queryParams('', 'key', key));
} }
if (order != null) {
queryParams.addAll(_queryParams('', 'order', order));
}
if (personId != null) { if (personId != null) {
queryParams.addAll(_queryParams('', 'personId', personId)); queryParams.addAll(_queryParams('', 'personId', personId));
} }
@ -1045,6 +1057,8 @@ class AssetApi {
/// ///
/// * [String] key: /// * [String] key:
/// ///
/// * [AssetOrder] order:
///
/// * [String] personId: /// * [String] personId:
/// ///
/// * [String] userId: /// * [String] userId:
@ -1052,8 +1066,8 @@ class AssetApi {
/// * [bool] withPartners: /// * [bool] withPartners:
/// ///
/// * [bool] withStacked: /// * [bool] withStacked:
Future<List<TimeBucketResponseDto>?> getTimeBuckets(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { Future<List<TimeBucketResponseDto>?> getTimeBuckets(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async {
final response = await getTimeBucketsWithHttpInfo(size, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); final response = await getTimeBucketsWithHttpInfo(size, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, );
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }

View File

@ -24,6 +24,7 @@ class AlbumResponseDto {
required this.id, required this.id,
required this.isActivityEnabled, required this.isActivityEnabled,
this.lastModifiedAssetTimestamp, this.lastModifiedAssetTimestamp,
this.order,
required this.owner, required this.owner,
required this.ownerId, required this.ownerId,
required this.shared, required this.shared,
@ -66,6 +67,14 @@ class AlbumResponseDto {
/// ///
DateTime? lastModifiedAssetTimestamp; DateTime? lastModifiedAssetTimestamp;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
AssetOrder? order;
UserResponseDto owner; UserResponseDto owner;
String ownerId; String ownerId;
@ -97,6 +106,7 @@ class AlbumResponseDto {
other.id == id && other.id == id &&
other.isActivityEnabled == isActivityEnabled && other.isActivityEnabled == isActivityEnabled &&
other.lastModifiedAssetTimestamp == lastModifiedAssetTimestamp && other.lastModifiedAssetTimestamp == lastModifiedAssetTimestamp &&
other.order == order &&
other.owner == owner && other.owner == owner &&
other.ownerId == ownerId && other.ownerId == ownerId &&
other.shared == shared && other.shared == shared &&
@ -118,6 +128,7 @@ class AlbumResponseDto {
(id.hashCode) + (id.hashCode) +
(isActivityEnabled.hashCode) + (isActivityEnabled.hashCode) +
(lastModifiedAssetTimestamp == null ? 0 : lastModifiedAssetTimestamp!.hashCode) + (lastModifiedAssetTimestamp == null ? 0 : lastModifiedAssetTimestamp!.hashCode) +
(order == null ? 0 : order!.hashCode) +
(owner.hashCode) + (owner.hashCode) +
(ownerId.hashCode) + (ownerId.hashCode) +
(shared.hashCode) + (shared.hashCode) +
@ -126,7 +137,7 @@ class AlbumResponseDto {
(updatedAt.hashCode); (updatedAt.hashCode);
@override @override
String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, isActivityEnabled=$isActivityEnabled, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, owner=$owner, ownerId=$ownerId, shared=$shared, sharedUsers=$sharedUsers, startDate=$startDate, updatedAt=$updatedAt]'; String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, isActivityEnabled=$isActivityEnabled, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, order=$order, owner=$owner, ownerId=$ownerId, shared=$shared, sharedUsers=$sharedUsers, startDate=$startDate, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -152,6 +163,11 @@ class AlbumResponseDto {
json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String(); json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String();
} else { } else {
// json[r'lastModifiedAssetTimestamp'] = null; // json[r'lastModifiedAssetTimestamp'] = null;
}
if (this.order != null) {
json[r'order'] = this.order;
} else {
// json[r'order'] = null;
} }
json[r'owner'] = this.owner; json[r'owner'] = this.owner;
json[r'ownerId'] = this.ownerId; json[r'ownerId'] = this.ownerId;
@ -185,6 +201,7 @@ class AlbumResponseDto {
id: mapValueOfType<String>(json, r'id')!, id: mapValueOfType<String>(json, r'id')!,
isActivityEnabled: mapValueOfType<bool>(json, r'isActivityEnabled')!, isActivityEnabled: mapValueOfType<bool>(json, r'isActivityEnabled')!,
lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', r''), lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', r''),
order: AssetOrder.fromJson(json[r'order']),
owner: UserResponseDto.fromJson(json[r'owner'])!, owner: UserResponseDto.fromJson(json[r'owner'])!,
ownerId: mapValueOfType<String>(json, r'ownerId')!, ownerId: mapValueOfType<String>(json, r'ownerId')!,
shared: mapValueOfType<bool>(json, r'shared')!, shared: mapValueOfType<bool>(json, r'shared')!,

View File

@ -17,6 +17,7 @@ class UpdateAlbumDto {
this.albumThumbnailAssetId, this.albumThumbnailAssetId,
this.description, this.description,
this.isActivityEnabled, this.isActivityEnabled,
this.order,
}); });
/// ///
@ -51,12 +52,21 @@ class UpdateAlbumDto {
/// ///
bool? isActivityEnabled; bool? isActivityEnabled;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
AssetOrder? order;
@override @override
bool operator ==(Object other) => identical(this, other) || other is UpdateAlbumDto && bool operator ==(Object other) => identical(this, other) || other is UpdateAlbumDto &&
other.albumName == albumName && other.albumName == albumName &&
other.albumThumbnailAssetId == albumThumbnailAssetId && other.albumThumbnailAssetId == albumThumbnailAssetId &&
other.description == description && other.description == description &&
other.isActivityEnabled == isActivityEnabled; other.isActivityEnabled == isActivityEnabled &&
other.order == order;
@override @override
int get hashCode => int get hashCode =>
@ -64,10 +74,11 @@ class UpdateAlbumDto {
(albumName == null ? 0 : albumName!.hashCode) + (albumName == null ? 0 : albumName!.hashCode) +
(albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) + (albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) +
(description == null ? 0 : description!.hashCode) + (description == null ? 0 : description!.hashCode) +
(isActivityEnabled == null ? 0 : isActivityEnabled!.hashCode); (isActivityEnabled == null ? 0 : isActivityEnabled!.hashCode) +
(order == null ? 0 : order!.hashCode);
@override @override
String toString() => 'UpdateAlbumDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, description=$description, isActivityEnabled=$isActivityEnabled]'; String toString() => 'UpdateAlbumDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, description=$description, isActivityEnabled=$isActivityEnabled, order=$order]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -91,6 +102,11 @@ class UpdateAlbumDto {
} else { } else {
// json[r'isActivityEnabled'] = null; // json[r'isActivityEnabled'] = null;
} }
if (this.order != null) {
json[r'order'] = this.order;
} else {
// json[r'order'] = null;
}
return json; return json;
} }
@ -106,6 +122,7 @@ class UpdateAlbumDto {
albumThumbnailAssetId: mapValueOfType<String>(json, r'albumThumbnailAssetId'), albumThumbnailAssetId: mapValueOfType<String>(json, r'albumThumbnailAssetId'),
description: mapValueOfType<String>(json, r'description'), description: mapValueOfType<String>(json, r'description'),
isActivityEnabled: mapValueOfType<bool>(json, r'isActivityEnabled'), isActivityEnabled: mapValueOfType<bool>(json, r'isActivityEnabled'),
order: AssetOrder.fromJson(json[r'order']),
); );
} }
return null; return null;

View File

@ -71,6 +71,11 @@ void main() {
// TODO // TODO
}); });
// AssetOrder order
test('to test the property `order`', () async {
// TODO
});
// UserResponseDto owner // UserResponseDto owner
test('to test the property `owner`', () async { test('to test the property `owner`', () async {
// TODO // TODO

View File

@ -95,12 +95,12 @@ void main() {
// TODO // TODO
}); });
//Future<List<AssetResponseDto>> getTimeBucket(TimeBucketSize size, String timeBucket, { String albumId, bool isArchived, bool isFavorite, bool isTrashed, String key, String personId, String userId, bool withPartners, bool withStacked }) async //Future<List<AssetResponseDto>> getTimeBucket(TimeBucketSize size, String timeBucket, { String albumId, bool isArchived, bool isFavorite, bool isTrashed, String key, AssetOrder order, String personId, String userId, bool withPartners, bool withStacked }) async
test('test getTimeBucket', () async { test('test getTimeBucket', () async {
// TODO // TODO
}); });
//Future<List<TimeBucketResponseDto>> getTimeBuckets(TimeBucketSize size, { String albumId, bool isArchived, bool isFavorite, bool isTrashed, String key, String personId, String userId, bool withPartners, bool withStacked }) async //Future<List<TimeBucketResponseDto>> getTimeBuckets(TimeBucketSize size, { String albumId, bool isArchived, bool isFavorite, bool isTrashed, String key, AssetOrder order, String personId, String userId, bool withPartners, bool withStacked }) async
test('test getTimeBuckets', () async { test('test getTimeBuckets', () async {
// TODO // TODO
}); });

View File

@ -36,6 +36,11 @@ void main() {
// TODO // TODO
}); });
// AssetOrder order
test('to test the property `order`', () async {
// TODO
});
}); });

View File

@ -1765,6 +1765,14 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "order",
"required": false,
"in": "query",
"schema": {
"$ref": "#/components/schemas/AssetOrder"
}
},
{ {
"name": "personId", "name": "personId",
"required": false, "required": false,
@ -1901,6 +1909,14 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "order",
"required": false,
"in": "query",
"schema": {
"$ref": "#/components/schemas/AssetOrder"
}
},
{ {
"name": "personId", "name": "personId",
"required": false, "required": false,
@ -6722,6 +6738,9 @@
"format": "date-time", "format": "date-time",
"type": "string" "type": "string"
}, },
"order": {
"$ref": "#/components/schemas/AssetOrder"
},
"owner": { "owner": {
"$ref": "#/components/schemas/UserResponseDto" "$ref": "#/components/schemas/UserResponseDto"
}, },
@ -10338,6 +10357,9 @@
}, },
"isActivityEnabled": { "isActivityEnabled": {
"type": "boolean" "type": "boolean"
},
"order": {
"$ref": "#/components/schemas/AssetOrder"
} }
}, },
"type": "object" "type": "object"

View File

@ -153,6 +153,7 @@ export type AlbumResponseDto = {
id: string; id: string;
isActivityEnabled: boolean; isActivityEnabled: boolean;
lastModifiedAssetTimestamp?: string; lastModifiedAssetTimestamp?: string;
order?: AssetOrder;
owner: UserResponseDto; owner: UserResponseDto;
ownerId: string; ownerId: string;
shared: boolean; shared: boolean;
@ -176,6 +177,7 @@ export type UpdateAlbumDto = {
albumThumbnailAssetId?: string; albumThumbnailAssetId?: string;
description?: string; description?: string;
isActivityEnabled?: boolean; isActivityEnabled?: boolean;
order?: AssetOrder;
}; };
export type BulkIdsDto = { export type BulkIdsDto = {
ids: string[]; ids: string[];
@ -1454,12 +1456,13 @@ export function getAssetThumbnail({ format, id, key }: {
...opts ...opts
})); }));
} }
export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, personId, size, timeBucket, userId, withPartners, withStacked }: { export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, timeBucket, userId, withPartners, withStacked }: {
albumId?: string; albumId?: string;
isArchived?: boolean; isArchived?: boolean;
isFavorite?: boolean; isFavorite?: boolean;
isTrashed?: boolean; isTrashed?: boolean;
key?: string; key?: string;
order?: AssetOrder;
personId?: string; personId?: string;
size: TimeBucketSize; size: TimeBucketSize;
timeBucket: string; timeBucket: string;
@ -1476,6 +1479,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key,
isFavorite, isFavorite,
isTrashed, isTrashed,
key, key,
order,
personId, personId,
size, size,
timeBucket, timeBucket,
@ -1486,12 +1490,13 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key,
...opts ...opts
})); }));
} }
export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, personId, size, userId, withPartners, withStacked }: { export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, userId, withPartners, withStacked }: {
albumId?: string; albumId?: string;
isArchived?: boolean; isArchived?: boolean;
isFavorite?: boolean; isFavorite?: boolean;
isTrashed?: boolean; isTrashed?: boolean;
key?: string; key?: string;
order?: AssetOrder;
personId?: string; personId?: string;
size: TimeBucketSize; size: TimeBucketSize;
userId?: string; userId?: string;
@ -1507,6 +1512,7 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key
isFavorite, isFavorite,
isTrashed, isTrashed,
key, key,
order,
personId, personId,
size, size,
userId, userId,
@ -2748,6 +2754,10 @@ export enum AssetTypeEnum {
Audio = "AUDIO", Audio = "AUDIO",
Other = "OTHER" Other = "OTHER"
} }
export enum AssetOrder {
Asc = "asc",
Desc = "desc"
}
export enum Error { export enum Error {
Duplicate = "duplicate", Duplicate = "duplicate",
NoPermission = "no_permission", NoPermission = "no_permission",
@ -2775,10 +2785,6 @@ export enum TimeBucketSize {
Day = "DAY", Day = "DAY",
Month = "MONTH" Month = "MONTH"
} }
export enum AssetOrder {
Asc = "asc",
Desc = "desc"
}
export enum EntityType { export enum EntityType {
Asset = "ASSET", Asset = "ASSET",
Album = "ALBUM" Album = "ALBUM"

View File

@ -1,4 +1,5 @@
import { AlbumEntity } from '@app/infra/entities'; import { AlbumEntity, AssetOrder } from '@app/infra/entities';
import { Optional } from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { AssetResponseDto, mapAsset } from '../asset'; import { AssetResponseDto, mapAsset } from '../asset';
import { AuthDto } from '../auth/auth.dto'; import { AuthDto } from '../auth/auth.dto';
@ -23,6 +24,9 @@ export class AlbumResponseDto {
startDate?: Date; startDate?: Date;
endDate?: Date; endDate?: Date;
isActivityEnabled!: boolean; isActivityEnabled!: boolean;
@Optional()
@ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder })
order?: AssetOrder;
} }
export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => { export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => {
@ -63,6 +67,7 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDt
assets: (withAssets ? assets : []).map((asset) => mapAsset(asset, { auth })), assets: (withAssets ? assets : []).map((asset) => mapAsset(asset, { auth })),
assetCount: entity.assets?.length || 0, assetCount: entity.assets?.length || 0,
isActivityEnabled: entity.isActivityEnabled, isActivityEnabled: entity.isActivityEnabled,
order: entity.order,
}; };
}; };

View File

@ -148,6 +148,7 @@ export class AlbumService {
description: dto.description, description: dto.description,
albumThumbnailAssetId: dto.albumThumbnailAssetId, albumThumbnailAssetId: dto.albumThumbnailAssetId,
isActivityEnabled: dto.isActivityEnabled, isActivityEnabled: dto.isActivityEnabled,
order: dto.order,
}); });
return mapAlbumWithoutAssets(updatedAlbum); return mapAlbumWithoutAssets(updatedAlbum);

View File

@ -1,4 +1,6 @@
import { IsString } from 'class-validator'; import { AssetOrder } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsString } from 'class-validator';
import { Optional, ValidateBoolean, ValidateUUID } from '../../domain.util'; import { Optional, ValidateBoolean, ValidateUUID } from '../../domain.util';
export class UpdateAlbumDto { export class UpdateAlbumDto {
@ -15,4 +17,9 @@ export class UpdateAlbumDto {
@ValidateBoolean({ optional: true }) @ValidateBoolean({ optional: true })
isActivityEnabled?: boolean; isActivityEnabled?: boolean;
@IsEnum(AssetOrder)
@Optional()
@ApiProperty({ enum: AssetOrder, enumName: 'AssetOrder' })
order?: AssetOrder;
} }

View File

@ -18,11 +18,6 @@ export class DeviceIdDto {
deviceId!: string; deviceId!: string;
} }
export enum AssetOrder {
ASC = 'asc',
DESC = 'desc',
}
const hasGPS = (o: { latitude: undefined; longitude: undefined }) => const hasGPS = (o: { latitude: undefined; longitude: undefined }) =>
o.latitude !== undefined || o.longitude !== undefined; o.latitude !== undefined || o.longitude !== undefined;
const ValidateGPS = () => ValidateIf(hasGPS); const ValidateGPS = () => ValidateIf(hasGPS);

View File

@ -1,6 +1,7 @@
import { AssetOrder } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { ValidateBoolean, ValidateUUID } from '../../domain.util'; import { Optional, ValidateBoolean, ValidateUUID } from '../../domain.util';
import { TimeBucketSize } from '../../repositories'; import { TimeBucketSize } from '../../repositories';
export class TimeBucketDto { export class TimeBucketDto {
@ -32,6 +33,11 @@ export class TimeBucketDto {
@ValidateBoolean({ optional: true }) @ValidateBoolean({ optional: true })
withPartners?: boolean; withPartners?: boolean;
@IsEnum(AssetOrder)
@Optional()
@ApiProperty({ enum: AssetOrder, enumName: 'AssetOrder' })
order?: AssetOrder;
} }
export class TimeBucketAssetDto extends TimeBucketDto { export class TimeBucketAssetDto extends TimeBucketDto {

View File

@ -1,5 +1,5 @@
import { AssetSearchOptions, ReverseGeocodeResult, SearchExploreItem } from '@app/domain'; import { AssetSearchOptions, ReverseGeocodeResult, SearchExploreItem } from '@app/domain';
import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity } from '@app/infra/entities'; import { AssetEntity, AssetJobStatusEntity, AssetOrder, AssetType, ExifEntity } from '@app/infra/entities';
import { FindOptionsRelations, FindOptionsSelect } from 'typeorm'; import { FindOptionsRelations, FindOptionsSelect } from 'typeorm';
import { Paginated, PaginationOptions } from '../domain.util'; import { Paginated, PaginationOptions } from '../domain.util';
@ -67,6 +67,7 @@ export interface AssetBuilderOptions {
export interface TimeBucketOptions extends AssetBuilderOptions { export interface TimeBucketOptions extends AssetBuilderOptions {
size: TimeBucketSize; size: TimeBucketSize;
order?: AssetOrder;
} }
export interface TimeBucketItem { export interface TimeBucketItem {

View File

@ -1,5 +1,4 @@
import { AssetOrder } from '@app/domain/asset/dto/asset.dto'; import { AssetOrder, AssetType, GeodataPlacesEntity } from '@app/infra/entities';
import { AssetType, GeodataPlacesEntity } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator'; import { IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';

View File

@ -1,6 +1,6 @@
import { AssetEntity } from '@app/infra/entities'; import { AssetEntity, AssetOrder } from '@app/infra/entities';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { AssetOrder, AssetResponseDto, mapAsset } from '../asset'; import { AssetResponseDto, mapAsset } from '../asset';
import { AuthDto } from '../auth'; import { AuthDto } from '../auth';
import { PersonResponseDto } from '../person'; import { PersonResponseDto } from '../person';
import { import {

View File

@ -14,6 +14,12 @@ import { AssetEntity } from './asset.entity';
import { SharedLinkEntity } from './shared-link.entity'; import { SharedLinkEntity } from './shared-link.entity';
import { UserEntity } from './user.entity'; import { UserEntity } from './user.entity';
// ran into issues when importing the enum from `asset.dto.ts`
export enum AssetOrder {
ASC = 'asc',
DESC = 'desc',
}
@Entity('albums') @Entity('albums')
export class AlbumEntity { export class AlbumEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
@ -59,4 +65,7 @@ export class AlbumEntity {
@Column({ default: true }) @Column({ default: true })
isActivityEnabled!: boolean; isActivityEnabled!: boolean;
@Column({ type: 'varchar', default: AssetOrder.DESC })
order!: AssetOrder;
} }

View File

@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AscendingOrderAlbum1710182081326 implements MigrationInterface {
name = 'AscendingOrderAlbum1710182081326'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "albums" ADD "order" character varying NOT NULL DEFAULT 'desc'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "albums" DROP COLUMN "order"`);
}
}

View File

@ -36,7 +36,7 @@ import {
Not, Not,
Repository, Repository,
} from 'typeorm'; } from 'typeorm';
import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity, SmartInfoEntity } from '../entities'; import { AssetEntity, AssetJobStatusEntity, AssetOrder, AssetType, ExifEntity, SmartInfoEntity } from '../entities';
import { DummyValue, GenerateSql } from '../infra.util'; import { DummyValue, GenerateSql } from '../infra.util';
import { Chunked, ChunkedArray, OptionalBetween, paginate, paginatedBuilder, searchAssetBuilder } from '../infra.utils'; import { Chunked, ChunkedArray, OptionalBetween, paginate, paginatedBuilder, searchAssetBuilder } from '../infra.utils';
import { Instrumentation } from '../instrumentation'; import { Instrumentation } from '../instrumentation';
@ -614,7 +614,7 @@ export class AssetRepository implements IAssetRepository {
.select(`COUNT(asset.id)::int`, 'count') .select(`COUNT(asset.id)::int`, 'count')
.addSelect(truncated, 'timeBucket') .addSelect(truncated, 'timeBucket')
.groupBy(truncated) .groupBy(truncated)
.orderBy(truncated, 'DESC') .orderBy(truncated, options.order === AssetOrder.ASC ? 'ASC' : 'DESC')
.getRawMany(); .getRawMany();
} }
@ -627,7 +627,7 @@ export class AssetRepository implements IAssetRepository {
// First sort by the day in localtime (put it in the right bucket) // First sort by the day in localtime (put it in the right bucket)
.orderBy(truncated, 'DESC') .orderBy(truncated, 'DESC')
// and then sort by the actual time // and then sort by the actual time
.addOrderBy('asset.fileCreatedAt', 'DESC') .addOrderBy('asset.fileCreatedAt', options.order === AssetOrder.ASC ? 'ASC' : 'DESC')
.getMany() .getMany()
); );
} }

View File

@ -15,6 +15,7 @@ FROM
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt", "AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt",
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
"AlbumEntity"."order" AS "AlbumEntity_order",
"AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id", "AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id",
"AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name", "AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name",
"AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor", "AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor",
@ -91,6 +92,7 @@ SELECT
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt", "AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt",
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
"AlbumEntity"."order" AS "AlbumEntity_order",
"AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id", "AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id",
"AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name", "AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name",
"AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor", "AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor",
@ -149,6 +151,7 @@ SELECT
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt", "AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt",
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
"AlbumEntity"."order" AS "AlbumEntity_order",
"AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id", "AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id",
"AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name", "AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name",
"AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor", "AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor",
@ -279,6 +282,7 @@ SELECT
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt", "AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt",
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
"AlbumEntity"."order" AS "AlbumEntity_order",
"AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id", "AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id",
"AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name", "AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name",
"AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor", "AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor",
@ -352,6 +356,7 @@ SELECT
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt", "AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt",
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
"AlbumEntity"."order" AS "AlbumEntity_order",
"AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id", "AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id",
"AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name", "AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name",
"AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor", "AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor",
@ -462,6 +467,7 @@ SELECT
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt", "AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt",
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
"AlbumEntity"."order" AS "AlbumEntity_order",
"AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id", "AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id",
"AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name", "AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name",
"AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor", "AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor",
@ -553,6 +559,7 @@ SELECT
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt", "AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt",
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
"AlbumEntity"."order" AS "AlbumEntity_order",
"AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id", "AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id",
"AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name", "AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name",
"AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor", "AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor",

View File

@ -87,6 +87,7 @@ FROM
"SharedLinkEntity__SharedLinkEntity_album"."deletedAt" AS "SharedLinkEntity__SharedLinkEntity_album_deletedAt", "SharedLinkEntity__SharedLinkEntity_album"."deletedAt" AS "SharedLinkEntity__SharedLinkEntity_album_deletedAt",
"SharedLinkEntity__SharedLinkEntity_album"."albumThumbnailAssetId" AS "SharedLinkEntity__SharedLinkEntity_album_albumThumbnailAssetId", "SharedLinkEntity__SharedLinkEntity_album"."albumThumbnailAssetId" AS "SharedLinkEntity__SharedLinkEntity_album_albumThumbnailAssetId",
"SharedLinkEntity__SharedLinkEntity_album"."isActivityEnabled" AS "SharedLinkEntity__SharedLinkEntity_album_isActivityEnabled", "SharedLinkEntity__SharedLinkEntity_album"."isActivityEnabled" AS "SharedLinkEntity__SharedLinkEntity_album_isActivityEnabled",
"SharedLinkEntity__SharedLinkEntity_album"."order" AS "SharedLinkEntity__SharedLinkEntity_album_order",
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."id" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_id", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."id" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_id",
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."deviceAssetId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_deviceAssetId", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."deviceAssetId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_deviceAssetId",
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."ownerId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_ownerId", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."ownerId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_ownerId",
@ -248,6 +249,7 @@ SELECT
"SharedLinkEntity__SharedLinkEntity_album"."deletedAt" AS "SharedLinkEntity__SharedLinkEntity_album_deletedAt", "SharedLinkEntity__SharedLinkEntity_album"."deletedAt" AS "SharedLinkEntity__SharedLinkEntity_album_deletedAt",
"SharedLinkEntity__SharedLinkEntity_album"."albumThumbnailAssetId" AS "SharedLinkEntity__SharedLinkEntity_album_albumThumbnailAssetId", "SharedLinkEntity__SharedLinkEntity_album"."albumThumbnailAssetId" AS "SharedLinkEntity__SharedLinkEntity_album_albumThumbnailAssetId",
"SharedLinkEntity__SharedLinkEntity_album"."isActivityEnabled" AS "SharedLinkEntity__SharedLinkEntity_album_isActivityEnabled", "SharedLinkEntity__SharedLinkEntity_album"."isActivityEnabled" AS "SharedLinkEntity__SharedLinkEntity_album_isActivityEnabled",
"SharedLinkEntity__SharedLinkEntity_album"."order" AS "SharedLinkEntity__SharedLinkEntity_album_order",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."id" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_id", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."id" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_id",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."name" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_name", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."name" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_name",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."avatarColor" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_avatarColor", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."avatarColor" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_avatarColor",

View File

@ -1,4 +1,4 @@
import { AlbumEntity } from '@app/infra/entities'; import { AlbumEntity, AssetOrder } from '@app/infra/entities';
import { assetStub } from './asset.stub'; import { assetStub } from './asset.stub';
import { authStub } from './auth.stub'; import { authStub } from './auth.stub';
import { userStub } from './user.stub'; import { userStub } from './user.stub';
@ -19,6 +19,7 @@ export const albumStub = {
sharedLinks: [], sharedLinks: [],
sharedUsers: [], sharedUsers: [],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC,
}), }),
sharedWithUser: Object.freeze<AlbumEntity>({ sharedWithUser: Object.freeze<AlbumEntity>({
id: 'album-2', id: 'album-2',
@ -35,6 +36,7 @@ export const albumStub = {
sharedLinks: [], sharedLinks: [],
sharedUsers: [userStub.user1], sharedUsers: [userStub.user1],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC,
}), }),
sharedWithMultiple: Object.freeze<AlbumEntity>({ sharedWithMultiple: Object.freeze<AlbumEntity>({
id: 'album-3', id: 'album-3',
@ -51,6 +53,7 @@ export const albumStub = {
sharedLinks: [], sharedLinks: [],
sharedUsers: [userStub.user1, userStub.user2], sharedUsers: [userStub.user1, userStub.user2],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC,
}), }),
sharedWithAdmin: Object.freeze<AlbumEntity>({ sharedWithAdmin: Object.freeze<AlbumEntity>({
id: 'album-3', id: 'album-3',
@ -67,6 +70,7 @@ export const albumStub = {
sharedLinks: [], sharedLinks: [],
sharedUsers: [userStub.admin], sharedUsers: [userStub.admin],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC,
}), }),
oneAsset: Object.freeze<AlbumEntity>({ oneAsset: Object.freeze<AlbumEntity>({
id: 'album-4', id: 'album-4',
@ -83,6 +87,7 @@ export const albumStub = {
sharedLinks: [], sharedLinks: [],
sharedUsers: [], sharedUsers: [],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC,
}), }),
twoAssets: Object.freeze<AlbumEntity>({ twoAssets: Object.freeze<AlbumEntity>({
id: 'album-4a', id: 'album-4a',
@ -99,6 +104,7 @@ export const albumStub = {
sharedLinks: [], sharedLinks: [],
sharedUsers: [], sharedUsers: [],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC,
}), }),
emptyWithInvalidThumbnail: Object.freeze<AlbumEntity>({ emptyWithInvalidThumbnail: Object.freeze<AlbumEntity>({
id: 'album-5', id: 'album-5',
@ -115,6 +121,7 @@ export const albumStub = {
sharedLinks: [], sharedLinks: [],
sharedUsers: [], sharedUsers: [],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC,
}), }),
emptyWithValidThumbnail: Object.freeze<AlbumEntity>({ emptyWithValidThumbnail: Object.freeze<AlbumEntity>({
id: 'album-5', id: 'album-5',
@ -131,6 +138,7 @@ export const albumStub = {
sharedLinks: [], sharedLinks: [],
sharedUsers: [], sharedUsers: [],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC,
}), }),
oneAssetInvalidThumbnail: Object.freeze<AlbumEntity>({ oneAssetInvalidThumbnail: Object.freeze<AlbumEntity>({
id: 'album-6', id: 'album-6',
@ -147,6 +155,7 @@ export const albumStub = {
sharedLinks: [], sharedLinks: [],
sharedUsers: [], sharedUsers: [],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC,
}), }),
oneAssetValidThumbnail: Object.freeze<AlbumEntity>({ oneAssetValidThumbnail: Object.freeze<AlbumEntity>({
id: 'album-6', id: 'album-6',
@ -163,5 +172,6 @@ export const albumStub = {
sharedLinks: [], sharedLinks: [],
sharedUsers: [], sharedUsers: [],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC,
}), }),
}; };

View File

@ -1,5 +1,5 @@
import { AlbumResponseDto, AssetResponseDto, ExifResponseDto, mapUser, SharedLinkResponseDto } from '@app/domain'; import { AlbumResponseDto, AssetResponseDto, ExifResponseDto, mapUser, SharedLinkResponseDto } from '@app/domain';
import { AssetType, SharedLinkEntity, SharedLinkType, UserEntity } from '@app/infra/entities'; import { AssetOrder, AssetType, SharedLinkEntity, SharedLinkType, UserEntity } from '@app/infra/entities';
import { assetStub } from './asset.stub'; import { assetStub } from './asset.stub';
import { authStub } from './auth.stub'; import { authStub } from './auth.stub';
import { libraryStub } from './library.stub'; import { libraryStub } from './library.stub';
@ -101,6 +101,7 @@ const albumResponse: AlbumResponseDto = {
assets: [], assets: [],
assetCount: 1, assetCount: 1,
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC,
}; };
export const sharedLinkStub = { export const sharedLinkStub = {
@ -181,6 +182,7 @@ export const sharedLinkStub = {
sharedUsers: [], sharedUsers: [],
sharedLinks: [], sharedLinks: [],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC,
assets: [ assets: [
{ {
id: 'id_1', id: 'id_1',

View File

@ -1,22 +1,55 @@
<script lang="ts"> <script lang="ts">
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import type { AlbumResponseDto, UserResponseDto } from '@immich/sdk'; import { updateAlbumInfo, type AlbumResponseDto, type UserResponseDto, AssetOrder } from '@immich/sdk';
import { mdiClose, mdiPlus } from '@mdi/js'; import { mdiArrowDownThin, mdiArrowUpThin, mdiClose, mdiPlus } from '@mdi/js';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte'; import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingDropdown from '../shared-components/settings/setting-dropdown.svelte';
import type { RenderedOption } from '../elements/dropdown.svelte';
import { handleError } from '$lib/utils/handle-error';
import { findKey } from 'lodash-es';
export let album: AlbumResponseDto; export let album: AlbumResponseDto;
export let order: AssetOrder | undefined;
export let user: UserResponseDto; export let user: UserResponseDto;
export let onChangeOrder: (order: AssetOrder) => void;
const options: Record<AssetOrder, RenderedOption> = {
[AssetOrder.Asc]: { icon: mdiArrowUpThin, title: 'Oldest first' },
[AssetOrder.Desc]: { icon: mdiArrowDownThin, title: 'Newest first' },
};
$: selectedOption = order ? options[order] : options[AssetOrder.Desc];
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
close: void; close: void;
toggleEnableActivity: void; toggleEnableActivity: void;
showSelectSharedUser: void; showSelectSharedUser: void;
}>(); }>();
const handleToggle = async (returnedOption: RenderedOption) => {
if (selectedOption === returnedOption) {
return;
}
let order = AssetOrder.Desc;
order = findKey(options, (option) => option === returnedOption) as AssetOrder;
try {
await updateAlbumInfo({
id: album.id,
updateAlbumDto: {
order,
},
});
onChangeOrder(order);
} catch (error) {
handleError(error, 'Error updating album order');
}
};
</script> </script>
<FullScreenModal onClose={() => dispatch('close')}> <FullScreenModal onClose={() => dispatch('close')}>
@ -34,8 +67,16 @@
<div class=" items-center justify-center p-4"> <div class=" items-center justify-center p-4">
<div class="py-2"> <div class="py-2">
<h2 class="text-gray text-sm mb-3">SHARING</h2> <h2 class="text-gray text-sm mb-2">SETTINGS</h2>
<div class="p-2"> <div class="grid p-2 gap-y-2">
{#if order}
<SettingDropdown
title="Display order"
options={Object.values(options)}
selectedOption={options[order]}
onToggle={handleToggle}
/>
{/if}
<SettingSwitch <SettingSwitch
title="Comments & likes" title="Comments & likes"
subtitle="Let others respond" subtitle="Let others respond"

View File

@ -0,0 +1,68 @@
<script lang="ts">
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
import Dropdown from '$lib/components/elements/dropdown.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import { AlbumViewMode, albumViewSettings } from '$lib/stores/preferences.store';
import {
mdiArrowDownThin,
mdiArrowUpThin,
mdiFormatListBulletedSquare,
mdiPlusBoxOutline,
mdiViewGridOutline,
} from '@mdi/js';
import { sortByOptions, type Sort, handleCreateAlbum } from '$lib/components/album-page/albums-list.svelte';
import SearchBar from '$lib/components/elements/search-bar.svelte';
export let searchAlbum: string;
const searchSort = (searched: string): Sort => {
return sortByOptions.find((option) => option.title === searched) || sortByOptions[0];
};
const handleChangeListMode = () => {
$albumViewSettings.view =
$albumViewSettings.view === AlbumViewMode.Cover ? AlbumViewMode.List : AlbumViewMode.Cover;
};
</script>
<div class="hidden lg:block lg:w-40 xl:w-60 2xl:w-80 h-10">
<SearchBar placeholder="Search albums" bind:name={searchAlbum} isSearching={false} />
</div>
<LinkButton on:click={handleCreateAlbum}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiPlusBoxOutline} size="18" />
Create album
</div>
</LinkButton>
<Dropdown
options={Object.values(sortByOptions)}
selectedOption={searchSort($albumViewSettings.sortBy)}
render={(option) => {
return {
title: option.title,
icon: option.sortDesc ? mdiArrowDownThin : mdiArrowUpThin,
};
}}
on:select={(event) => {
for (const key of sortByOptions) {
if (key.title === event.detail.title) {
key.sortDesc = !key.sortDesc;
$albumViewSettings.sortBy = key.title;
break;
}
}
}}
/>
<LinkButton on:click={() => handleChangeListMode()}>
<div class="flex place-items-center gap-2 text-sm">
{#if $albumViewSettings.view === AlbumViewMode.List}
<Icon path={mdiViewGridOutline} size="18" />
<p class="hidden sm:block">Cover</p>
{:else}
<Icon path={mdiFormatListBulletedSquare} size="18" />
<p class="hidden sm:block">List</p>
{/if}
</div>
</LinkButton>

View File

@ -0,0 +1,282 @@
<script lang="ts" context="module">
import { AlbumViewMode, albumViewSettings } from '$lib/stores/preferences.store';
import { goto } from '$app/navigation';
import type { OnShowContextMenuDetail } from '$lib/components/album-page/album-card';
import { AppRoute } from '$lib/constants';
import { createAlbum, deleteAlbum, type AlbumResponseDto } from '@immich/sdk';
import { get } from 'svelte/store';
export const handleCreateAlbum = async () => {
try {
const newAlbum = await createAlbum({ createAlbumDto: { albumName: '' } });
await goto(`${AppRoute.ALBUMS}/${newAlbum.id}`);
} catch (error) {
handleError(error, 'Unable to create album');
}
};
export interface Sort {
title: string;
sortDesc: boolean;
widthClass: string;
sortFn: (reverse: boolean, albums: AlbumResponseDto[]) => AlbumResponseDto[];
}
export let sortByOptions: Sort[] = [
{
title: 'Album title',
sortDesc: get(albumViewSettings).sortDesc, // Load Sort Direction
widthClass: 'text-left w-8/12 sm:w-4/12 md:w-4/12 md:w-4/12 xl:w-[30%] 2xl:w-[40%]',
sortFn: (reverse, albums) => {
return orderBy(albums, 'albumName', [reverse ? 'desc' : 'asc']);
},
},
{
title: 'Number of assets',
sortDesc: get(albumViewSettings).sortDesc,
widthClass: 'text-center w-4/12 m:w-2/12 md:w-2/12 xl:w-[15%] 2xl:w-[12%]',
sortFn: (reverse, albums) => {
return orderBy(albums, 'assetCount', [reverse ? 'desc' : 'asc']);
},
},
{
title: 'Last modified',
sortDesc: get(albumViewSettings).sortDesc,
widthClass: 'text-center hidden sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]',
sortFn: (reverse, albums) => {
return orderBy(albums, [(album) => new Date(album.updatedAt)], [reverse ? 'desc' : 'asc']);
},
},
{
title: 'Created date',
sortDesc: get(albumViewSettings).sortDesc,
widthClass: 'text-center hidden sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]',
sortFn: (reverse, albums) => {
return orderBy(albums, [(album) => new Date(album.createdAt)], [reverse ? 'desc' : 'asc']);
},
},
{
title: 'Most recent photo',
sortDesc: get(albumViewSettings).sortDesc,
widthClass: 'text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]',
sortFn: (reverse, albums) => {
return orderBy(
albums,
[(album) => (album.endDate ? new Date(album.endDate) : '')],
[reverse ? 'desc' : 'asc'],
).sort((a, b) => {
if (a.endDate === undefined) {
return 1;
}
if (b.endDate === undefined) {
return -1;
}
return 0;
});
},
},
{
title: 'Oldest photo',
sortDesc: get(albumViewSettings).sortDesc,
widthClass: 'text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]',
sortFn: (reverse, albums) => {
return orderBy(
albums,
[(album) => (album.startDate ? new Date(album.startDate) : null)],
[reverse ? 'desc' : 'asc'],
).sort((a, b) => {
if (a.startDate === undefined) {
return 1;
}
if (b.startDate === undefined) {
return -1;
}
return 0;
});
},
},
];
</script>
<script lang="ts">
import AlbumCard from '$lib/components/album-page/album-card.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import EditAlbumForm from '$lib/components/forms/edit-album-form.svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { mdiDeleteOutline } from '@mdi/js';
import { orderBy } from 'lodash-es';
import { onMount } from 'svelte';
import { flip } from 'svelte/animate';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
import AlbumsTable from '$lib/components/album-page/albums-table.svelte';
import { handleError } from '$lib/utils/handle-error';
export let albums: AlbumResponseDto[];
export let searchAlbum: string;
let shouldShowEditAlbumForm = false;
let selectedAlbum: AlbumResponseDto;
let albumToDelete: AlbumResponseDto | null;
let contextMenuPosition: OnShowContextMenuDetail = { x: 0, y: 0 };
let contextMenuTargetAlbum: AlbumResponseDto | undefined = undefined;
$: {
for (const key of sortByOptions) {
if (key.title === $albumViewSettings.sortBy) {
albums = key.sortFn(key.sortDesc, albums);
$albumViewSettings.sortDesc = key.sortDesc; // "Save" sortDesc
$albumViewSettings.sortBy = key.title;
break;
}
}
}
$: isShowContextMenu = !!contextMenuTargetAlbum;
$: albumsFiltered = albums.filter((album) => album.albumName.toLowerCase().includes(searchAlbum.toLowerCase()));
onMount(async () => {
await removeAlbumsIfEmpty();
});
function showAlbumContextMenu(contextMenuDetail: OnShowContextMenuDetail, album: AlbumResponseDto): void {
contextMenuTargetAlbum = album;
contextMenuPosition = {
x: contextMenuDetail.x,
y: contextMenuDetail.y,
};
}
function closeAlbumContextMenu() {
contextMenuTargetAlbum = undefined;
}
async function handleDeleteAlbum(albumToDelete: AlbumResponseDto): Promise<void> {
await deleteAlbum({ id: albumToDelete.id });
albums = albums.filter(({ id }) => id !== albumToDelete.id);
}
const chooseAlbumToDelete = (album: AlbumResponseDto) => {
contextMenuTargetAlbum = album;
setAlbumToDelete();
};
const setAlbumToDelete = () => {
albumToDelete = contextMenuTargetAlbum ?? null;
closeAlbumContextMenu();
};
const handleEdit = (album: AlbumResponseDto) => {
selectedAlbum = { ...album };
shouldShowEditAlbumForm = true;
};
const deleteSelectedAlbum = async () => {
if (!albumToDelete) {
return;
}
try {
await handleDeleteAlbum(albumToDelete);
} catch {
notificationController.show({
message: 'Error deleting album',
type: NotificationType.Error,
});
} finally {
albumToDelete = null;
}
};
const removeAlbumsIfEmpty = async () => {
for (const album of albums) {
if (album.assetCount == 0 && album.albumName == '') {
try {
await handleDeleteAlbum(album);
} catch (error) {
console.log(error);
}
}
}
};
const successModifyAlbum = () => {
shouldShowEditAlbumForm = false;
notificationController.show({
message: 'Album infos updated',
type: NotificationType.Info,
});
albums[albums.findIndex((x) => x.id === selectedAlbum.id)] = selectedAlbum;
};
</script>
{#if shouldShowEditAlbumForm}
<FullScreenModal onClose={() => (shouldShowEditAlbumForm = false)}>
<EditAlbumForm
album={selectedAlbum}
on:editSuccess={() => successModifyAlbum()}
on:cancel={() => (shouldShowEditAlbumForm = false)}
/>
</FullScreenModal>
{/if}
{#if albums.length > 0}
<!-- Album Card -->
{#if $albumViewSettings.view === AlbumViewMode.Cover}
<div class="grid grid-cols-[repeat(auto-fill,minmax(14rem,1fr))]">
{#each albumsFiltered as album, index (album.id)}
<a data-sveltekit-preload-data="hover" href="{AppRoute.ALBUMS}/{album.id}" animate:flip={{ duration: 200 }}>
<AlbumCard
preload={index < 20}
{album}
on:showalbumcontextmenu={({ detail }) => showAlbumContextMenu(detail, album)}
/>
</a>
{/each}
</div>
{:else if $albumViewSettings.view === AlbumViewMode.List}
<AlbumsTable
{sortByOptions}
{albumsFiltered}
onChooseAlbumToDelete={(album) => chooseAlbumToDelete(album)}
onAlbumToEdit={(album) => handleEdit(album)}
/>
{/if}
<!-- Empty Message -->
{:else}
<EmptyPlaceholder text="Create an album to organize your photos and videos" onClick={handleCreateAlbum} />
{/if}
<!-- Context Menu -->
{#if isShowContextMenu}
<section class="fixed left-0 top-0 z-10 flex h-screen w-screen">
<ContextMenu {...contextMenuPosition} on:outclick={closeAlbumContextMenu} on:escape={closeAlbumContextMenu}>
<MenuOption on:click={() => setAlbumToDelete()}>
<span class="flex place-content-center place-items-center gap-2">
<Icon path={mdiDeleteOutline} size="18" />
<p>Delete album</p>
</span>
</MenuOption>
</ContextMenu>
</section>
{/if}
{#if albumToDelete}
<ConfirmDialogue
title="Delete Album"
confirmText="Delete"
onConfirm={deleteSelectedAlbum}
onClose={() => (albumToDelete = null)}
>
<svelte:fragment slot="prompt">
<p>Are you sure you want to delete the album <b>{albumToDelete.albumName}</b>?</p>
<p>If this album is shared, other users will not be able to access it anymore.</p>
</svelte:fragment>
</ConfirmDialogue>
{/if}

View File

@ -1,14 +1,15 @@
<script lang="ts"> <script lang="ts">
import type { Sort } from '../../../routes/(user)/albums/+page.svelte'; import { albumViewSettings } from '$lib/stores/preferences.store';
import type { Sort } from '$lib/components/album-page/albums-list.svelte';
export let albumViewSettings: string;
export let option: Sort; export let option: Sort;
const handleSort = () => { const handleSort = () => {
if (albumViewSettings === option.title) { if ($albumViewSettings.sortBy === option.title) {
$albumViewSettings.sortDesc = !option.sortDesc;
option.sortDesc = !option.sortDesc; option.sortDesc = !option.sortDesc;
} else { } else {
albumViewSettings = option.title; $albumViewSettings.sortBy = option.title;
} }
}; };
</script> </script>
@ -18,7 +19,7 @@
class="rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50" class="rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50"
on:click={() => handleSort()} on:click={() => handleSort()}
> >
{#if albumViewSettings === option.title} {#if $albumViewSettings.sortBy === option.title}
{#if option.sortDesc} {#if option.sortDesc}
&#8595; &#8595;
{:else} {:else}

View File

@ -0,0 +1,87 @@
<script lang="ts">
import { AppRoute } from '$lib/constants';
import type { AlbumResponseDto } from '@immich/sdk';
import TableHeader from '$lib/components/album-page/albums-table-header.svelte';
import { goto } from '$app/navigation';
import Icon from '$lib/components/elements/icon.svelte';
import { mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js';
import type { Sort } from '$lib/components/album-page/albums-list.svelte';
import { locale } from '$lib/stores/preferences.store';
import { dateFormats } from '$lib/constants';
export let albumsFiltered: AlbumResponseDto[];
export let sortByOptions: Sort[];
export let onChooseAlbumToDelete: (album: AlbumResponseDto) => void;
export let onAlbumToEdit: (album: AlbumResponseDto) => void;
const dateLocaleString = (dateString: string) => {
return new Date(dateString).toLocaleDateString($locale, dateFormats.album);
};
</script>
<table class="mt-2 w-full text-left">
<thead
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
>
<tr class="flex w-full place-items-center p-2 md:p-5">
{#each sortByOptions as option, index (index)}
<TableHeader {option} />
{/each}
<th class="hidden text-center text-sm font-medium 2xl:block 2xl:w-[12%]">Action</th>
</tr>
</thead>
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg">
{#each albumsFiltered as album (album.id)}
<tr
class="flex h-[50px] w-full place-items-center border-[3px] border-transparent p-2 text-center odd:bg-immich-gray even:bg-immich-bg hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5"
on:click={() => goto(`${AppRoute.ALBUMS}/${album.id}`)}
on:keydown={(event) => event.key === 'Enter' && goto(`${AppRoute.ALBUMS}/${album.id}`)}
tabindex="0"
>
<a data-sveltekit-preload-data="hover" class="flex w-full" href="{AppRoute.ALBUMS}/{album.id}">
<td class="text-md text-ellipsis text-left w-8/12 sm:w-4/12 md:w-4/12 xl:w-[30%] 2xl:w-[40%]"
>{album.albumName}</td
>
<td class="text-md text-ellipsis text-center sm:w-2/12 md:w-2/12 xl:w-[15%] 2xl:w-[12%]">
{album.assetCount}
{album.assetCount > 1 ? `items` : `item`}
</td>
<td class="text-md hidden text-ellipsis text-center sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]"
>{dateLocaleString(album.updatedAt)}
</td>
<td class="text-md hidden text-ellipsis text-center sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]"
>{dateLocaleString(album.createdAt)}</td
>
<td class="text-md text-ellipsis text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]">
{#if album.endDate}
{dateLocaleString(album.endDate)}
{:else}
&#10060;
{/if}</td
>
<td class="text-md text-ellipsis text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]"
>{#if album.startDate}
{dateLocaleString(album.startDate)}
{:else}
&#10060;
{/if}</td
>
</a>
<td class="text-md hidden text-ellipsis text-center 2xl:block xl:w-[15%] 2xl:w-[12%]">
<button
on:click|stopPropagation={() => onAlbumToEdit(album)}
class="rounded-full z-1 bg-immich-primary p-3 text-gray-100 transition-all duration-150 hover:bg-immich-primary/75 dark:bg-immich-dark-primary dark:text-gray-700"
>
<Icon path={mdiPencilOutline} size="16" />
</button>
<button
on:click|stopPropagation={() => onChooseAlbumToDelete(album)}
class="rounded-full z-1 bg-immich-primary p-3 text-gray-100 transition-all duration-150 hover:bg-immich-primary/75 dark:bg-immich-dark-primary dark:text-gray-700"
>
<Icon path={mdiTrashCanOutline} size="16" />
</button>
</td>
</tr>
{/each}
</tbody>
</table>

View File

@ -20,7 +20,7 @@
[SlideshowNavigation.DescendingOrder]: { icon: mdiArrowDownThin, title: 'Forward' }, [SlideshowNavigation.DescendingOrder]: { icon: mdiArrowDownThin, title: 'Forward' },
}; };
export const handleToggle = (selectedOption: RenderedOption) => { const handleToggle = (selectedOption: RenderedOption) => {
for (const [key, option] of Object.entries(options)) { for (const [key, option] of Object.entries(options)) {
if (option === selectedOption) { if (option === selectedOption) {
$slideshowNavigation = key as SlideshowNavigation; $slideshowNavigation = key as SlideshowNavigation;

View File

@ -55,7 +55,7 @@
<ChangePasswordSettings /> <ChangePasswordSettings />
</SettingAccordion> </SettingAccordion>
<SettingAccordion key="sharing" title="Sharing" subtitle="Manage sharing with partners"> <SettingAccordion key="partner-sharing" title="Partner Sharing" subtitle="Manage sharing with partners">
<PartnerSettings user={$user} /> <PartnerSettings user={$user} />
</SettingAccordion> </SettingAccordion>
</SettingAccordionState> </SettingAccordionState>

View File

@ -161,7 +161,10 @@ export class AssetStore {
this.assetToBucket = {}; this.assetToBucket = {};
this.albumAssets = new Set(); this.albumAssets = new Set();
const buckets = await getTimeBuckets({ ...this.options, key: getKey() }); const buckets = await getTimeBuckets({
...this.options,
key: getKey(),
});
this.initialized = true; this.initialized = true;

View File

@ -1,406 +1,17 @@
<script lang="ts" context="module">
export interface Sort {
title: string;
sortDesc: boolean;
widthClass: string;
sortFn: (reverse: boolean, albums: AlbumResponseDto[]) => AlbumResponseDto[];
}
</script>
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import AlbumCard from '$lib/components/album-page/album-card.svelte';
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
import Dropdown from '$lib/components/elements/dropdown.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import TableHeader from '$lib/components/elements/table-header.svelte';
import EditAlbumForm from '$lib/components/forms/edit-album-form.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { AppRoute, dateFormats } from '$lib/constants';
import { AlbumViewMode, albumViewSettings, locale } from '$lib/stores/preferences.store';
import type { AlbumResponseDto } from '@immich/sdk';
import {
mdiArrowDownThin,
mdiArrowUpThin,
mdiDeleteOutline,
mdiFormatListBulletedSquare,
mdiPencilOutline,
mdiPlusBoxOutline,
mdiTrashCanOutline,
mdiViewGridOutline,
} from '@mdi/js';
import { orderBy } from 'lodash-es';
import { onMount } from 'svelte';
import { flip } from 'svelte/animate';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { useAlbums } from './albums.bloc'; import AlbumsControls from '$lib/components/album-page/albums-controls.svelte';
import SearchBar from '$lib/components/elements/search-bar.svelte'; import Albums from '$lib/components/album-page/albums-list.svelte';
export let data: PageData; export let data: PageData;
let shouldShowEditUserForm = false;
let selectedAlbum: AlbumResponseDto;
let searchAlbum = ''; let searchAlbum = '';
let sortByOptions: Record<string, Sort> = {
albumTitle: {
title: 'Album title',
sortDesc: $albumViewSettings.sortDesc, // Load Sort Direction
widthClass: 'text-left w-8/12 sm:w-4/12 md:w-4/12 md:w-4/12 xl:w-[30%] 2xl:w-[40%]',
sortFn: (reverse, albums) => {
return orderBy(albums, 'albumName', [reverse ? 'desc' : 'asc']);
},
},
numberOfAssets: {
title: 'Number of assets',
sortDesc: $albumViewSettings.sortDesc,
widthClass: 'text-center w-4/12 m:w-2/12 md:w-2/12 xl:w-[15%] 2xl:w-[12%]',
sortFn: (reverse, albums) => {
return orderBy(albums, 'assetCount', [reverse ? 'desc' : 'asc']);
},
},
lastModified: {
title: 'Last modified',
sortDesc: $albumViewSettings.sortDesc,
widthClass: 'text-center hidden sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]',
sortFn: (reverse, albums) => {
return orderBy(albums, [(album) => new Date(album.updatedAt)], [reverse ? 'desc' : 'asc']);
},
},
created: {
title: 'Created date',
sortDesc: $albumViewSettings.sortDesc,
widthClass: 'text-center hidden sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]',
sortFn: (reverse, albums) => {
return orderBy(albums, [(album) => new Date(album.createdAt)], [reverse ? 'desc' : 'asc']);
},
},
mostRecent: {
title: 'Most recent photo',
sortDesc: $albumViewSettings.sortDesc,
widthClass: 'text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]',
sortFn: (reverse, albums) => {
return orderBy(
albums,
[(album) => (album.endDate ? new Date(album.endDate) : '')],
[reverse ? 'desc' : 'asc'],
).sort((a, b) => {
if (a.endDate === undefined) {
return 1;
}
if (b.endDate === undefined) {
return -1;
}
return 0;
});
},
},
mostOld: {
title: 'Oldest photo',
sortDesc: $albumViewSettings.sortDesc,
widthClass: 'text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]',
sortFn: (reverse, albums) => {
return orderBy(
albums,
[(album) => (album.startDate ? new Date(album.startDate) : null)],
[reverse ? 'desc' : 'asc'],
).sort((a, b) => {
if (a.startDate === undefined) {
return 1;
}
if (b.startDate === undefined) {
return -1;
}
return 0;
});
},
},
};
const handleEdit = (album: AlbumResponseDto) => {
selectedAlbum = { ...album };
shouldShowEditUserForm = true;
};
const {
albums: unsortedAlbums,
isShowContextMenu,
contextMenuPosition,
contextMenuTargetAlbum,
createAlbum,
deleteAlbum,
showAlbumContextMenu,
closeAlbumContextMenu,
} = useAlbums({ albums: data.albums });
let albums = unsortedAlbums;
let albumToDelete: AlbumResponseDto | null;
const chooseAlbumToDelete = (album: AlbumResponseDto) => {
$contextMenuTargetAlbum = album;
setAlbumToDelete();
};
const setAlbumToDelete = () => {
albumToDelete = $contextMenuTargetAlbum ?? null;
closeAlbumContextMenu();
};
const deleteSelectedAlbum = async () => {
if (!albumToDelete) {
return;
}
try {
await deleteAlbum(albumToDelete);
} catch {
notificationController.show({
message: 'Error deleting album',
type: NotificationType.Error,
});
} finally {
albumToDelete = null;
}
};
$: {
for (const key in sortByOptions) {
if (sortByOptions[key].title === $albumViewSettings.sortBy) {
$albums = sortByOptions[key].sortFn(sortByOptions[key].sortDesc, $unsortedAlbums);
$albumViewSettings.sortDesc = sortByOptions[key].sortDesc; // "Save" sortDesc
$albumViewSettings.sortBy = sortByOptions[key].title;
break;
}
}
}
$: albumsFiltered = $albums.filter((album) => album.albumName.toLowerCase().includes(searchAlbum.toLowerCase()));
const searchSort = (searched: string): Sort => {
for (const key in sortByOptions) {
if (sortByOptions[key].title === searched) {
return sortByOptions[key];
}
}
return sortByOptions[0];
};
const handleCreateAlbum = async () => {
const newAlbum = await createAlbum();
if (newAlbum) {
await goto(`${AppRoute.ALBUMS}/${newAlbum.id}`);
}
};
const dateLocaleString = (dateString: string) => {
return new Date(dateString).toLocaleDateString($locale, dateFormats.album);
};
onMount(async () => {
await removeAlbumsIfEmpty();
});
const removeAlbumsIfEmpty = async () => {
try {
for (const album of $albums) {
if (album.assetCount == 0 && album.albumName == '') {
await deleteAlbum(album);
}
}
} catch (error) {
console.log(error);
}
};
const successModifyAlbum = () => {
shouldShowEditUserForm = false;
notificationController.show({
message: 'Album infos updated',
type: NotificationType.Info,
});
$albums[$albums.findIndex((x) => x.id === selectedAlbum.id)] = selectedAlbum;
};
const handleChangeListMode = () => {
$albumViewSettings.view =
$albumViewSettings.view === AlbumViewMode.Cover ? AlbumViewMode.List : AlbumViewMode.Cover;
};
</script> </script>
{#if shouldShowEditUserForm}
<FullScreenModal onClose={() => (shouldShowEditUserForm = false)}>
<EditAlbumForm
album={selectedAlbum}
on:editSuccess={() => successModifyAlbum()}
on:cancel={() => (shouldShowEditUserForm = false)}
/>
</FullScreenModal>
{/if}
<UserPageLayout title={data.meta.title}> <UserPageLayout title={data.meta.title}>
<div class="flex place-items-center gap-2" slot="buttons"> <div class="flex place-items-center gap-2" slot="buttons">
<div class="hidden lg:block lg:w-40 xl:w-60 2xl:w-80 h-10"> <AlbumsControls bind:searchAlbum />
<SearchBar placeholder="Search albums" bind:name={searchAlbum} isSearching={false} />
</div> </div>
<LinkButton on:click={handleCreateAlbum}> <Albums albums={data.albums} {searchAlbum} />
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiPlusBoxOutline} size="18" />
Create album
</div>
</LinkButton>
<Dropdown
options={Object.values(sortByOptions)}
selectedOption={searchSort($albumViewSettings.sortBy)}
render={(option) => {
return {
title: option.title,
icon: option.sortDesc ? mdiArrowDownThin : mdiArrowUpThin,
};
}}
on:select={(event) => {
for (const key in sortByOptions) {
if (sortByOptions[key].title === event.detail.title) {
sortByOptions[key].sortDesc = !sortByOptions[key].sortDesc;
$albumViewSettings.sortBy = sortByOptions[key].title;
}
}
}}
/>
<LinkButton on:click={() => handleChangeListMode()}>
<div class="flex place-items-center gap-2 text-sm">
{#if $albumViewSettings.view === AlbumViewMode.List}
<Icon path={mdiViewGridOutline} size="18" />
<p class="hidden sm:block">Cover</p>
{:else}
<Icon path={mdiFormatListBulletedSquare} size="18" />
<p class="hidden sm:block">List</p>
{/if}
</div>
</LinkButton>
</div>
{#if $albums.length > 0}
<!-- Album Card -->
{#if $albumViewSettings.view === AlbumViewMode.Cover}
<div class="grid grid-cols-[repeat(auto-fill,minmax(14rem,1fr))]">
{#each albumsFiltered as album, index (album.id)}
<a data-sveltekit-preload-data="hover" href="{AppRoute.ALBUMS}/{album.id}" animate:flip={{ duration: 200 }}>
<AlbumCard
preload={index < 20}
{album}
on:showalbumcontextmenu={(e) => showAlbumContextMenu(e.detail, album)}
/>
</a>
{/each}
</div>
{:else if $albumViewSettings.view === AlbumViewMode.List}
<table class="mt-2 w-full text-left">
<thead
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
>
<tr class="flex w-full place-items-center p-2 md:p-5">
{#each Object.keys(sortByOptions) as key (key)}
<TableHeader bind:albumViewSettings={$albumViewSettings.sortBy} bind:option={sortByOptions[key]} />
{/each}
<th class="hidden text-center text-sm font-medium 2xl:block 2xl:w-[12%]">Action</th>
</tr>
</thead>
<tbody
class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg"
>
{#each albumsFiltered as album (album.id)}
<tr
class="flex h-[50px] w-full place-items-center border-[3px] border-transparent p-2 text-center odd:bg-immich-gray even:bg-immich-bg hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5"
on:click={() => goto(`${AppRoute.ALBUMS}/${album.id}`)}
on:keydown={(event) => event.key === 'Enter' && goto(`${AppRoute.ALBUMS}/${album.id}`)}
tabindex="0"
>
<a data-sveltekit-preload-data="hover" class="flex w-full" href="{AppRoute.ALBUMS}/{album.id}">
<td class="text-md text-ellipsis text-left w-8/12 sm:w-4/12 md:w-4/12 xl:w-[30%] 2xl:w-[40%]"
>{album.albumName}</td
>
<td class="text-md text-ellipsis text-center sm:w-2/12 md:w-2/12 xl:w-[15%] 2xl:w-[12%]">
{album.assetCount}
{album.assetCount > 1 ? `items` : `item`}
</td>
<td class="text-md hidden text-ellipsis text-center sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]"
>{dateLocaleString(album.updatedAt)}
</td>
<td class="text-md hidden text-ellipsis text-center sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]"
>{dateLocaleString(album.createdAt)}</td
>
<td class="text-md text-ellipsis text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]">
{#if album.endDate}
{dateLocaleString(album.endDate)}
{:else}
&#10060;
{/if}</td
>
<td class="text-md text-ellipsis text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]"
>{#if album.startDate}
{dateLocaleString(album.startDate)}
{:else}
&#10060;
{/if}</td
>
</a>
<td class="text-md hidden text-ellipsis text-center 2xl:block xl:w-[15%] 2xl:w-[12%]">
<button
on:click|stopPropagation={() => handleEdit(album)}
class="rounded-full z-1 bg-immich-primary p-3 text-gray-100 transition-all duration-150 hover:bg-immich-primary/75 dark:bg-immich-dark-primary dark:text-gray-700"
>
<Icon path={mdiPencilOutline} size="16" />
</button>
<button
on:click|stopPropagation={() => chooseAlbumToDelete(album)}
class="rounded-full z-1 bg-immich-primary p-3 text-gray-100 transition-all duration-150 hover:bg-immich-primary/75 dark:bg-immich-dark-primary dark:text-gray-700"
>
<Icon path={mdiTrashCanOutline} size="16" />
</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
<!-- Empty Message -->
{:else}
<EmptyPlaceholder text="Create an album to organize your photos and videos" onClick={handleCreateAlbum} />
{/if}
</UserPageLayout> </UserPageLayout>
<!-- Context Menu -->
{#if $isShowContextMenu}
<ContextMenu {...$contextMenuPosition} on:outclick={closeAlbumContextMenu} on:escape={closeAlbumContextMenu}>
<MenuOption on:click={() => setAlbumToDelete()}>
<span class="flex place-content-center place-items-center gap-2">
<Icon path={mdiDeleteOutline} size="18" />
<p>Delete album</p>
</span>
</MenuOption>
</ContextMenu>
{/if}
{#if albumToDelete}
<ConfirmDialogue
title="Delete Album"
confirmText="Delete"
onConfirm={deleteSelectedAlbum}
onClose={() => (albumToDelete = null)}
>
<svelte:fragment slot="prompt">
<p>Are you sure you want to delete the album <b>{albumToDelete.albumName}</b>?</p>
<p>If this album is shared, other users will not be able to access it anymore.</p>
</svelte:fragment>
</ConfirmDialogue>
{/if}

View File

@ -58,6 +58,7 @@
updateAlbumInfo, updateAlbumInfo,
type ActivityResponseDto, type ActivityResponseDto,
type UserResponseDto, type UserResponseDto,
AssetOrder,
} from '@immich/sdk'; } from '@immich/sdk';
import { import {
mdiArrowLeft, mdiArrowLeft,
@ -83,6 +84,7 @@
$: album = data.album; $: album = data.album;
$: albumId = album.id; $: albumId = album.id;
$: albumKey = `${albumId}_${albumOrder}`;
$: { $: {
if (!album.isActivityEnabled && $numberOfComments === 0) { if (!album.isActivityEnabled && $numberOfComments === 0) {
@ -112,8 +114,9 @@
let globalWidth: number; let globalWidth: number;
let assetGridWidth: number; let assetGridWidth: number;
let textArea: HTMLTextAreaElement; let textArea: HTMLTextAreaElement;
let albumOrder: AssetOrder | undefined = data.album.order;
$: assetStore = new AssetStore({ albumId }); $: assetStore = new AssetStore({ albumId, order: albumOrder });
const assetInteractionStore = createAssetInteractionStore(); const assetInteractionStore = createAssetInteractionStore();
const { isMultiSelectState, selectedAssets } = assetInteractionStore; const { isMultiSelectState, selectedAssets } = assetInteractionStore;
@ -512,7 +515,7 @@
style={`width:${assetGridWidth}px`} style={`width:${assetGridWidth}px`}
> >
<!-- Use key because AssetGrid can't deal with changing stores --> <!-- Use key because AssetGrid can't deal with changing stores -->
{#key albumId} {#key albumKey}
{#if viewMode === ViewMode.SELECT_ASSETS} {#if viewMode === ViewMode.SELECT_ASSETS}
<AssetGrid <AssetGrid
assetStore={timelineStore} assetStore={timelineStore}
@ -679,7 +682,9 @@
{#if viewMode === ViewMode.OPTIONS && $user} {#if viewMode === ViewMode.OPTIONS && $user}
<AlbumOptions <AlbumOptions
{album} {album}
order={albumOrder}
user={$user} user={$user}
onChangeOrder={(order) => (albumOrder = order)}
on:close={() => (viewMode = ViewMode.VIEW)} on:close={() => (viewMode = ViewMode.VIEW)}
on:toggleEnableActivity={handleToggleEnableActivity} on:toggleEnableActivity={handleToggleEnableActivity}
on:showSelectSharedUser={() => (viewMode = ViewMode.SELECT_USERS)} on:showSelectSharedUser={() => (viewMode = ViewMode.SELECT_USERS)}

View File

@ -1,73 +0,0 @@
import type { OnShowContextMenuDetail } from '$lib/components/album-page/album-card';
import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification';
import { asyncTimeout } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { createAlbum, deleteAlbum, getAllAlbums, type AlbumResponseDto } from '@immich/sdk';
import { derived, get, writable } from 'svelte/store';
type AlbumsProperties = { albums: AlbumResponseDto[] };
export const useAlbums = (properties: AlbumsProperties) => {
const albums = writable([...properties.albums]);
const contextMenuPosition = writable<OnShowContextMenuDetail>({ x: 0, y: 0 });
const contextMenuTargetAlbum = writable<AlbumResponseDto | undefined>();
const isShowContextMenu = derived(contextMenuTargetAlbum, ($selectedAlbum) => !!$selectedAlbum);
async function loadAlbums(): Promise<void> {
try {
const data = await getAllAlbums({});
albums.set(data);
// Delete album that has no photos and is named ''
for (const album of data) {
if (album.albumName === '' && album.assetCount === 0) {
await asyncTimeout(500);
await handleDeleteAlbum(album);
}
}
} catch {
notificationController.show({
message: 'Error loading albums',
type: NotificationType.Error,
});
}
}
async function handleCreateAlbum(): Promise<AlbumResponseDto | undefined> {
try {
return await createAlbum({ createAlbumDto: { albumName: '' } });
} catch (error) {
handleError(error, 'Unable to create album');
}
}
async function handleDeleteAlbum(albumToDelete: AlbumResponseDto): Promise<void> {
await deleteAlbum({ id: albumToDelete.id });
albums.set(get(albums).filter(({ id }) => id !== albumToDelete.id));
}
function showAlbumContextMenu(contextMenuDetail: OnShowContextMenuDetail, album: AlbumResponseDto): void {
contextMenuTargetAlbum.set(album);
contextMenuPosition.set({
x: contextMenuDetail.x,
y: contextMenuDetail.y,
});
}
function closeAlbumContextMenu() {
contextMenuTargetAlbum.set(undefined);
}
return {
albums,
isShowContextMenu,
contextMenuPosition,
contextMenuTargetAlbum,
loadAlbums,
createAlbum: handleCreateAlbum,
deleteAlbum: handleDeleteAlbum,
showAlbumContextMenu,
closeAlbumContextMenu,
};
};

View File

@ -1,5 +1,5 @@
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import type { AlbumResponseDto } from '@immich/sdk'; import { AssetOrder, type AlbumResponseDto } from '@immich/sdk';
import { Sync } from 'factory.ts'; import { Sync } from 'factory.ts';
import { userFactory } from './user-factory'; import { userFactory } from './user-factory';
@ -18,4 +18,5 @@ export const albumFactory = Sync.makeFactory<AlbumResponseDto>({
sharedUsers: [], sharedUsers: [],
hasSharedLink: false, hasSharedLink: false,
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.Desc,
}); });