From 5cd13227ad31e31c8ac57066090419557c8a5581 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 11 Aug 2023 12:00:51 -0400 Subject: [PATCH] feat(web): timeline bucket for albums (4) (#3604) * feat: server changes for album timeline * feat(web): album timeline view * chore: open api * chore: remove archive action * fix: favorite for non-owners --- cli/src/api/open-api/api.ts | 41 +- mobile/openapi/doc/AlbumApi.md | 6 +- mobile/openapi/doc/AlbumResponseDto.md | 3 + mobile/openapi/lib/api/album_api.dart | 13 +- .../openapi/lib/model/album_response_dto.dart | 44 +- mobile/openapi/test/album_api_test.dart | 2 +- .../openapi/test/album_response_dto_test.dart | 15 + server/immich-openapi-specs.json | 20 + server/src/domain/album/album-response.dto.ts | 21 +- server/src/domain/album/album.service.spec.ts | 11 +- server/src/domain/album/album.service.ts | 32 +- server/src/domain/album/dto/album.dto.ts | 10 + server/src/domain/album/dto/index.ts | 1 + server/src/domain/asset/asset.repository.ts | 5 +- server/src/domain/asset/asset.service.ts | 22 +- server/src/domain/search/search.service.ts | 4 +- .../shared-link/shared-link-response.dto.ts | 6 +- .../immich/controllers/album.controller.ts | 14 +- .../infra/repositories/album.repository.ts | 1 + .../infra/repositories/asset.repository.ts | 19 +- server/test/e2e/album.e2e-spec.ts | 1 + server/test/fixtures/shared-link.stub.ts | 3 +- web/.prettierrc | 3 +- web/src/api/open-api/api.ts | 41 +- .../components/album-page/album-viewer.svelte | 497 ++-------------- .../album-page/asset-selection.svelte | 74 --- .../album-page/share-info-modal.svelte | 19 +- .../album-page/user-selection-modal.svelte | 25 +- .../actions/create-shared-link.svelte | 7 +- .../actions/remove-from-album.svelte | 6 +- .../actions/select-all-assets.svelte | 2 +- .../photos-page/asset-date-group.svelte | 82 ++- .../components/photos-page/asset-grid.svelte | 62 +- .../create-shared-link-modal.svelte | 20 +- .../navigation-bar/account-info-panel.svelte | 2 +- .../navigation-bar/navigation-bar.svelte | 2 +- .../shared-components/user-avatar.svelte | 8 +- .../partner-selection-modal.svelte | 2 +- web/src/lib/constants.ts | 8 + web/src/lib/stores/asset-interaction.store.ts | 32 +- web/src/lib/stores/assets.store.ts | 25 +- web/src/lib/utils/asset-utils.ts | 11 +- .../(user)/albums/[albumId]/+page.server.ts | 6 +- .../(user)/albums/[albumId]/+page.svelte | 533 +++++++++++++++++- web/src/routes/(user)/sharing/+page.svelte | 2 +- .../(user)/sharing/sharedlinks/+page.svelte | 7 +- web/src/test-data/factories/album-factory.ts | 1 + 47 files changed, 1014 insertions(+), 757 deletions(-) create mode 100644 server/src/domain/album/dto/album.dto.ts delete mode 100644 web/src/lib/components/album-page/asset-selection.svelte diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index c58a4a4c5..9cedbdf2c 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -216,6 +216,18 @@ export interface AlbumResponseDto { * @memberof AlbumResponseDto */ 'description': string; + /** + * + * @type {string} + * @memberof AlbumResponseDto + */ + 'endDate'?: string; + /** + * + * @type {boolean} + * @memberof AlbumResponseDto + */ + 'hasSharedLink': boolean; /** * * @type {string} @@ -252,6 +264,12 @@ export interface AlbumResponseDto { * @memberof AlbumResponseDto */ 'sharedUsers': Array; + /** + * + * @type {string} + * @memberof AlbumResponseDto + */ + 'startDate'?: string; /** * * @type {string} @@ -3899,11 +3917,12 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration /** * * @param {string} id + * @param {boolean} [withoutAssets] * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getAlbumInfo: async (id: string, key?: string, options: AxiosRequestConfig = {}): Promise => { + getAlbumInfo: async (id: string, withoutAssets?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'id' is not null or undefined assertParamExists('getAlbumInfo', 'id', id) const localVarPath = `/album/{id}` @@ -3928,6 +3947,10 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration // http bearer authentication required await setBearerAuthToObject(localVarHeaderParameter, configuration) + if (withoutAssets !== undefined) { + localVarQueryParameter['withoutAssets'] = withoutAssets; + } + if (key !== undefined) { localVarQueryParameter['key'] = key; } @@ -4198,12 +4221,13 @@ export const AlbumApiFp = function(configuration?: Configuration) { /** * * @param {string} id + * @param {boolean} [withoutAssets] * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getAlbumInfo(id: string, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getAlbumInfo(id, key, options); + async getAlbumInfo(id: string, withoutAssets?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getAlbumInfo(id, withoutAssets, key, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -4311,7 +4335,7 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath * @throws {RequiredError} */ getAlbumInfo(requestParameters: AlbumApiGetAlbumInfoRequest, options?: AxiosRequestConfig): AxiosPromise { - return localVarFp.getAlbumInfo(requestParameters.id, requestParameters.key, options).then((request) => request(axios, basePath)); + return localVarFp.getAlbumInfo(requestParameters.id, requestParameters.withoutAssets, requestParameters.key, options).then((request) => request(axios, basePath)); }, /** * @@ -4442,6 +4466,13 @@ export interface AlbumApiGetAlbumInfoRequest { */ readonly id: string + /** + * + * @type {boolean} + * @memberof AlbumApiGetAlbumInfo + */ + readonly withoutAssets?: boolean + /** * * @type {string} @@ -4603,7 +4634,7 @@ export class AlbumApi extends BaseAPI { * @memberof AlbumApi */ public getAlbumInfo(requestParameters: AlbumApiGetAlbumInfoRequest, options?: AxiosRequestConfig) { - return AlbumApiFp(this.configuration).getAlbumInfo(requestParameters.id, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); + return AlbumApiFp(this.configuration).getAlbumInfo(requestParameters.id, requestParameters.withoutAssets, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); } /** diff --git a/mobile/openapi/doc/AlbumApi.md b/mobile/openapi/doc/AlbumApi.md index b12e68c8e..fab77bc03 100644 --- a/mobile/openapi/doc/AlbumApi.md +++ b/mobile/openapi/doc/AlbumApi.md @@ -298,7 +298,7 @@ This endpoint does not need any parameter. [[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) # **getAlbumInfo** -> AlbumResponseDto getAlbumInfo(id, key) +> AlbumResponseDto getAlbumInfo(id, withoutAssets, key) @@ -322,10 +322,11 @@ import 'package:openapi/api.dart'; final api_instance = AlbumApi(); final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final withoutAssets = true; // bool | final key = key_example; // String | try { - final result = api_instance.getAlbumInfo(id, key); + final result = api_instance.getAlbumInfo(id, withoutAssets, key); print(result); } catch (e) { print('Exception when calling AlbumApi->getAlbumInfo: $e\n'); @@ -337,6 +338,7 @@ try { Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- **id** | **String**| | + **withoutAssets** | **bool**| | [optional] **key** | **String**| | [optional] ### Return type diff --git a/mobile/openapi/doc/AlbumResponseDto.md b/mobile/openapi/doc/AlbumResponseDto.md index 9a806a958..93620b9fc 100644 --- a/mobile/openapi/doc/AlbumResponseDto.md +++ b/mobile/openapi/doc/AlbumResponseDto.md @@ -14,12 +14,15 @@ Name | Type | Description | Notes **assets** | [**List**](AssetResponseDto.md) | | [default to const []] **createdAt** | [**DateTime**](DateTime.md) | | **description** | **String** | | +**endDate** | [**DateTime**](DateTime.md) | | [optional] +**hasSharedLink** | **bool** | | **id** | **String** | | **lastModifiedAssetTimestamp** | [**DateTime**](DateTime.md) | | [optional] **owner** | [**UserResponseDto**](UserResponseDto.md) | | **ownerId** | **String** | | **shared** | **bool** | | **sharedUsers** | [**List**](UserResponseDto.md) | | [default to const []] +**startDate** | [**DateTime**](DateTime.md) | | [optional] **updatedAt** | [**DateTime**](DateTime.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) diff --git a/mobile/openapi/lib/api/album_api.dart b/mobile/openapi/lib/api/album_api.dart index 39b44e9be..122a83940 100644 --- a/mobile/openapi/lib/api/album_api.dart +++ b/mobile/openapi/lib/api/album_api.dart @@ -264,8 +264,10 @@ class AlbumApi { /// /// * [String] id (required): /// + /// * [bool] withoutAssets: + /// /// * [String] key: - Future getAlbumInfoWithHttpInfo(String id, { String? key, }) async { + Future getAlbumInfoWithHttpInfo(String id, { bool? withoutAssets, String? key, }) async { // ignore: prefer_const_declarations final path = r'/album/{id}' .replaceAll('{id}', id); @@ -277,6 +279,9 @@ class AlbumApi { final headerParams = {}; final formParams = {}; + if (withoutAssets != null) { + queryParams.addAll(_queryParams('', 'withoutAssets', withoutAssets)); + } if (key != null) { queryParams.addAll(_queryParams('', 'key', key)); } @@ -299,9 +304,11 @@ class AlbumApi { /// /// * [String] id (required): /// + /// * [bool] withoutAssets: + /// /// * [String] key: - Future getAlbumInfo(String id, { String? key, }) async { - final response = await getAlbumInfoWithHttpInfo(id, key: key, ); + Future getAlbumInfo(String id, { bool? withoutAssets, String? key, }) async { + final response = await getAlbumInfoWithHttpInfo(id, withoutAssets: withoutAssets, key: key, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/model/album_response_dto.dart b/mobile/openapi/lib/model/album_response_dto.dart index 12b5d3779..cf2ad9252 100644 --- a/mobile/openapi/lib/model/album_response_dto.dart +++ b/mobile/openapi/lib/model/album_response_dto.dart @@ -19,12 +19,15 @@ class AlbumResponseDto { this.assets = const [], required this.createdAt, required this.description, + this.endDate, + required this.hasSharedLink, required this.id, this.lastModifiedAssetTimestamp, required this.owner, required this.ownerId, required this.shared, this.sharedUsers = const [], + this.startDate, required this.updatedAt, }); @@ -40,6 +43,16 @@ class AlbumResponseDto { String description; + /// + /// 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. + /// + DateTime? endDate; + + bool hasSharedLink; + String id; /// @@ -58,6 +71,14 @@ class AlbumResponseDto { List sharedUsers; + /// + /// 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. + /// + DateTime? startDate; + DateTime updatedAt; @override @@ -68,12 +89,15 @@ class AlbumResponseDto { other.assets == assets && other.createdAt == createdAt && other.description == description && + other.endDate == endDate && + other.hasSharedLink == hasSharedLink && other.id == id && other.lastModifiedAssetTimestamp == lastModifiedAssetTimestamp && other.owner == owner && other.ownerId == ownerId && other.shared == shared && other.sharedUsers == sharedUsers && + other.startDate == startDate && other.updatedAt == updatedAt; @override @@ -85,16 +109,19 @@ class AlbumResponseDto { (assets.hashCode) + (createdAt.hashCode) + (description.hashCode) + + (endDate == null ? 0 : endDate!.hashCode) + + (hasSharedLink.hashCode) + (id.hashCode) + (lastModifiedAssetTimestamp == null ? 0 : lastModifiedAssetTimestamp!.hashCode) + (owner.hashCode) + (ownerId.hashCode) + (shared.hashCode) + (sharedUsers.hashCode) + + (startDate == null ? 0 : startDate!.hashCode) + (updatedAt.hashCode); @override - String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, description=$description, id=$id, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, owner=$owner, ownerId=$ownerId, shared=$shared, sharedUsers=$sharedUsers, updatedAt=$updatedAt]'; + String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, owner=$owner, ownerId=$ownerId, shared=$shared, sharedUsers=$sharedUsers, startDate=$startDate, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -108,6 +135,12 @@ class AlbumResponseDto { json[r'assets'] = this.assets; json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); json[r'description'] = this.description; + if (this.endDate != null) { + json[r'endDate'] = this.endDate!.toUtc().toIso8601String(); + } else { + // json[r'endDate'] = null; + } + json[r'hasSharedLink'] = this.hasSharedLink; json[r'id'] = this.id; if (this.lastModifiedAssetTimestamp != null) { json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String(); @@ -118,6 +151,11 @@ class AlbumResponseDto { json[r'ownerId'] = this.ownerId; json[r'shared'] = this.shared; json[r'sharedUsers'] = this.sharedUsers; + if (this.startDate != null) { + json[r'startDate'] = this.startDate!.toUtc().toIso8601String(); + } else { + // json[r'startDate'] = null; + } json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); return json; } @@ -136,12 +174,15 @@ class AlbumResponseDto { assets: AssetResponseDto.listFromJson(json[r'assets']), createdAt: mapDateTime(json, r'createdAt', '')!, description: mapValueOfType(json, r'description')!, + endDate: mapDateTime(json, r'endDate', ''), + hasSharedLink: mapValueOfType(json, r'hasSharedLink')!, id: mapValueOfType(json, r'id')!, lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', ''), owner: UserResponseDto.fromJson(json[r'owner'])!, ownerId: mapValueOfType(json, r'ownerId')!, shared: mapValueOfType(json, r'shared')!, sharedUsers: UserResponseDto.listFromJson(json[r'sharedUsers']), + startDate: mapDateTime(json, r'startDate', ''), updatedAt: mapDateTime(json, r'updatedAt', '')!, ); } @@ -196,6 +237,7 @@ class AlbumResponseDto { 'assets', 'createdAt', 'description', + 'hasSharedLink', 'id', 'owner', 'ownerId', diff --git a/mobile/openapi/test/album_api_test.dart b/mobile/openapi/test/album_api_test.dart index 085b7f0ff..af4cd2a45 100644 --- a/mobile/openapi/test/album_api_test.dart +++ b/mobile/openapi/test/album_api_test.dart @@ -42,7 +42,7 @@ void main() { // TODO }); - //Future getAlbumInfo(String id, { String key }) async + //Future getAlbumInfo(String id, { bool withoutAssets, String key }) async test('test getAlbumInfo', () async { // TODO }); diff --git a/mobile/openapi/test/album_response_dto_test.dart b/mobile/openapi/test/album_response_dto_test.dart index 2c01a043a..c84174200 100644 --- a/mobile/openapi/test/album_response_dto_test.dart +++ b/mobile/openapi/test/album_response_dto_test.dart @@ -46,6 +46,16 @@ void main() { // TODO }); + // DateTime endDate + test('to test the property `endDate`', () async { + // TODO + }); + + // bool hasSharedLink + test('to test the property `hasSharedLink`', () async { + // TODO + }); + // String id test('to test the property `id`', () async { // TODO @@ -76,6 +86,11 @@ void main() { // TODO }); + // DateTime startDate + test('to test the property `startDate`', () async { + // TODO + }); + // DateTime updatedAt test('to test the property `updatedAt`', () async { // TODO diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index bbf28e8d1..d5504ba32 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -173,6 +173,14 @@ "type": "string" } }, + { + "name": "withoutAssets", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, { "name": "key", "required": false, @@ -4757,6 +4765,13 @@ "description": { "type": "string" }, + "endDate": { + "format": "date-time", + "type": "string" + }, + "hasSharedLink": { + "type": "boolean" + }, "id": { "type": "string" }, @@ -4779,6 +4794,10 @@ }, "type": "array" }, + "startDate": { + "format": "date-time", + "type": "string" + }, "updatedAt": { "format": "date-time", "type": "string" @@ -4795,6 +4814,7 @@ "albumThumbnailAssetId", "shared", "sharedUsers", + "hasSharedLink", "assets", "owner" ], diff --git a/server/src/domain/album/album-response.dto.ts b/server/src/domain/album/album-response.dto.ts index 5fde07c57..78842b6cb 100644 --- a/server/src/domain/album/album-response.dto.ts +++ b/server/src/domain/album/album-response.dto.ts @@ -13,14 +13,17 @@ export class AlbumResponseDto { albumThumbnailAssetId!: string | null; shared!: boolean; sharedUsers!: UserResponseDto[]; + hasSharedLink!: boolean; assets!: AssetResponseDto[]; owner!: UserResponseDto; @ApiProperty({ type: 'integer' }) assetCount!: number; lastModifiedAssetTimestamp?: Date; + startDate?: Date; + endDate?: Date; } -const _map = (entity: AlbumEntity, withAssets: boolean): AlbumResponseDto => { +export const mapAlbum = (entity: AlbumEntity, withAssets: boolean): AlbumResponseDto => { const sharedUsers: UserResponseDto[] = []; entity.sharedUsers?.forEach((user) => { @@ -28,6 +31,11 @@ const _map = (entity: AlbumEntity, withAssets: boolean): AlbumResponseDto => { sharedUsers.push(userDto); }); + const assets = entity.assets || []; + + const hasSharedLink = entity.sharedLinks?.length > 0; + const hasSharedUser = sharedUsers.length > 0; + return { albumName: entity.albumName, description: entity.description, @@ -38,14 +46,17 @@ const _map = (entity: AlbumEntity, withAssets: boolean): AlbumResponseDto => { ownerId: entity.ownerId, owner: mapUser(entity.owner), sharedUsers, - shared: sharedUsers.length > 0 || entity.sharedLinks?.length > 0, - assets: withAssets ? entity.assets?.map((asset) => mapAsset(asset)) || [] : [], + shared: hasSharedUser || hasSharedLink, + hasSharedLink, + startDate: assets.at(0)?.fileCreatedAt || undefined, + endDate: assets.at(-1)?.fileCreatedAt || undefined, + assets: (withAssets ? assets : []).map((asset) => mapAsset(asset)), assetCount: entity.assets?.length || 0, }; }; -export const mapAlbum = (entity: AlbumEntity) => _map(entity, true); -export const mapAlbumExcludeAssetInfo = (entity: AlbumEntity) => _map(entity, false); +export const mapAlbumWithAssets = (entity: AlbumEntity) => mapAlbum(entity, true); +export const mapAlbumWithoutAssets = (entity: AlbumEntity) => mapAlbum(entity, false); export class AlbumCountResponseDto { @ApiProperty({ type: 'integer' }) diff --git a/server/src/domain/album/album.service.spec.ts b/server/src/domain/album/album.service.spec.ts index febacbb38..463d94d7f 100644 --- a/server/src/domain/album/album.service.spec.ts +++ b/server/src/domain/album/album.service.spec.ts @@ -181,6 +181,9 @@ describe(AlbumService.name, () => { ownerId: 'admin_id', shared: false, sharedUsers: [], + startDate: undefined, + endDate: undefined, + hasSharedLink: false, updatedAt: expect.anything(), }); @@ -427,7 +430,7 @@ describe(AlbumService.name, () => { albumMock.getById.mockResolvedValue(albumStub.oneAsset); accessMock.album.hasOwnerAccess.mockResolvedValue(true); - await sut.get(authStub.admin, albumStub.oneAsset.id); + await sut.get(authStub.admin, albumStub.oneAsset.id, {}); expect(albumMock.getById).toHaveBeenCalledWith(albumStub.oneAsset.id); expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, albumStub.oneAsset.id); @@ -437,7 +440,7 @@ describe(AlbumService.name, () => { albumMock.getById.mockResolvedValue(albumStub.oneAsset); accessMock.album.hasSharedLinkAccess.mockResolvedValue(true); - await sut.get(authStub.adminSharedLink, 'album-123'); + await sut.get(authStub.adminSharedLink, 'album-123', {}); expect(albumMock.getById).toHaveBeenCalledWith('album-123'); expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalledWith( @@ -450,7 +453,7 @@ describe(AlbumService.name, () => { albumMock.getById.mockResolvedValue(albumStub.oneAsset); accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true); - await sut.get(authStub.user1, 'album-123'); + await sut.get(authStub.user1, 'album-123', {}); expect(albumMock.getById).toHaveBeenCalledWith('album-123'); expect(accessMock.album.hasSharedAlbumAccess).toHaveBeenCalledWith(authStub.user1.id, 'album-123'); @@ -460,7 +463,7 @@ describe(AlbumService.name, () => { accessMock.album.hasOwnerAccess.mockResolvedValue(false); accessMock.album.hasSharedAlbumAccess.mockResolvedValue(false); - await expect(sut.get(authStub.admin, 'album-123')).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.get(authStub.admin, 'album-123', {})).rejects.toBeInstanceOf(BadRequestException); expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-123'); expect(accessMock.album.hasSharedAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-123'); diff --git a/server/src/domain/album/album.service.ts b/server/src/domain/album/album.service.ts index f98cdfb1f..925b87ac9 100644 --- a/server/src/domain/album/album.service.ts +++ b/server/src/domain/album/album.service.ts @@ -1,13 +1,19 @@ import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/entities'; import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { AccessCore, IAccessRepository, Permission } from '../access'; -import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto, IAssetRepository, mapAsset } from '../asset'; +import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto, IAssetRepository } from '../asset'; import { AuthUserDto } from '../auth'; import { IJobRepository, JobName } from '../job'; import { IUserRepository } from '../user'; -import { AlbumCountResponseDto, AlbumResponseDto, mapAlbum } from './album-response.dto'; +import { + AlbumCountResponseDto, + AlbumResponseDto, + mapAlbum, + mapAlbumWithAssets, + mapAlbumWithoutAssets, +} from './album-response.dto'; import { IAlbumRepository } from './album.repository'; -import { AddUsersDto, CreateAlbumDto, GetAlbumsDto, UpdateAlbumDto } from './dto'; +import { AddUsersDto, AlbumInfoDto, CreateAlbumDto, GetAlbumsDto, UpdateAlbumDto } from './dto'; @Injectable() export class AlbumService { @@ -66,21 +72,19 @@ export class AlbumService { albums.map(async (album) => { const lastModifiedAsset = await this.assetRepository.getLastUpdatedAssetForAlbumId(album.id); return { - ...album, - assets: album?.assets?.map(mapAsset), - sharedLinks: undefined, // Don't return shared links - shared: album.sharedLinks?.length > 0 || album.sharedUsers?.length > 0, + ...mapAlbumWithoutAssets(album), + sharedLinks: undefined, assetCount: albumsAssetCountObj[album.id], lastModifiedAssetTimestamp: lastModifiedAsset?.fileModifiedAt, - } as AlbumResponseDto; + }; }), ); } - async get(authUser: AuthUserDto, id: string) { + async get(authUser: AuthUserDto, id: string, dto: AlbumInfoDto) { await this.access.requirePermission(authUser, Permission.ALBUM_READ, id); await this.albumRepository.updateThumbnails(); - return mapAlbum(await this.findOrFail(id)); + return mapAlbum(await this.findOrFail(id), !dto.withoutAssets); } async create(authUser: AuthUserDto, dto: CreateAlbumDto): Promise { @@ -101,7 +105,7 @@ export class AlbumService { }); await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { ids: [album.id] } }); - return mapAlbum(album); + return mapAlbumWithAssets(album); } async update(authUser: AuthUserDto, id: string, dto: UpdateAlbumDto): Promise { @@ -125,7 +129,7 @@ export class AlbumService { await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { ids: [updatedAlbum.id] } }); - return mapAlbum(updatedAlbum); + return mapAlbumWithAssets(updatedAlbum); } async delete(authUser: AuthUserDto, id: string): Promise { @@ -218,7 +222,7 @@ export class AlbumService { return results; } - async addUsers(authUser: AuthUserDto, id: string, dto: AddUsersDto) { + async addUsers(authUser: AuthUserDto, id: string, dto: AddUsersDto): Promise { await this.access.requirePermission(authUser, Permission.ALBUM_SHARE, id); const album = await this.findOrFail(id); @@ -243,7 +247,7 @@ export class AlbumService { updatedAt: new Date(), sharedUsers: album.sharedUsers, }) - .then(mapAlbum); + .then(mapAlbumWithAssets); } async removeUser(authUser: AuthUserDto, id: string, userId: string | 'me'): Promise { diff --git a/server/src/domain/album/dto/album.dto.ts b/server/src/domain/album/dto/album.dto.ts new file mode 100644 index 000000000..f4b39a899 --- /dev/null +++ b/server/src/domain/album/dto/album.dto.ts @@ -0,0 +1,10 @@ +import { Transform } from 'class-transformer'; +import { IsBoolean, IsOptional } from 'class-validator'; +import { toBoolean } from '../../domain.util'; + +export class AlbumInfoDto { + @IsOptional() + @IsBoolean() + @Transform(toBoolean) + withoutAssets?: boolean; +} diff --git a/server/src/domain/album/dto/index.ts b/server/src/domain/album/dto/index.ts index 4234895f6..b1a4c2141 100644 --- a/server/src/domain/album/dto/index.ts +++ b/server/src/domain/album/dto/index.ts @@ -1,4 +1,5 @@ export * from './album-add-users.dto'; export * from './album-create.dto'; export * from './album-update.dto'; +export * from './album.dto'; export * from './get-albums.dto'; diff --git a/server/src/domain/asset/asset.repository.ts b/server/src/domain/asset/asset.repository.ts index 1a5ded426..faf21361e 100644 --- a/server/src/domain/asset/asset.repository.ts +++ b/server/src/domain/asset/asset.repository.ts @@ -58,6 +58,7 @@ export interface TimeBucketOptions { isFavorite?: boolean; albumId?: string; personId?: string; + userId?: string; } export interface TimeBucketItem { @@ -82,6 +83,6 @@ export interface IAssetRepository { findLivePhotoMatch(options: LivePhotoSearchOptions): Promise; getMapMarkers(ownerId: string, options?: MapMarkerSearchOptions): Promise; getStatistics(ownerId: string, options: AssetStatsOptions): Promise; - getTimeBuckets(userId: string, options: TimeBucketOptions): Promise; - getByTimeBucket(userId: string, timeBucket: string, options: TimeBucketOptions): Promise; + getTimeBuckets(options: TimeBucketOptions): Promise; + getByTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise; } diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 560a0d85f..fdd6df6ef 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -144,18 +144,24 @@ export class AssetService { return Promise.all(requests).then((results) => results.filter((result) => result.assets.length > 0)); } + private async timeBucketChecks(authUser: AuthUserDto, dto: TimeBucketDto) { + if (dto.albumId) { + await this.access.requirePermission(authUser, Permission.ALBUM_READ, [dto.albumId]); + } else if (dto.userId) { + await this.access.requirePermission(authUser, Permission.LIBRARY_READ, [dto.userId]); + } else { + dto.userId = authUser.id; + } + } + async getTimeBuckets(authUser: AuthUserDto, dto: TimeBucketDto): Promise { - const { userId, ...options } = dto; - const targetId = userId || authUser.id; - await this.access.requirePermission(authUser, Permission.LIBRARY_READ, [targetId]); - return this.assetRepository.getTimeBuckets(targetId, options); + await this.timeBucketChecks(authUser, dto); + return this.assetRepository.getTimeBuckets(dto); } async getByTimeBucket(authUser: AuthUserDto, dto: TimeBucketAssetDto): Promise { - const { userId, timeBucket, ...options } = dto; - const targetId = userId || authUser.id; - await this.access.requirePermission(authUser, Permission.LIBRARY_READ, [targetId]); - const assets = await this.assetRepository.getByTimeBucket(targetId, timeBucket, options); + await this.timeBucketChecks(authUser, dto); + const assets = await this.assetRepository.getByTimeBucket(dto.timeBucket, dto); return assets.map(mapAsset); } diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts index 64185ad51..80236b8d4 100644 --- a/server/src/domain/search/search.service.ts +++ b/server/src/domain/search/search.service.ts @@ -1,7 +1,7 @@ import { AlbumEntity, AssetEntity, AssetFaceEntity } from '@app/infra/entities'; import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { mapAlbum } from '../album'; +import { mapAlbumWithAssets } from '../album'; import { IAlbumRepository } from '../album/album.repository'; import { AssetResponseDto, mapAsset } from '../asset'; import { IAssetRepository } from '../asset/asset.repository'; @@ -148,7 +148,7 @@ export class SearchService { const lookup = await this.getLookupMap(assets.items.map((asset) => asset.id)); return { - albums: { ...albums, items: albums.items.map(mapAlbum) }, + albums: { ...albums, items: albums.items.map(mapAlbumWithAssets) }, assets: { ...assets, items: assets.items diff --git a/server/src/domain/shared-link/shared-link-response.dto.ts b/server/src/domain/shared-link/shared-link-response.dto.ts index 6c9832e09..f3b0d1678 100644 --- a/server/src/domain/shared-link/shared-link-response.dto.ts +++ b/server/src/domain/shared-link/shared-link-response.dto.ts @@ -1,7 +1,7 @@ import { SharedLinkEntity, SharedLinkType } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; import _ from 'lodash'; -import { AlbumResponseDto, mapAlbumExcludeAssetInfo } from '../album'; +import { AlbumResponseDto, mapAlbumWithoutAssets } from '../album'; import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from '../asset'; export class SharedLinkResponseDto { @@ -36,7 +36,7 @@ export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseD createdAt: sharedLink.createdAt, expiresAt: sharedLink.expiresAt, assets: assets.map(mapAsset), - album: sharedLink.album ? mapAlbumExcludeAssetInfo(sharedLink.album) : undefined, + album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined, allowUpload: sharedLink.allowUpload, allowDownload: sharedLink.allowDownload, showExif: sharedLink.showExif, @@ -58,7 +58,7 @@ export function mapSharedLinkWithNoExif(sharedLink: SharedLinkEntity): SharedLin createdAt: sharedLink.createdAt, expiresAt: sharedLink.expiresAt, assets: assets.map(mapAssetWithoutExif), - album: sharedLink.album ? mapAlbumExcludeAssetInfo(sharedLink.album) : undefined, + album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined, allowUpload: sharedLink.allowUpload, allowDownload: sharedLink.allowDownload, showExif: sharedLink.showExif, diff --git a/server/src/immich/controllers/album.controller.ts b/server/src/immich/controllers/album.controller.ts index 6f6ad2132..a12d047d6 100644 --- a/server/src/immich/controllers/album.controller.ts +++ b/server/src/immich/controllers/album.controller.ts @@ -1,14 +1,16 @@ import { AddUsersDto, AlbumCountResponseDto, + AlbumInfoDto, + AlbumResponseDto, AlbumService, AuthUserDto, BulkIdResponseDto, BulkIdsDto, CreateAlbumDto as CreateDto, + GetAlbumsDto, UpdateAlbumDto as UpdateDto, } from '@app/domain'; -import { GetAlbumsDto } from '@app/domain/album/dto/get-albums.dto'; import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { ParseMeUUIDPipe } from '../api-v1/validation/parse-me-uuid-pipe'; @@ -40,8 +42,8 @@ export class AlbumController { @SharedLinkRoute() @Get(':id') - getAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) { - return this.service.get(authUser, id); + getAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Query() dto: AlbumInfoDto) { + return this.service.get(authUser, id, dto); } @Patch(':id') @@ -74,7 +76,11 @@ export class AlbumController { } @Put(':id/users') - addUsersToAlbum(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: AddUsersDto) { + addUsersToAlbum( + @AuthUser() authUser: AuthUserDto, + @Param() { id }: UUIDParamDto, + @Body() dto: AddUsersDto, + ): Promise { return this.service.addUsers(authUser, id, dto); } diff --git a/server/src/infra/repositories/album.repository.ts b/server/src/infra/repositories/album.repository.ts index 850b11f26..fcf8c54c1 100644 --- a/server/src/infra/repositories/album.repository.ts +++ b/server/src/infra/repositories/album.repository.ts @@ -181,6 +181,7 @@ export class AlbumRepository implements IAlbumRepository { relations: { owner: true, sharedUsers: true, + sharedLinks: true, assets: true, }, }); diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 0fa8d8aa4..23f927347 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -366,10 +366,10 @@ export class AssetRepository implements IAssetRepository { return result; } - getTimeBuckets(userId: string, options: TimeBucketOptions): Promise { + getTimeBuckets(options: TimeBucketOptions): Promise { const truncateValue = truncateMap[options.size]; - return this.getBuilder(userId, options) + return this.getBuilder(options) .select(`COUNT(asset.id)::int`, 'count') .addSelect(`date_trunc('${truncateValue}', "fileCreatedAt")`, 'timeBucket') .groupBy(`date_trunc('${truncateValue}', "fileCreatedAt")`) @@ -377,27 +377,30 @@ export class AssetRepository implements IAssetRepository { .getRawMany(); } - getByTimeBucket(userId: string, timeBucket: string, options: TimeBucketOptions): Promise { + getByTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise { const truncateValue = truncateMap[options.size]; - return this.getBuilder(userId, options) + return this.getBuilder(options) .andWhere(`date_trunc('${truncateValue}', "fileCreatedAt") = :timeBucket`, { timeBucket }) .orderBy('asset.fileCreatedAt', 'DESC') .getMany(); } - private getBuilder(userId: string, options: TimeBucketOptions) { - const { isArchived, isFavorite, albumId, personId } = options; + private getBuilder(options: TimeBucketOptions) { + const { isArchived, isFavorite, albumId, personId, userId } = options; let builder = this.repository .createQueryBuilder('asset') - .where('asset.ownerId = :userId', { userId }) - .andWhere('asset.isVisible = true') + .where('asset.isVisible = true') .leftJoinAndSelect('asset.exifInfo', 'exifInfo'); if (albumId) { builder = builder.leftJoin('asset.albums', 'album').andWhere('album.id = :albumId', { albumId }); } + if (userId) { + builder = builder.where('asset.ownerId = :userId', { userId }); + } + if (isArchived != undefined) { builder = builder.andWhere('asset.isArchived = :isArchived', { isArchived }); } diff --git a/server/test/e2e/album.e2e-spec.ts b/server/test/e2e/album.e2e-spec.ts index ffde674f1..f50c850ca 100644 --- a/server/test/e2e/album.e2e-spec.ts +++ b/server/test/e2e/album.e2e-spec.ts @@ -197,6 +197,7 @@ describe(`${AlbumController.name} (e2e)`, () => { albumThumbnailAssetId: null, shared: false, sharedUsers: [], + hasSharedLink: false, assets: [], assetCount: 0, owner: expect.objectContaining({ email: user1.userEmail }), diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 004df894d..e85b000ce 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -77,6 +77,7 @@ const albumResponse: AlbumResponseDto = { owner: mapUser(userStub.admin), sharedUsers: [], shared: false, + hasSharedLink: false, assets: [], assetCount: 1, }; @@ -278,7 +279,7 @@ export const sharedLinkResponseStub = { allowUpload: false, allowDownload: false, showExif: false, - album: albumResponse, + album: { ...albumResponse, startDate: assetResponse.fileCreatedAt, endDate: assetResponse.fileCreatedAt }, assets: [{ ...assetResponse, exifInfo: undefined }], }), }; diff --git a/web/.prettierrc b/web/.prettierrc index 4d8d9e29b..281bce185 100644 --- a/web/.prettierrc +++ b/web/.prettierrc @@ -4,5 +4,6 @@ "printWidth": 120, "semi": true, "organizeImportsSkipDestructiveCodeActions": true, - "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"] + "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], + "pluginSearchDirs": false } diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index c58a4a4c5..9cedbdf2c 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -216,6 +216,18 @@ export interface AlbumResponseDto { * @memberof AlbumResponseDto */ 'description': string; + /** + * + * @type {string} + * @memberof AlbumResponseDto + */ + 'endDate'?: string; + /** + * + * @type {boolean} + * @memberof AlbumResponseDto + */ + 'hasSharedLink': boolean; /** * * @type {string} @@ -252,6 +264,12 @@ export interface AlbumResponseDto { * @memberof AlbumResponseDto */ 'sharedUsers': Array; + /** + * + * @type {string} + * @memberof AlbumResponseDto + */ + 'startDate'?: string; /** * * @type {string} @@ -3899,11 +3917,12 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration /** * * @param {string} id + * @param {boolean} [withoutAssets] * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getAlbumInfo: async (id: string, key?: string, options: AxiosRequestConfig = {}): Promise => { + getAlbumInfo: async (id: string, withoutAssets?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'id' is not null or undefined assertParamExists('getAlbumInfo', 'id', id) const localVarPath = `/album/{id}` @@ -3928,6 +3947,10 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration // http bearer authentication required await setBearerAuthToObject(localVarHeaderParameter, configuration) + if (withoutAssets !== undefined) { + localVarQueryParameter['withoutAssets'] = withoutAssets; + } + if (key !== undefined) { localVarQueryParameter['key'] = key; } @@ -4198,12 +4221,13 @@ export const AlbumApiFp = function(configuration?: Configuration) { /** * * @param {string} id + * @param {boolean} [withoutAssets] * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getAlbumInfo(id: string, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getAlbumInfo(id, key, options); + async getAlbumInfo(id: string, withoutAssets?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getAlbumInfo(id, withoutAssets, key, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -4311,7 +4335,7 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath * @throws {RequiredError} */ getAlbumInfo(requestParameters: AlbumApiGetAlbumInfoRequest, options?: AxiosRequestConfig): AxiosPromise { - return localVarFp.getAlbumInfo(requestParameters.id, requestParameters.key, options).then((request) => request(axios, basePath)); + return localVarFp.getAlbumInfo(requestParameters.id, requestParameters.withoutAssets, requestParameters.key, options).then((request) => request(axios, basePath)); }, /** * @@ -4442,6 +4466,13 @@ export interface AlbumApiGetAlbumInfoRequest { */ readonly id: string + /** + * + * @type {boolean} + * @memberof AlbumApiGetAlbumInfo + */ + readonly withoutAssets?: boolean + /** * * @type {string} @@ -4603,7 +4634,7 @@ export class AlbumApi extends BaseAPI { * @memberof AlbumApi */ public getAlbumInfo(requestParameters: AlbumApiGetAlbumInfoRequest, options?: AxiosRequestConfig) { - return AlbumApiFp(this.configuration).getAlbumInfo(requestParameters.id, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); + return AlbumApiFp(this.configuration).getAlbumInfo(requestParameters.id, requestParameters.withoutAssets, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); } /** diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index c81f3462d..c0ad198a4 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -1,90 +1,29 @@ -
- +
{#if isMultiSelectionMode} (multiSelectAsset = new Set())}> - {#if sharedLink?.allowDownload || !isPublicShared} - - {/if} - {#if isOwned || isMultiSelectionUserOwned} - + {#if sharedLink.allowDownload} + {/if} - {/if} - - - {#if !isMultiSelectionMode} - goto(backUrl)} - backIcon={ArrowLeft} - showBackButton={(!isPublicShared && isOwned) || (!isPublicShared && !isOwned) || (isPublicShared && isOwned)} - > + {:else} + - {#if isPublicShared && !isOwned} - - -

IMMICH

-
- {/if} + + +

IMMICH

+
- {#if !isCreatingSharedAlbum} - {#if !sharedLink} - (isShowAssetSelection = true)} - logo={FileImagePlusOutline} - /> - {:else if sharedLink?.allowUpload} - openFileUploadDialog(album.id, sharedLink?.key)} - logo={FileImagePlusOutline} - /> - {/if} - - {#if isOwned} - (isShowShareUserSelection = true)} - logo={ShareVariantOutline} - /> - (isShowDeleteConfirmation = true)} - logo={DeleteOutline} - /> - {/if} + {#if sharedLink.allowUpload} + openFileUploadDialog(album.id, sharedLink.key)} + logo={FileImagePlusOutline} + /> {/if} - {#if album.assetCount > 0 && !isCreatingSharedAlbum} - {#if !isPublicShared || (isPublicShared && sharedLink?.allowDownload)} - downloadAlbum()} logo={FolderDownloadOutline} /> - {/if} - - {#if !isPublicShared && isOwned} - - {#if isShowAlbumOptions} - (isShowAlbumOptions = false)}> - { - isShowThumbnailSelection = true; - isShowAlbumOptions = false; - }} - text="Set album cover" - /> - - {/if} - - {/if} + {#if album.assetCount > 0 && sharedLink.allowDownload} + downloadAlbum()} logo={FolderDownloadOutline} /> {/if} - {#if isPublicShared} - - {/if} - - {#if isCreatingSharedAlbum && album.sharedUsers.length == 0} - - {/if} +
{/if}
- { - if (e.key == 'Enter') { - isEditingTitle = false; - titleInput.blur(); - } - }} - on:focus={() => (isEditingTitle = true)} - on:blur={() => (isEditingTitle = false)} - class={`w-[99%] border-b-2 border-transparent text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary ${ - isOwned ? 'hover:border-gray-400' : 'hover:border-transparent' - } bg-immich-bg focus:border-b-2 focus:border-immich-primary focus:outline-none dark:bg-immich-dark-bg dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray`} - type="text" - bind:value={album.albumName} - disabled={!isOwned} - bind:this={titleInput} - title="Edit Title" - /> +

+ {album.albumName} +

{#if album.assetCount > 0} @@ -456,108 +133,12 @@

{album.assetCount} items

{/if} - {#if album.shared} -
- {#each album.sharedUsers as user (user.id)} - - {/each} - - -
- {/if} - +

+ {album.description} +

- {#if album.assetCount > 0 && !isShowAssetSelection} - - {:else} - -
-
-

ADD PHOTOS

- -
-
- {/if} +
- -{#if isShowAssetSelection} - (isShowAssetSelection = false)} - on:create-album={createAlbumHandler} - /> -{/if} - -{#if isShowShareUserSelection} - (isShowShareUserSelection = false)} - on:add-user={addUserHandler} - on:sharedlinkclick={onSharedLinkClickHandler} - sharedUsersInAlbum={new Set(album.sharedUsers)} - /> -{/if} - -{#if isShowShareLinkModal} - (isShowShareLinkModal = false)} shareType={SharedLinkType.Album} {album} /> -{/if} - -{#if isShowShareInfoModal} - (isShowShareInfoModal = false)} {album} on:user-deleted={sharedUserDeletedHandler} /> -{/if} - -{#if isShowThumbnailSelection} - (isShowThumbnailSelection = false)} - on:thumbnail-selected={setAlbumThumbnailHandler} - /> -{/if} - -{#if isShowDeleteConfirmation} - (isShowDeleteConfirmation = false)} - > - -

Are you sure you want to delete the album {album.albumName}?

-

If this album is shared, other users will not be able to access it anymore.

-
-
-{/if} - -{#if isEditingDescription} - (isEditingDescription = false)} - on:updated={({ detail: description }) => descriptionUpdatedHandler(description)} - /> -{/if} diff --git a/web/src/lib/components/album-page/asset-selection.svelte b/web/src/lib/components/album-page/asset-selection.svelte deleted file mode 100644 index bac374664..000000000 --- a/web/src/lib/components/album-page/asset-selection.svelte +++ /dev/null @@ -1,74 +0,0 @@ - - -
- { - assetInteractionStore.clearMultiselect(); - dispatch('go-back'); - }} - > - - {#if $selectedAssets.size == 0} -

Add to album

- {:else} -

- {$selectedAssets.size.toLocaleString($locale)} selected -

- {/if} -
- - - - - -
-
- -
-
diff --git a/web/src/lib/components/album-page/share-info-modal.svelte b/web/src/lib/components/album-page/share-info-modal.svelte index b3849effb..4e0ccab55 100644 --- a/web/src/lib/components/album-page/share-info-modal.svelte +++ b/web/src/lib/components/album-page/share-info-modal.svelte @@ -13,7 +13,10 @@ export let album: AlbumResponseDto; - const dispatch = createEventDispatcher(); + const dispatch = createEventDispatcher<{ + remove: string; + close: void; + }>(); let currentUser: UserResponseDto; let position = { x: 0, y: 0 }; @@ -59,7 +62,7 @@ try { await api.albumApi.removeUserFromAlbum({ id: album.id, userId }); - dispatch('user-deleted', { userId }); + dispatch('remove', userId); const message = userId === 'me' ? `Left ${album.albumName}` : `Removed ${selectedRemoveUser.firstName}`; notificationController.show({ type: NotificationType.Info, message }); } catch (e) { @@ -79,6 +82,16 @@
+
+
+ +

{album.owner.firstName} {album.owner.lastName}

+
+ +
+

Owner

+
+
{#each album.sharedUsers as user}
{user.firstName} {user.lastName}

-
+
{#if isOwned}
; let users: UserResponseDto[] = []; let selectedUsers: UserResponseDto[] = []; - const dispatch = createEventDispatcher(); + const dispatch = createEventDispatcher<{ + select: UserResponseDto[]; + share: void; + close: void; + }>(); let sharedLinks: SharedLinkResponseDto[] = []; onMount(async () => { await getSharedLinks(); @@ -25,7 +28,7 @@ users = data.filter((user) => !(user.deletedAt || user.id === album.ownerId)); // Remove the existed shared users from the album - sharedUsersInAlbum.forEach((sharedUser) => { + album.sharedUsers.forEach((sharedUser) => { users = users.filter((user) => user.id !== sharedUser.id); }); }); @@ -36,7 +39,7 @@ sharedLinks = data.filter((link) => link.album?.id === album.id); }; - const selectUser = (user: UserResponseDto) => { + const handleSelect = (user: UserResponseDto) => { if (selectedUsers.includes(user)) { selectedUsers = selectedUsers.filter((selectedUser) => selectedUser.id !== user.id); } else { @@ -44,13 +47,9 @@ } }; - const deselectUser = (user: UserResponseDto) => { + const handleUnselect = (user: UserResponseDto) => { selectedUsers = selectedUsers.filter((selectedUser) => selectedUser.id !== user.id); }; - - const onSharedLinkClick = () => { - dispatch('sharedlinkclick'); - }; dispatch('close')}> @@ -69,7 +68,7 @@ {#each selectedUsers as user} {#key user.id} +
{/if}
@@ -127,7 +126,7 @@
+ {/if} + + + {/if} + + {#if viewMode === ViewMode.SELECT_ASSETS} + + +

+ {#if $timelineSelected.size == 0} + Add to album + {:else} + {$timelineSelected.size.toLocaleString($locale)} selected + {/if} +

+
+ + + + + +
+ {/if} + + {#if viewMode === ViewMode.SELECT_THUMBNAIL} + (viewMode = ViewMode.VIEW)}> + Select Album Cover + + {/if} + {/if} + + +
+ {#if viewMode === ViewMode.SELECT_ASSETS} + + {:else} + handleUpdateThumbnail(asset.id)} + > + {#if viewMode !== ViewMode.SELECT_THUMBNAIL} + +
+ e.key == 'Enter' && titleInput.blur()} + on:blur={handleUpdateName} + class="w-[99%] border-b-2 border-transparent text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary {isOwned + ? 'hover:border-gray-400' + : 'hover:border-transparent'} bg-immich-bg focus:border-b-2 focus:border-immich-primary focus:outline-none dark:bg-immich-dark-bg dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray" + type="text" + bind:value={album.albumName} + disabled={!isOwned} + bind:this={titleInput} + title="Edit Title" + /> + + + {#if album.assetCount > 0} + +

{getDateRange()}

+

·

+

{album.assetCount} items

+
+ {/if} + + + {#if album.sharedUsers.length > 0 || (album.hasSharedLink && isOwned)} +
+ + {#if album.hasSharedLink && isOwned} + (viewMode = ViewMode.LINK_SHARING)} + /> + {/if} + + + + + + {#each album.sharedUsers as user (user.id)} + + {/each} + + {#if isOwned} + (viewMode = ViewMode.SELECT_USERS)} + title="Add more users" + /> + {/if} +
+ {/if} + + + {#if isOwned || album.description} + + {/if} +
+ {/if} + + {#if album.assetCount === 0} +
+
+

ADD PHOTOS

+ +
+
+ {/if} +
+ {/if} +
+ +{#if viewMode === ViewMode.SELECT_USERS} + handleAddUsers(users)} + on:share={() => (viewMode = ViewMode.LINK_SHARING)} + on:close={() => (viewMode = ViewMode.VIEW)} + /> +{/if} + +{#if viewMode === ViewMode.LINK_SHARING} + (viewMode = ViewMode.VIEW)} /> +{/if} + +{#if viewMode === ViewMode.VIEW_USERS} + (viewMode = ViewMode.VIEW)} + {album} + on:remove={({ detail: userId }) => handleRemoveUser(userId)} + /> +{/if} + +{#if viewMode === ViewMode.CONFIRM_DELETE} + (viewMode = ViewMode.VIEW)} + > + +

Are you sure you want to delete the album {album.albumName}?

+

If this album is shared, other users will not be able to access it anymore.

+
+
+{/if} + +{#if isEditingDescription} + (isEditingDescription = false)} + on:updated={({ detail: description }) => handleUpdateDescription(description)} + /> +{/if} diff --git a/web/src/routes/(user)/sharing/+page.svelte b/web/src/routes/(user)/sharing/+page.svelte index 148c783c7..5bc8495e4 100644 --- a/web/src/routes/(user)/sharing/+page.svelte +++ b/web/src/routes/(user)/sharing/+page.svelte @@ -68,7 +68,7 @@ href="/partners/{partner.id}" class="flex gap-4 rounded-lg px-5 py-4 transition-all hover:bg-gray-200 dark:hover:bg-gray-700" > - +

{partner.firstName} diff --git a/web/src/routes/(user)/sharing/sharedlinks/+page.svelte b/web/src/routes/(user)/sharing/sharedlinks/+page.svelte index b1a3e346d..cb12b83de 100644 --- a/web/src/routes/(user)/sharing/sharedlinks/+page.svelte +++ b/web/src/routes/(user)/sharing/sharedlinks/+page.svelte @@ -85,12 +85,7 @@

{#if editSharedLink} - + {/if} {#if deleteLinkId} diff --git a/web/src/test-data/factories/album-factory.ts b/web/src/test-data/factories/album-factory.ts index dfa5e530e..d87a09626 100644 --- a/web/src/test-data/factories/album-factory.ts +++ b/web/src/test-data/factories/album-factory.ts @@ -16,4 +16,5 @@ export const albumFactory = Sync.makeFactory({ owner: userFactory.build(), shared: false, sharedUsers: [], + hasSharedLink: false, });