forked from Cutlery/immich
Merge branch 'main' of https://github.com/immich-app/immich into feat/offline-files-job
This commit is contained in:
commit
d8dd1fbff0
6
.github/workflows/docker.yml
vendored
6
.github/workflows/docker.yml
vendored
@ -67,12 +67,6 @@ jobs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
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
|
||||
# Only push to Docker Hub when making a release
|
||||
|
8
.github/workflows/test.yml
vendored
8
.github/workflows/test.yml
vendored
@ -91,17 +91,13 @@ jobs:
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Run setup typescript-sdk
|
||||
- name: Setup typescript-sdk
|
||||
run: npm ci && npm run build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
|
||||
- name: Run npm install (cli)
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Run npm install (server)
|
||||
run: npm ci
|
||||
working-directory: ./server
|
||||
|
||||
- name: Run linter
|
||||
run: npm run lint
|
||||
if: ${{ !cancelled() }}
|
||||
|
@ -1,11 +1,15 @@
|
||||
**/*.spec.js
|
||||
coverage/**
|
||||
src/**
|
||||
upload/**
|
||||
.editorconfig
|
||||
.eslintignore
|
||||
.eslintrc.js
|
||||
.eslintrc.cjs
|
||||
.gitignore
|
||||
.prettierignore
|
||||
.prettierrc
|
||||
Dockerfile
|
||||
package-lock.json
|
||||
testSetup.js
|
||||
tsconfig.json
|
||||
tsconfig.build.json
|
||||
vite.config.ts
|
||||
vitest.config.ts
|
||||
|
@ -15,19 +15,7 @@
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"rootDirs": ["src", "../server/src"],
|
||||
"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"]
|
||||
},
|
||||
"exclude": ["dist", "node_modules"]
|
||||
|
@ -1,6 +1,7 @@
|
||||
import {
|
||||
AlbumResponseDto,
|
||||
AssetFileUploadResponseDto,
|
||||
AssetOrder,
|
||||
LoginResponseDto,
|
||||
SharedLinkType,
|
||||
deleteUser,
|
||||
@ -353,6 +354,7 @@ describe('/album', () => {
|
||||
assetCount: 0,
|
||||
owner: expect.objectContaining({ email: user1.userEmail }),
|
||||
isActivityEnabled: true,
|
||||
order: AssetOrder.Desc,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
1
mobile/openapi/doc/AlbumResponseDto.md
generated
1
mobile/openapi/doc/AlbumResponseDto.md
generated
@ -19,6 +19,7 @@ Name | Type | Description | Notes
|
||||
**id** | **String** | |
|
||||
**isActivityEnabled** | **bool** | |
|
||||
**lastModifiedAssetTimestamp** | [**DateTime**](DateTime.md) | | [optional]
|
||||
**order** | [**AssetOrder**](AssetOrder.md) | | [optional]
|
||||
**owner** | [**UserResponseDto**](UserResponseDto.md) | |
|
||||
**ownerId** | **String** | |
|
||||
**shared** | **bool** | |
|
||||
|
12
mobile/openapi/doc/AssetApi.md
generated
12
mobile/openapi/doc/AssetApi.md
generated
@ -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)
|
||||
|
||||
# **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 isTrashed = true; // bool |
|
||||
final key = key_example; // String |
|
||||
final order = ; // AssetOrder |
|
||||
final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
|
||||
final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
|
||||
final withPartners = true; // bool |
|
||||
final withStacked = true; // bool |
|
||||
|
||||
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);
|
||||
} catch (e) {
|
||||
print('Exception when calling AssetApi->getTimeBucket: $e\n');
|
||||
@ -888,6 +889,7 @@ Name | Type | Description | Notes
|
||||
**isFavorite** | **bool**| | [optional]
|
||||
**isTrashed** | **bool**| | [optional]
|
||||
**key** | **String**| | [optional]
|
||||
**order** | [**AssetOrder**](.md)| | [optional]
|
||||
**personId** | **String**| | [optional]
|
||||
**userId** | **String**| | [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)
|
||||
|
||||
# **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 isTrashed = true; // bool |
|
||||
final key = key_example; // String |
|
||||
final order = ; // AssetOrder |
|
||||
final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
|
||||
final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
|
||||
final withPartners = true; // bool |
|
||||
final withStacked = true; // bool |
|
||||
|
||||
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);
|
||||
} catch (e) {
|
||||
print('Exception when calling AssetApi->getTimeBuckets: $e\n');
|
||||
@ -961,6 +964,7 @@ Name | Type | Description | Notes
|
||||
**isFavorite** | **bool**| | [optional]
|
||||
**isTrashed** | **bool**| | [optional]
|
||||
**key** | **String**| | [optional]
|
||||
**order** | [**AssetOrder**](.md)| | [optional]
|
||||
**personId** | **String**| | [optional]
|
||||
**userId** | **String**| | [optional]
|
||||
**withPartners** | **bool**| | [optional]
|
||||
|
1
mobile/openapi/doc/UpdateAlbumDto.md
generated
1
mobile/openapi/doc/UpdateAlbumDto.md
generated
@ -12,6 +12,7 @@ Name | Type | Description | Notes
|
||||
**albumThumbnailAssetId** | **String** | | [optional]
|
||||
**description** | **String** | | [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)
|
||||
|
||||
|
26
mobile/openapi/lib/api/asset_api.dart
generated
26
mobile/openapi/lib/api/asset_api.dart
generated
@ -852,6 +852,8 @@ class AssetApi {
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [AssetOrder] order:
|
||||
///
|
||||
/// * [String] personId:
|
||||
///
|
||||
/// * [String] userId:
|
||||
@ -859,7 +861,7 @@ class AssetApi {
|
||||
/// * [bool] withPartners:
|
||||
///
|
||||
/// * [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
|
||||
final path = r'/asset/time-bucket';
|
||||
|
||||
@ -885,6 +887,9 @@ class AssetApi {
|
||||
if (key != null) {
|
||||
queryParams.addAll(_queryParams('', 'key', key));
|
||||
}
|
||||
if (order != null) {
|
||||
queryParams.addAll(_queryParams('', 'order', order));
|
||||
}
|
||||
if (personId != null) {
|
||||
queryParams.addAll(_queryParams('', 'personId', personId));
|
||||
}
|
||||
@ -930,6 +935,8 @@ class AssetApi {
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [AssetOrder] order:
|
||||
///
|
||||
/// * [String] personId:
|
||||
///
|
||||
/// * [String] userId:
|
||||
@ -937,8 +944,8 @@ class AssetApi {
|
||||
/// * [bool] withPartners:
|
||||
///
|
||||
/// * [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 {
|
||||
final response = await getTimeBucketWithHttpInfo(size, timeBucket, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, );
|
||||
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, order: order, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
@ -970,6 +977,8 @@ class AssetApi {
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [AssetOrder] order:
|
||||
///
|
||||
/// * [String] personId:
|
||||
///
|
||||
/// * [String] userId:
|
||||
@ -977,7 +986,7 @@ class AssetApi {
|
||||
/// * [bool] withPartners:
|
||||
///
|
||||
/// * [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
|
||||
final path = r'/asset/time-buckets';
|
||||
|
||||
@ -1003,6 +1012,9 @@ class AssetApi {
|
||||
if (key != null) {
|
||||
queryParams.addAll(_queryParams('', 'key', key));
|
||||
}
|
||||
if (order != null) {
|
||||
queryParams.addAll(_queryParams('', 'order', order));
|
||||
}
|
||||
if (personId != null) {
|
||||
queryParams.addAll(_queryParams('', 'personId', personId));
|
||||
}
|
||||
@ -1045,6 +1057,8 @@ class AssetApi {
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [AssetOrder] order:
|
||||
///
|
||||
/// * [String] personId:
|
||||
///
|
||||
/// * [String] userId:
|
||||
@ -1052,8 +1066,8 @@ class AssetApi {
|
||||
/// * [bool] withPartners:
|
||||
///
|
||||
/// * [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 {
|
||||
final response = await getTimeBucketsWithHttpInfo(size, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, );
|
||||
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, order: order, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
19
mobile/openapi/lib/model/album_response_dto.dart
generated
19
mobile/openapi/lib/model/album_response_dto.dart
generated
@ -24,6 +24,7 @@ class AlbumResponseDto {
|
||||
required this.id,
|
||||
required this.isActivityEnabled,
|
||||
this.lastModifiedAssetTimestamp,
|
||||
this.order,
|
||||
required this.owner,
|
||||
required this.ownerId,
|
||||
required this.shared,
|
||||
@ -66,6 +67,14 @@ class AlbumResponseDto {
|
||||
///
|
||||
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;
|
||||
|
||||
String ownerId;
|
||||
@ -97,6 +106,7 @@ class AlbumResponseDto {
|
||||
other.id == id &&
|
||||
other.isActivityEnabled == isActivityEnabled &&
|
||||
other.lastModifiedAssetTimestamp == lastModifiedAssetTimestamp &&
|
||||
other.order == order &&
|
||||
other.owner == owner &&
|
||||
other.ownerId == ownerId &&
|
||||
other.shared == shared &&
|
||||
@ -118,6 +128,7 @@ class AlbumResponseDto {
|
||||
(id.hashCode) +
|
||||
(isActivityEnabled.hashCode) +
|
||||
(lastModifiedAssetTimestamp == null ? 0 : lastModifiedAssetTimestamp!.hashCode) +
|
||||
(order == null ? 0 : order!.hashCode) +
|
||||
(owner.hashCode) +
|
||||
(ownerId.hashCode) +
|
||||
(shared.hashCode) +
|
||||
@ -126,7 +137,7 @@ class AlbumResponseDto {
|
||||
(updatedAt.hashCode);
|
||||
|
||||
@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() {
|
||||
final json = <String, dynamic>{};
|
||||
@ -152,6 +163,11 @@ class AlbumResponseDto {
|
||||
json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String();
|
||||
} else {
|
||||
// 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'ownerId'] = this.ownerId;
|
||||
@ -185,6 +201,7 @@ class AlbumResponseDto {
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
isActivityEnabled: mapValueOfType<bool>(json, r'isActivityEnabled')!,
|
||||
lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', r''),
|
||||
order: AssetOrder.fromJson(json[r'order']),
|
||||
owner: UserResponseDto.fromJson(json[r'owner'])!,
|
||||
ownerId: mapValueOfType<String>(json, r'ownerId')!,
|
||||
shared: mapValueOfType<bool>(json, r'shared')!,
|
||||
|
23
mobile/openapi/lib/model/update_album_dto.dart
generated
23
mobile/openapi/lib/model/update_album_dto.dart
generated
@ -17,6 +17,7 @@ class UpdateAlbumDto {
|
||||
this.albumThumbnailAssetId,
|
||||
this.description,
|
||||
this.isActivityEnabled,
|
||||
this.order,
|
||||
});
|
||||
|
||||
///
|
||||
@ -51,12 +52,21 @@ class UpdateAlbumDto {
|
||||
///
|
||||
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
|
||||
bool operator ==(Object other) => identical(this, other) || other is UpdateAlbumDto &&
|
||||
other.albumName == albumName &&
|
||||
other.albumThumbnailAssetId == albumThumbnailAssetId &&
|
||||
other.description == description &&
|
||||
other.isActivityEnabled == isActivityEnabled;
|
||||
other.isActivityEnabled == isActivityEnabled &&
|
||||
other.order == order;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
@ -64,10 +74,11 @@ class UpdateAlbumDto {
|
||||
(albumName == null ? 0 : albumName!.hashCode) +
|
||||
(albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) +
|
||||
(description == null ? 0 : description!.hashCode) +
|
||||
(isActivityEnabled == null ? 0 : isActivityEnabled!.hashCode);
|
||||
(isActivityEnabled == null ? 0 : isActivityEnabled!.hashCode) +
|
||||
(order == null ? 0 : order!.hashCode);
|
||||
|
||||
@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() {
|
||||
final json = <String, dynamic>{};
|
||||
@ -91,6 +102,11 @@ class UpdateAlbumDto {
|
||||
} else {
|
||||
// json[r'isActivityEnabled'] = null;
|
||||
}
|
||||
if (this.order != null) {
|
||||
json[r'order'] = this.order;
|
||||
} else {
|
||||
// json[r'order'] = null;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
@ -106,6 +122,7 @@ class UpdateAlbumDto {
|
||||
albumThumbnailAssetId: mapValueOfType<String>(json, r'albumThumbnailAssetId'),
|
||||
description: mapValueOfType<String>(json, r'description'),
|
||||
isActivityEnabled: mapValueOfType<bool>(json, r'isActivityEnabled'),
|
||||
order: AssetOrder.fromJson(json[r'order']),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
5
mobile/openapi/test/album_response_dto_test.dart
generated
5
mobile/openapi/test/album_response_dto_test.dart
generated
@ -71,6 +71,11 @@ void main() {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// AssetOrder order
|
||||
test('to test the property `order`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// UserResponseDto owner
|
||||
test('to test the property `owner`', () async {
|
||||
// TODO
|
||||
|
4
mobile/openapi/test/asset_api_test.dart
generated
4
mobile/openapi/test/asset_api_test.dart
generated
@ -95,12 +95,12 @@ void main() {
|
||||
// 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 {
|
||||
// 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 {
|
||||
// TODO
|
||||
});
|
||||
|
5
mobile/openapi/test/update_album_dto_test.dart
generated
5
mobile/openapi/test/update_album_dto_test.dart
generated
@ -36,6 +36,11 @@ void main() {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// AssetOrder order
|
||||
test('to test the property `order`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
@ -1765,6 +1765,14 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "order",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AssetOrder"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "personId",
|
||||
"required": false,
|
||||
@ -1901,6 +1909,14 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "order",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AssetOrder"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "personId",
|
||||
"required": false,
|
||||
@ -6722,6 +6738,9 @@
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
"order": {
|
||||
"$ref": "#/components/schemas/AssetOrder"
|
||||
},
|
||||
"owner": {
|
||||
"$ref": "#/components/schemas/UserResponseDto"
|
||||
},
|
||||
@ -10338,6 +10357,9 @@
|
||||
},
|
||||
"isActivityEnabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"order": {
|
||||
"$ref": "#/components/schemas/AssetOrder"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
@ -153,6 +153,7 @@ export type AlbumResponseDto = {
|
||||
id: string;
|
||||
isActivityEnabled: boolean;
|
||||
lastModifiedAssetTimestamp?: string;
|
||||
order?: AssetOrder;
|
||||
owner: UserResponseDto;
|
||||
ownerId: string;
|
||||
shared: boolean;
|
||||
@ -176,6 +177,7 @@ export type UpdateAlbumDto = {
|
||||
albumThumbnailAssetId?: string;
|
||||
description?: string;
|
||||
isActivityEnabled?: boolean;
|
||||
order?: AssetOrder;
|
||||
};
|
||||
export type BulkIdsDto = {
|
||||
ids: string[];
|
||||
@ -1454,12 +1456,13 @@ export function getAssetThumbnail({ format, id, key }: {
|
||||
...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;
|
||||
isArchived?: boolean;
|
||||
isFavorite?: boolean;
|
||||
isTrashed?: boolean;
|
||||
key?: string;
|
||||
order?: AssetOrder;
|
||||
personId?: string;
|
||||
size: TimeBucketSize;
|
||||
timeBucket: string;
|
||||
@ -1476,6 +1479,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key,
|
||||
isFavorite,
|
||||
isTrashed,
|
||||
key,
|
||||
order,
|
||||
personId,
|
||||
size,
|
||||
timeBucket,
|
||||
@ -1486,12 +1490,13 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key,
|
||||
...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;
|
||||
isArchived?: boolean;
|
||||
isFavorite?: boolean;
|
||||
isTrashed?: boolean;
|
||||
key?: string;
|
||||
order?: AssetOrder;
|
||||
personId?: string;
|
||||
size: TimeBucketSize;
|
||||
userId?: string;
|
||||
@ -1507,6 +1512,7 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key
|
||||
isFavorite,
|
||||
isTrashed,
|
||||
key,
|
||||
order,
|
||||
personId,
|
||||
size,
|
||||
userId,
|
||||
@ -2748,6 +2754,10 @@ export enum AssetTypeEnum {
|
||||
Audio = "AUDIO",
|
||||
Other = "OTHER"
|
||||
}
|
||||
export enum AssetOrder {
|
||||
Asc = "asc",
|
||||
Desc = "desc"
|
||||
}
|
||||
export enum Error {
|
||||
Duplicate = "duplicate",
|
||||
NoPermission = "no_permission",
|
||||
@ -2775,10 +2785,6 @@ export enum TimeBucketSize {
|
||||
Day = "DAY",
|
||||
Month = "MONTH"
|
||||
}
|
||||
export enum AssetOrder {
|
||||
Asc = "asc",
|
||||
Desc = "desc"
|
||||
}
|
||||
export enum EntityType {
|
||||
Asset = "ASSET",
|
||||
Album = "ALBUM"
|
||||
|
@ -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 { AssetResponseDto, mapAsset } from '../asset';
|
||||
import { AuthDto } from '../auth/auth.dto';
|
||||
@ -23,6 +24,9 @@ export class AlbumResponseDto {
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
isActivityEnabled!: boolean;
|
||||
@Optional()
|
||||
@ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder })
|
||||
order?: AssetOrder;
|
||||
}
|
||||
|
||||
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 })),
|
||||
assetCount: entity.assets?.length || 0,
|
||||
isActivityEnabled: entity.isActivityEnabled,
|
||||
order: entity.order,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -148,6 +148,7 @@ export class AlbumService {
|
||||
description: dto.description,
|
||||
albumThumbnailAssetId: dto.albumThumbnailAssetId,
|
||||
isActivityEnabled: dto.isActivityEnabled,
|
||||
order: dto.order,
|
||||
});
|
||||
|
||||
return mapAlbumWithoutAssets(updatedAlbum);
|
||||
|
@ -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';
|
||||
|
||||
export class UpdateAlbumDto {
|
||||
@ -15,4 +17,9 @@ export class UpdateAlbumDto {
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
isActivityEnabled?: boolean;
|
||||
|
||||
@IsEnum(AssetOrder)
|
||||
@Optional()
|
||||
@ApiProperty({ enum: AssetOrder, enumName: 'AssetOrder' })
|
||||
order?: AssetOrder;
|
||||
}
|
||||
|
@ -18,11 +18,6 @@ export class DeviceIdDto {
|
||||
deviceId!: string;
|
||||
}
|
||||
|
||||
export enum AssetOrder {
|
||||
ASC = 'asc',
|
||||
DESC = 'desc',
|
||||
}
|
||||
|
||||
const hasGPS = (o: { latitude: undefined; longitude: undefined }) =>
|
||||
o.latitude !== undefined || o.longitude !== undefined;
|
||||
const ValidateGPS = () => ValidateIf(hasGPS);
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { AssetOrder } from '@app/infra/entities';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
|
||||
import { ValidateBoolean, ValidateUUID } from '../../domain.util';
|
||||
import { Optional, ValidateBoolean, ValidateUUID } from '../../domain.util';
|
||||
import { TimeBucketSize } from '../../repositories';
|
||||
|
||||
export class TimeBucketDto {
|
||||
@ -32,6 +33,11 @@ export class TimeBucketDto {
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
withPartners?: boolean;
|
||||
|
||||
@IsEnum(AssetOrder)
|
||||
@Optional()
|
||||
@ApiProperty({ enum: AssetOrder, enumName: 'AssetOrder' })
|
||||
order?: AssetOrder;
|
||||
}
|
||||
|
||||
export class TimeBucketAssetDto extends TimeBucketDto {
|
||||
|
@ -1,5 +1,5 @@
|
||||
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 { Paginated, PaginationOptions } from '../domain.util';
|
||||
|
||||
@ -67,6 +67,7 @@ export interface AssetBuilderOptions {
|
||||
|
||||
export interface TimeBucketOptions extends AssetBuilderOptions {
|
||||
size: TimeBucketSize;
|
||||
order?: AssetOrder;
|
||||
}
|
||||
|
||||
export interface TimeBucketItem {
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { AssetOrder } from '@app/domain/asset/dto/asset.dto';
|
||||
import { AssetType, GeodataPlacesEntity } from '@app/infra/entities';
|
||||
import { AssetOrder, AssetType, GeodataPlacesEntity } from '@app/infra/entities';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { AssetEntity } from '@app/infra/entities';
|
||||
import { AssetEntity, AssetOrder } from '@app/infra/entities';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { AssetOrder, AssetResponseDto, mapAsset } from '../asset';
|
||||
import { AssetResponseDto, mapAsset } from '../asset';
|
||||
import { AuthDto } from '../auth';
|
||||
import { PersonResponseDto } from '../person';
|
||||
import {
|
||||
|
@ -14,6 +14,12 @@ import { AssetEntity } from './asset.entity';
|
||||
import { SharedLinkEntity } from './shared-link.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')
|
||||
export class AlbumEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
@ -59,4 +65,7 @@ export class AlbumEntity {
|
||||
|
||||
@Column({ default: true })
|
||||
isActivityEnabled!: boolean;
|
||||
|
||||
@Column({ type: 'varchar', default: AssetOrder.DESC })
|
||||
order!: AssetOrder;
|
||||
}
|
||||
|
@ -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"`);
|
||||
}
|
||||
|
||||
}
|
@ -36,7 +36,7 @@ import {
|
||||
Not,
|
||||
Repository,
|
||||
} 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 { Chunked, ChunkedArray, OptionalBetween, paginate, paginatedBuilder, searchAssetBuilder } from '../infra.utils';
|
||||
import { Instrumentation } from '../instrumentation';
|
||||
@ -614,7 +614,7 @@ export class AssetRepository implements IAssetRepository {
|
||||
.select(`COUNT(asset.id)::int`, 'count')
|
||||
.addSelect(truncated, 'timeBucket')
|
||||
.groupBy(truncated)
|
||||
.orderBy(truncated, 'DESC')
|
||||
.orderBy(truncated, options.order === AssetOrder.ASC ? 'ASC' : 'DESC')
|
||||
.getRawMany();
|
||||
}
|
||||
|
||||
@ -627,7 +627,7 @@ export class AssetRepository implements IAssetRepository {
|
||||
// First sort by the day in localtime (put it in the right bucket)
|
||||
.orderBy(truncated, 'DESC')
|
||||
// and then sort by the actual time
|
||||
.addOrderBy('asset.fileCreatedAt', 'DESC')
|
||||
.addOrderBy('asset.fileCreatedAt', options.order === AssetOrder.ASC ? 'ASC' : 'DESC')
|
||||
.getMany()
|
||||
);
|
||||
}
|
||||
|
@ -15,6 +15,7 @@ FROM
|
||||
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt",
|
||||
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
|
||||
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
|
||||
"AlbumEntity"."order" AS "AlbumEntity_order",
|
||||
"AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id",
|
||||
"AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name",
|
||||
"AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor",
|
||||
@ -91,6 +92,7 @@ SELECT
|
||||
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt",
|
||||
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
|
||||
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
|
||||
"AlbumEntity"."order" AS "AlbumEntity_order",
|
||||
"AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id",
|
||||
"AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name",
|
||||
"AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor",
|
||||
@ -149,6 +151,7 @@ SELECT
|
||||
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt",
|
||||
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
|
||||
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
|
||||
"AlbumEntity"."order" AS "AlbumEntity_order",
|
||||
"AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id",
|
||||
"AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name",
|
||||
"AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor",
|
||||
@ -279,6 +282,7 @@ SELECT
|
||||
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt",
|
||||
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
|
||||
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
|
||||
"AlbumEntity"."order" AS "AlbumEntity_order",
|
||||
"AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id",
|
||||
"AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name",
|
||||
"AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor",
|
||||
@ -352,6 +356,7 @@ SELECT
|
||||
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt",
|
||||
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
|
||||
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
|
||||
"AlbumEntity"."order" AS "AlbumEntity_order",
|
||||
"AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id",
|
||||
"AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name",
|
||||
"AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor",
|
||||
@ -462,6 +467,7 @@ SELECT
|
||||
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt",
|
||||
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
|
||||
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
|
||||
"AlbumEntity"."order" AS "AlbumEntity_order",
|
||||
"AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id",
|
||||
"AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name",
|
||||
"AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor",
|
||||
@ -553,6 +559,7 @@ SELECT
|
||||
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt",
|
||||
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
|
||||
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
|
||||
"AlbumEntity"."order" AS "AlbumEntity_order",
|
||||
"AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id",
|
||||
"AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name",
|
||||
"AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor",
|
||||
|
@ -87,6 +87,7 @@ FROM
|
||||
"SharedLinkEntity__SharedLinkEntity_album"."deletedAt" AS "SharedLinkEntity__SharedLinkEntity_album_deletedAt",
|
||||
"SharedLinkEntity__SharedLinkEntity_album"."albumThumbnailAssetId" AS "SharedLinkEntity__SharedLinkEntity_album_albumThumbnailAssetId",
|
||||
"SharedLinkEntity__SharedLinkEntity_album"."isActivityEnabled" AS "SharedLinkEntity__SharedLinkEntity_album_isActivityEnabled",
|
||||
"SharedLinkEntity__SharedLinkEntity_album"."order" AS "SharedLinkEntity__SharedLinkEntity_album_order",
|
||||
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."id" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_id",
|
||||
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."deviceAssetId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_deviceAssetId",
|
||||
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."ownerId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_ownerId",
|
||||
@ -248,6 +249,7 @@ SELECT
|
||||
"SharedLinkEntity__SharedLinkEntity_album"."deletedAt" AS "SharedLinkEntity__SharedLinkEntity_album_deletedAt",
|
||||
"SharedLinkEntity__SharedLinkEntity_album"."albumThumbnailAssetId" AS "SharedLinkEntity__SharedLinkEntity_album_albumThumbnailAssetId",
|
||||
"SharedLinkEntity__SharedLinkEntity_album"."isActivityEnabled" AS "SharedLinkEntity__SharedLinkEntity_album_isActivityEnabled",
|
||||
"SharedLinkEntity__SharedLinkEntity_album"."order" AS "SharedLinkEntity__SharedLinkEntity_album_order",
|
||||
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."id" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_id",
|
||||
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."name" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_name",
|
||||
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."avatarColor" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_avatarColor",
|
||||
|
12
server/test/fixtures/album.stub.ts
vendored
12
server/test/fixtures/album.stub.ts
vendored
@ -1,4 +1,4 @@
|
||||
import { AlbumEntity } from '@app/infra/entities';
|
||||
import { AlbumEntity, AssetOrder } from '@app/infra/entities';
|
||||
import { assetStub } from './asset.stub';
|
||||
import { authStub } from './auth.stub';
|
||||
import { userStub } from './user.stub';
|
||||
@ -19,6 +19,7 @@ export const albumStub = {
|
||||
sharedLinks: [],
|
||||
sharedUsers: [],
|
||||
isActivityEnabled: true,
|
||||
order: AssetOrder.DESC,
|
||||
}),
|
||||
sharedWithUser: Object.freeze<AlbumEntity>({
|
||||
id: 'album-2',
|
||||
@ -35,6 +36,7 @@ export const albumStub = {
|
||||
sharedLinks: [],
|
||||
sharedUsers: [userStub.user1],
|
||||
isActivityEnabled: true,
|
||||
order: AssetOrder.DESC,
|
||||
}),
|
||||
sharedWithMultiple: Object.freeze<AlbumEntity>({
|
||||
id: 'album-3',
|
||||
@ -51,6 +53,7 @@ export const albumStub = {
|
||||
sharedLinks: [],
|
||||
sharedUsers: [userStub.user1, userStub.user2],
|
||||
isActivityEnabled: true,
|
||||
order: AssetOrder.DESC,
|
||||
}),
|
||||
sharedWithAdmin: Object.freeze<AlbumEntity>({
|
||||
id: 'album-3',
|
||||
@ -67,6 +70,7 @@ export const albumStub = {
|
||||
sharedLinks: [],
|
||||
sharedUsers: [userStub.admin],
|
||||
isActivityEnabled: true,
|
||||
order: AssetOrder.DESC,
|
||||
}),
|
||||
oneAsset: Object.freeze<AlbumEntity>({
|
||||
id: 'album-4',
|
||||
@ -83,6 +87,7 @@ export const albumStub = {
|
||||
sharedLinks: [],
|
||||
sharedUsers: [],
|
||||
isActivityEnabled: true,
|
||||
order: AssetOrder.DESC,
|
||||
}),
|
||||
twoAssets: Object.freeze<AlbumEntity>({
|
||||
id: 'album-4a',
|
||||
@ -99,6 +104,7 @@ export const albumStub = {
|
||||
sharedLinks: [],
|
||||
sharedUsers: [],
|
||||
isActivityEnabled: true,
|
||||
order: AssetOrder.DESC,
|
||||
}),
|
||||
emptyWithInvalidThumbnail: Object.freeze<AlbumEntity>({
|
||||
id: 'album-5',
|
||||
@ -115,6 +121,7 @@ export const albumStub = {
|
||||
sharedLinks: [],
|
||||
sharedUsers: [],
|
||||
isActivityEnabled: true,
|
||||
order: AssetOrder.DESC,
|
||||
}),
|
||||
emptyWithValidThumbnail: Object.freeze<AlbumEntity>({
|
||||
id: 'album-5',
|
||||
@ -131,6 +138,7 @@ export const albumStub = {
|
||||
sharedLinks: [],
|
||||
sharedUsers: [],
|
||||
isActivityEnabled: true,
|
||||
order: AssetOrder.DESC,
|
||||
}),
|
||||
oneAssetInvalidThumbnail: Object.freeze<AlbumEntity>({
|
||||
id: 'album-6',
|
||||
@ -147,6 +155,7 @@ export const albumStub = {
|
||||
sharedLinks: [],
|
||||
sharedUsers: [],
|
||||
isActivityEnabled: true,
|
||||
order: AssetOrder.DESC,
|
||||
}),
|
||||
oneAssetValidThumbnail: Object.freeze<AlbumEntity>({
|
||||
id: 'album-6',
|
||||
@ -163,5 +172,6 @@ export const albumStub = {
|
||||
sharedLinks: [],
|
||||
sharedUsers: [],
|
||||
isActivityEnabled: true,
|
||||
order: AssetOrder.DESC,
|
||||
}),
|
||||
};
|
||||
|
4
server/test/fixtures/shared-link.stub.ts
vendored
4
server/test/fixtures/shared-link.stub.ts
vendored
@ -1,5 +1,5 @@
|
||||
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 { authStub } from './auth.stub';
|
||||
import { libraryStub } from './library.stub';
|
||||
@ -101,6 +101,7 @@ const albumResponse: AlbumResponseDto = {
|
||||
assets: [],
|
||||
assetCount: 1,
|
||||
isActivityEnabled: true,
|
||||
order: AssetOrder.DESC,
|
||||
};
|
||||
|
||||
export const sharedLinkStub = {
|
||||
@ -181,6 +182,7 @@ export const sharedLinkStub = {
|
||||
sharedUsers: [],
|
||||
sharedLinks: [],
|
||||
isActivityEnabled: true,
|
||||
order: AssetOrder.DESC,
|
||||
assets: [
|
||||
{
|
||||
id: 'id_1',
|
||||
|
@ -1,22 +1,55 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import type { AlbumResponseDto, UserResponseDto } from '@immich/sdk';
|
||||
import { mdiClose, mdiPlus } from '@mdi/js';
|
||||
import { updateAlbumInfo, type AlbumResponseDto, type UserResponseDto, AssetOrder } from '@immich/sdk';
|
||||
import { mdiArrowDownThin, mdiArrowUpThin, mdiClose, mdiPlus } from '@mdi/js';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
||||
import UserAvatar from '$lib/components/shared-components/user-avatar.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 order: AssetOrder | undefined;
|
||||
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<{
|
||||
close: void;
|
||||
toggleEnableActivity: 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>
|
||||
|
||||
<FullScreenModal onClose={() => dispatch('close')}>
|
||||
@ -34,8 +67,16 @@
|
||||
|
||||
<div class=" items-center justify-center p-4">
|
||||
<div class="py-2">
|
||||
<h2 class="text-gray text-sm mb-3">SHARING</h2>
|
||||
<div class="p-2">
|
||||
<h2 class="text-gray text-sm mb-2">SETTINGS</h2>
|
||||
<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
|
||||
title="Comments & likes"
|
||||
subtitle="Let others respond"
|
||||
|
68
web/src/lib/components/album-page/albums-controls.svelte
Normal file
68
web/src/lib/components/album-page/albums-controls.svelte
Normal 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>
|
282
web/src/lib/components/album-page/albums-list.svelte
Normal file
282
web/src/lib/components/album-page/albums-list.svelte
Normal 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}
|
@ -1,14 +1,15 @@
|
||||
<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;
|
||||
|
||||
const handleSort = () => {
|
||||
if (albumViewSettings === option.title) {
|
||||
if ($albumViewSettings.sortBy === option.title) {
|
||||
$albumViewSettings.sortDesc = !option.sortDesc;
|
||||
option.sortDesc = !option.sortDesc;
|
||||
} else {
|
||||
albumViewSettings = option.title;
|
||||
$albumViewSettings.sortBy = option.title;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@ -18,7 +19,7 @@
|
||||
class="rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50"
|
||||
on:click={() => handleSort()}
|
||||
>
|
||||
{#if albumViewSettings === option.title}
|
||||
{#if $albumViewSettings.sortBy === option.title}
|
||||
{#if option.sortDesc}
|
||||
↓
|
||||
{:else}
|
87
web/src/lib/components/album-page/albums-table.svelte
Normal file
87
web/src/lib/components/album-page/albums-table.svelte
Normal 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}
|
||||
❌
|
||||
{/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}
|
||||
❌
|
||||
{/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>
|
@ -20,7 +20,7 @@
|
||||
[SlideshowNavigation.DescendingOrder]: { icon: mdiArrowDownThin, title: 'Forward' },
|
||||
};
|
||||
|
||||
export const handleToggle = (selectedOption: RenderedOption) => {
|
||||
const handleToggle = (selectedOption: RenderedOption) => {
|
||||
for (const [key, option] of Object.entries(options)) {
|
||||
if (option === selectedOption) {
|
||||
$slideshowNavigation = key as SlideshowNavigation;
|
||||
|
@ -55,7 +55,7 @@
|
||||
<ChangePasswordSettings />
|
||||
</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} />
|
||||
</SettingAccordion>
|
||||
</SettingAccordionState>
|
||||
|
@ -161,7 +161,10 @@ export class AssetStore {
|
||||
this.assetToBucket = {};
|
||||
this.albumAssets = new Set();
|
||||
|
||||
const buckets = await getTimeBuckets({ ...this.options, key: getKey() });
|
||||
const buckets = await getTimeBuckets({
|
||||
...this.options,
|
||||
key: getKey(),
|
||||
});
|
||||
|
||||
this.initialized = true;
|
||||
|
||||
|
@ -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">
|
||||
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 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 { useAlbums } from './albums.bloc';
|
||||
import SearchBar from '$lib/components/elements/search-bar.svelte';
|
||||
import AlbumsControls from '$lib/components/album-page/albums-controls.svelte';
|
||||
import Albums from '$lib/components/album-page/albums-list.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let shouldShowEditUserForm = false;
|
||||
let selectedAlbum: AlbumResponseDto;
|
||||
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>
|
||||
|
||||
{#if shouldShowEditUserForm}
|
||||
<FullScreenModal onClose={() => (shouldShowEditUserForm = false)}>
|
||||
<EditAlbumForm
|
||||
album={selectedAlbum}
|
||||
on:editSuccess={() => successModifyAlbum()}
|
||||
on:cancel={() => (shouldShowEditUserForm = false)}
|
||||
/>
|
||||
</FullScreenModal>
|
||||
{/if}
|
||||
|
||||
<UserPageLayout title={data.meta.title}>
|
||||
<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">
|
||||
<SearchBar placeholder="Search albums" bind:name={searchAlbum} isSearching={false} />
|
||||
<AlbumsControls bind:searchAlbum />
|
||||
</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 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}
|
||||
❌
|
||||
{/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}
|
||||
❌
|
||||
{/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}
|
||||
<Albums albums={data.albums} {searchAlbum} />
|
||||
</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}
|
||||
|
@ -58,6 +58,7 @@
|
||||
updateAlbumInfo,
|
||||
type ActivityResponseDto,
|
||||
type UserResponseDto,
|
||||
AssetOrder,
|
||||
} from '@immich/sdk';
|
||||
import {
|
||||
mdiArrowLeft,
|
||||
@ -83,6 +84,7 @@
|
||||
|
||||
$: album = data.album;
|
||||
$: albumId = album.id;
|
||||
$: albumKey = `${albumId}_${albumOrder}`;
|
||||
|
||||
$: {
|
||||
if (!album.isActivityEnabled && $numberOfComments === 0) {
|
||||
@ -112,8 +114,9 @@
|
||||
let globalWidth: number;
|
||||
let assetGridWidth: number;
|
||||
let textArea: HTMLTextAreaElement;
|
||||
let albumOrder: AssetOrder | undefined = data.album.order;
|
||||
|
||||
$: assetStore = new AssetStore({ albumId });
|
||||
$: assetStore = new AssetStore({ albumId, order: albumOrder });
|
||||
const assetInteractionStore = createAssetInteractionStore();
|
||||
const { isMultiSelectState, selectedAssets } = assetInteractionStore;
|
||||
|
||||
@ -512,7 +515,7 @@
|
||||
style={`width:${assetGridWidth}px`}
|
||||
>
|
||||
<!-- Use key because AssetGrid can't deal with changing stores -->
|
||||
{#key albumId}
|
||||
{#key albumKey}
|
||||
{#if viewMode === ViewMode.SELECT_ASSETS}
|
||||
<AssetGrid
|
||||
assetStore={timelineStore}
|
||||
@ -679,7 +682,9 @@
|
||||
{#if viewMode === ViewMode.OPTIONS && $user}
|
||||
<AlbumOptions
|
||||
{album}
|
||||
order={albumOrder}
|
||||
user={$user}
|
||||
onChangeOrder={(order) => (albumOrder = order)}
|
||||
on:close={() => (viewMode = ViewMode.VIEW)}
|
||||
on:toggleEnableActivity={handleToggleEnableActivity}
|
||||
on:showSelectSharedUser={() => (viewMode = ViewMode.SELECT_USERS)}
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
@ -1,5 +1,5 @@
|
||||
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 { userFactory } from './user-factory';
|
||||
|
||||
@ -18,4 +18,5 @@ export const albumFactory = Sync.makeFactory<AlbumResponseDto>({
|
||||
sharedUsers: [],
|
||||
hasSharedLink: false,
|
||||
isActivityEnabled: true,
|
||||
order: AssetOrder.Desc,
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user