mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-01 02:17:43 +09:00 
			
		
		
		
	feat(web): Remove from Stack (#19703)
* - add component - update server's StackCreateDto for merge parameter - Update stackRepo to only merge stacks when merge=true (default) - update web action handlers to show stack changes * - make open-api * lint & format * - Add proper icon to 'remove from stack' - change web unstack icon to image-off-outline * - cleanup * - format & lint * - make open-api: StackCreateDto merge optional * initial addition of new endpoint * remove stack endpoint * - fix up remove stack endpoint - open-api * - Undo stackCreate merge parameter * - open-api typescript * open-api dart * Tests: - add tests - update assetStub.imageFrom2015 to have required stack attributes to include it with tests * update event name * Fix event name in test * remove asset_update check * - merge stack.removeAsset params into one object - refactor asset existence check (no need for asset fetch) - fix tests * Don't return updated stack * Create specialized stack id & primary asset fetch for asset removal checks * Correct new permission names * make sql * - fix open-api * - cleanup
This commit is contained in:
		
							
								
								
									
										1
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							| @@ -221,6 +221,7 @@ Class | Method | HTTP request | Description | |||||||
| *StacksApi* | [**deleteStack**](doc//StacksApi.md#deletestack) | **DELETE** /stacks/{id} |  | *StacksApi* | [**deleteStack**](doc//StacksApi.md#deletestack) | **DELETE** /stacks/{id} |  | ||||||
| *StacksApi* | [**deleteStacks**](doc//StacksApi.md#deletestacks) | **DELETE** /stacks |  | *StacksApi* | [**deleteStacks**](doc//StacksApi.md#deletestacks) | **DELETE** /stacks |  | ||||||
| *StacksApi* | [**getStack**](doc//StacksApi.md#getstack) | **GET** /stacks/{id} |  | *StacksApi* | [**getStack**](doc//StacksApi.md#getstack) | **GET** /stacks/{id} |  | ||||||
|  | *StacksApi* | [**removeAssetFromStack**](doc//StacksApi.md#removeassetfromstack) | **DELETE** /stacks/{id}/assets/{assetId} |  | ||||||
| *StacksApi* | [**searchStacks**](doc//StacksApi.md#searchstacks) | **GET** /stacks |  | *StacksApi* | [**searchStacks**](doc//StacksApi.md#searchstacks) | **GET** /stacks |  | ||||||
| *StacksApi* | [**updateStack**](doc//StacksApi.md#updatestack) | **PUT** /stacks/{id} |  | *StacksApi* | [**updateStack**](doc//StacksApi.md#updatestack) | **PUT** /stacks/{id} |  | ||||||
| *SyncApi* | [**deleteSyncAck**](doc//SyncApi.md#deletesyncack) | **DELETE** /sync/ack |  | *SyncApi* | [**deleteSyncAck**](doc//SyncApi.md#deletesyncack) | **DELETE** /sync/ack |  | ||||||
|   | |||||||
							
								
								
									
										45
									
								
								mobile/openapi/lib/api/stacks_api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										45
									
								
								mobile/openapi/lib/api/stacks_api.dart
									
									
									
										generated
									
									
									
								
							| @@ -190,6 +190,51 @@ class StacksApi { | |||||||
|     return null; |     return null; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   /// Performs an HTTP 'DELETE /stacks/{id}/assets/{assetId}' operation and returns the [Response]. | ||||||
|  |   /// Parameters: | ||||||
|  |   /// | ||||||
|  |   /// * [String] assetId (required): | ||||||
|  |   /// | ||||||
|  |   /// * [String] id (required): | ||||||
|  |   Future<Response> removeAssetFromStackWithHttpInfo(String assetId, String id,) async { | ||||||
|  |     // ignore: prefer_const_declarations | ||||||
|  |     final apiPath = r'/stacks/{id}/assets/{assetId}' | ||||||
|  |       .replaceAll('{assetId}', assetId) | ||||||
|  |       .replaceAll('{id}', id); | ||||||
|  | 
 | ||||||
|  |     // ignore: prefer_final_locals | ||||||
|  |     Object? postBody; | ||||||
|  | 
 | ||||||
|  |     final queryParams = <QueryParam>[]; | ||||||
|  |     final headerParams = <String, String>{}; | ||||||
|  |     final formParams = <String, String>{}; | ||||||
|  | 
 | ||||||
|  |     const contentTypes = <String>[]; | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     return apiClient.invokeAPI( | ||||||
|  |       apiPath, | ||||||
|  |       'DELETE', | ||||||
|  |       queryParams, | ||||||
|  |       postBody, | ||||||
|  |       headerParams, | ||||||
|  |       formParams, | ||||||
|  |       contentTypes.isEmpty ? null : contentTypes.first, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// Parameters: | ||||||
|  |   /// | ||||||
|  |   /// * [String] assetId (required): | ||||||
|  |   /// | ||||||
|  |   /// * [String] id (required): | ||||||
|  |   Future<void> removeAssetFromStack(String assetId, String id,) async { | ||||||
|  |     final response = await removeAssetFromStackWithHttpInfo(assetId, id,); | ||||||
|  |     if (response.statusCode >= HttpStatus.badRequest) { | ||||||
|  |       throw ApiException(response.statusCode, await _decodeBodyBytes(response)); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   /// Performs an HTTP 'GET /stacks' operation and returns the [Response]. |   /// Performs an HTTP 'GET /stacks' operation and returns the [Response]. | ||||||
|   /// Parameters: |   /// Parameters: | ||||||
|   /// |   /// | ||||||
|   | |||||||
| @@ -6746,6 +6746,50 @@ | |||||||
|         ] |         ] | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|  |     "/stacks/{id}/assets/{assetId}": { | ||||||
|  |       "delete": { | ||||||
|  |         "operationId": "removeAssetFromStack", | ||||||
|  |         "parameters": [ | ||||||
|  |           { | ||||||
|  |             "name": "assetId", | ||||||
|  |             "required": true, | ||||||
|  |             "in": "path", | ||||||
|  |             "schema": { | ||||||
|  |               "format": "uuid", | ||||||
|  |               "type": "string" | ||||||
|  |             } | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             "name": "id", | ||||||
|  |             "required": true, | ||||||
|  |             "in": "path", | ||||||
|  |             "schema": { | ||||||
|  |               "format": "uuid", | ||||||
|  |               "type": "string" | ||||||
|  |             } | ||||||
|  |           } | ||||||
|  |         ], | ||||||
|  |         "responses": { | ||||||
|  |           "204": { | ||||||
|  |             "description": "" | ||||||
|  |           } | ||||||
|  |         }, | ||||||
|  |         "security": [ | ||||||
|  |           { | ||||||
|  |             "bearer": [] | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             "cookie": [] | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             "api_key": [] | ||||||
|  |           } | ||||||
|  |         ], | ||||||
|  |         "tags": [ | ||||||
|  |           "Stacks" | ||||||
|  |         ] | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|     "/sync/ack": { |     "/sync/ack": { | ||||||
|       "delete": { |       "delete": { | ||||||
|         "operationId": "deleteSyncAck", |         "operationId": "deleteSyncAck", | ||||||
|   | |||||||
| @@ -3373,6 +3373,15 @@ export function updateStack({ id, stackUpdateDto }: { | |||||||
|         body: stackUpdateDto |         body: stackUpdateDto | ||||||
|     }))); |     }))); | ||||||
| } | } | ||||||
|  | export function removeAssetFromStack({ assetId, id }: { | ||||||
|  |     assetId: string; | ||||||
|  |     id: string; | ||||||
|  | }, opts?: Oazapfts.RequestOpts) { | ||||||
|  |     return oazapfts.ok(oazapfts.fetchText(`/stacks/${encodeURIComponent(id)}/assets/${encodeURIComponent(assetId)}`, { | ||||||
|  |         ...opts, | ||||||
|  |         method: "DELETE" | ||||||
|  |     })); | ||||||
|  | } | ||||||
| export function deleteSyncAck({ syncAckDeleteDto }: { | export function deleteSyncAck({ syncAckDeleteDto }: { | ||||||
|     syncAckDeleteDto: SyncAckDeleteDto; |     syncAckDeleteDto: SyncAckDeleteDto; | ||||||
| }, opts?: Oazapfts.RequestOpts) { | }, opts?: Oazapfts.RequestOpts) { | ||||||
|   | |||||||
| @@ -6,7 +6,7 @@ import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto } from | |||||||
| import { Permission } from 'src/enum'; | import { Permission } from 'src/enum'; | ||||||
| import { Auth, Authenticated } from 'src/middleware/auth.guard'; | import { Auth, Authenticated } from 'src/middleware/auth.guard'; | ||||||
| import { StackService } from 'src/services/stack.service'; | import { StackService } from 'src/services/stack.service'; | ||||||
| import { UUIDParamDto } from 'src/validation'; | import { UUIDAssetIDParamDto, UUIDParamDto } from 'src/validation'; | ||||||
|  |  | ||||||
| @ApiTags('Stacks') | @ApiTags('Stacks') | ||||||
| @Controller('stacks') | @Controller('stacks') | ||||||
| @@ -54,4 +54,11 @@ export class StackController { | |||||||
|   deleteStack(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> { |   deleteStack(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> { | ||||||
|     return this.service.delete(auth, id); |     return this.service.delete(auth, id); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   @Delete(':id/assets/:assetId') | ||||||
|  |   @Authenticated({ permission: Permission.StackUpdate }) | ||||||
|  |   @HttpCode(HttpStatus.NO_CONTENT) | ||||||
|  |   removeAssetFromStack(@Auth() auth: AuthDto, @Param() dto: UUIDAssetIDParamDto): Promise<void> { | ||||||
|  |     return this.service.removeAsset(auth, dto); | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -143,3 +143,13 @@ from | |||||||
|   "stack" |   "stack" | ||||||
| where | where | ||||||
|   "id" = $1::uuid |   "id" = $1::uuid | ||||||
|  |  | ||||||
|  | -- StackRepository.getForAssetRemoval | ||||||
|  | select | ||||||
|  |   "stackId" as "id", | ||||||
|  |   "stack"."primaryAssetId" | ||||||
|  | from | ||||||
|  |   "asset" | ||||||
|  |   left join "stack" on "stack"."id" = "asset"."stackId" | ||||||
|  | where | ||||||
|  |   "asset"."id" = $1 | ||||||
|   | |||||||
| @@ -152,4 +152,14 @@ export class StackRepository { | |||||||
|       .where('id', '=', asUuid(id)) |       .where('id', '=', asUuid(id)) | ||||||
|       .executeTakeFirst(); |       .executeTakeFirst(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) | ||||||
|  |   getForAssetRemoval(assetId: string) { | ||||||
|  |     return this.db | ||||||
|  |       .selectFrom('asset') | ||||||
|  |       .leftJoin('stack', 'stack.id', 'asset.stackId') | ||||||
|  |       .select(['stackId as id', 'stack.primaryAssetId']) | ||||||
|  |       .where('asset.id', '=', assetId) | ||||||
|  |       .executeTakeFirst(); | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -188,4 +188,53 @@ describe(StackService.name, () => { | |||||||
|       }); |       }); | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|  |   describe('removeAsset', () => { | ||||||
|  |     it('should require stack.update permissions', async () => { | ||||||
|  |       await expect(sut.removeAsset(authStub.admin, { id: 'stack-id', assetId: 'asset-id' })).rejects.toBeInstanceOf( | ||||||
|  |         BadRequestException, | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       expect(mocks.stack.getForAssetRemoval).not.toHaveBeenCalled(); | ||||||
|  |       expect(mocks.asset.update).not.toHaveBeenCalled(); | ||||||
|  |       expect(mocks.event.emit).not.toHaveBeenCalled(); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should fail if the asset is not in the stack', async () => { | ||||||
|  |       mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); | ||||||
|  |       mocks.stack.getForAssetRemoval.mockResolvedValue({ id: null, primaryAssetId: null }); | ||||||
|  |  | ||||||
|  |       await expect( | ||||||
|  |         sut.removeAsset(authStub.admin, { id: 'stack-id', assetId: assetStub.imageFrom2015.id }), | ||||||
|  |       ).rejects.toBeInstanceOf(BadRequestException); | ||||||
|  |  | ||||||
|  |       expect(mocks.asset.update).not.toHaveBeenCalled(); | ||||||
|  |       expect(mocks.event.emit).not.toHaveBeenCalled(); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should fail if the assetId is the primaryAssetId', async () => { | ||||||
|  |       mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); | ||||||
|  |       mocks.stack.getForAssetRemoval.mockResolvedValue({ id: 'stack-id', primaryAssetId: assetStub.image.id }); | ||||||
|  |  | ||||||
|  |       await expect( | ||||||
|  |         sut.removeAsset(authStub.admin, { id: 'stack-id', assetId: assetStub.image.id }), | ||||||
|  |       ).rejects.toBeInstanceOf(BadRequestException); | ||||||
|  |  | ||||||
|  |       expect(mocks.asset.update).not.toHaveBeenCalled(); | ||||||
|  |       expect(mocks.event.emit).not.toHaveBeenCalled(); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it("should update the asset to nullify it's stack-id", async () => { | ||||||
|  |       mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); | ||||||
|  |       mocks.stack.getForAssetRemoval.mockResolvedValue({ id: 'stack-id', primaryAssetId: assetStub.image.id }); | ||||||
|  |  | ||||||
|  |       await sut.removeAsset(authStub.admin, { id: 'stack-id', assetId: assetStub.image1.id }); | ||||||
|  |  | ||||||
|  |       expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image1.id, stackId: null }); | ||||||
|  |       expect(mocks.event.emit).toHaveBeenCalledWith('StackUpdate', { | ||||||
|  |         stackId: 'stack-id', | ||||||
|  |         userId: authStub.admin.user.id, | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -4,6 +4,7 @@ import { AuthDto } from 'src/dtos/auth.dto'; | |||||||
| import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto, mapStack } from 'src/dtos/stack.dto'; | import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto, mapStack } from 'src/dtos/stack.dto'; | ||||||
| import { Permission } from 'src/enum'; | import { Permission } from 'src/enum'; | ||||||
| import { BaseService } from 'src/services/base.service'; | import { BaseService } from 'src/services/base.service'; | ||||||
|  | import { UUIDAssetIDParamDto } from 'src/validation'; | ||||||
|  |  | ||||||
| @Injectable() | @Injectable() | ||||||
| export class StackService extends BaseService { | export class StackService extends BaseService { | ||||||
| @@ -58,6 +59,24 @@ export class StackService extends BaseService { | |||||||
|     await this.eventRepository.emit('StackDeleteAll', { stackIds: dto.ids, userId: auth.user.id }); |     await this.eventRepository.emit('StackDeleteAll', { stackIds: dto.ids, userId: auth.user.id }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   async removeAsset(auth: AuthDto, dto: UUIDAssetIDParamDto): Promise<void> { | ||||||
|  |     const { id: stackId, assetId } = dto; | ||||||
|  |     await this.requireAccess({ auth, permission: Permission.StackUpdate, ids: [stackId] }); | ||||||
|  |  | ||||||
|  |     const stack = await this.stackRepository.getForAssetRemoval(assetId); | ||||||
|  |  | ||||||
|  |     if (!stack?.id || stack.id !== stackId) { | ||||||
|  |       throw new BadRequestException('Asset not in stack'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (stack.primaryAssetId === assetId) { | ||||||
|  |       throw new BadRequestException("Cannot remove stack's primary asset"); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     await this.assetRepository.update({ id: assetId, stackId: null }); | ||||||
|  |     await this.eventRepository.emit('StackUpdate', { stackId, userId: auth.user.id }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   private async findOrFail(id: string) { |   private async findOrFail(id: string) { | ||||||
|     const stack = await this.stackRepository.getById(id); |     const stack = await this.stackRepository.getById(id); | ||||||
|     if (!stack) { |     if (!stack) { | ||||||
|   | |||||||
| @@ -63,6 +63,22 @@ export class FileNotEmptyValidator extends FileValidator { | |||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | type UUIDOptions = { optional?: boolean; each?: boolean; nullable?: boolean }; | ||||||
|  | export const ValidateUUID = (options?: UUIDOptions & ApiPropertyOptions) => { | ||||||
|  |   const { optional, each, nullable, ...apiPropertyOptions } = { | ||||||
|  |     optional: false, | ||||||
|  |     each: false, | ||||||
|  |     nullable: false, | ||||||
|  |     ...options, | ||||||
|  |   }; | ||||||
|  |   return applyDecorators( | ||||||
|  |     IsUUID('4', { each }), | ||||||
|  |     ApiProperty({ format: 'uuid', ...apiPropertyOptions }), | ||||||
|  |     optional ? Optional({ nullable }) : IsNotEmpty(), | ||||||
|  |     each ? IsArray() : IsString(), | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
| export class UUIDParamDto { | export class UUIDParamDto { | ||||||
|   @IsNotEmpty() |   @IsNotEmpty() | ||||||
|   @IsUUID('4') |   @IsUUID('4') | ||||||
| @@ -70,6 +86,14 @@ export class UUIDParamDto { | |||||||
|   id!: string; |   id!: string; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | export class UUIDAssetIDParamDto { | ||||||
|  |   @ValidateUUID() | ||||||
|  |   id!: string; | ||||||
|  |  | ||||||
|  |   @ValidateUUID() | ||||||
|  |   assetId!: string; | ||||||
|  | } | ||||||
|  |  | ||||||
| type PinCodeOptions = { optional?: boolean } & OptionalOptions; | type PinCodeOptions = { optional?: boolean } & OptionalOptions; | ||||||
| export const PinCode = (options?: PinCodeOptions & ApiPropertyOptions) => { | export const PinCode = (options?: PinCodeOptions & ApiPropertyOptions) => { | ||||||
|   const { optional, nullable, emptyToNull, ...apiPropertyOptions } = { |   const { optional, nullable, emptyToNull, ...apiPropertyOptions } = { | ||||||
| @@ -131,22 +155,6 @@ export const ValidateHexColor = () => { | |||||||
|   return applyDecorators(...decorators); |   return applyDecorators(...decorators); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| type UUIDOptions = { optional?: boolean; each?: boolean; nullable?: boolean }; |  | ||||||
| export const ValidateUUID = (options?: UUIDOptions & ApiPropertyOptions) => { |  | ||||||
|   const { optional, each, nullable, ...apiPropertyOptions } = { |  | ||||||
|     optional: false, |  | ||||||
|     each: false, |  | ||||||
|     nullable: false, |  | ||||||
|     ...options, |  | ||||||
|   }; |  | ||||||
|   return applyDecorators( |  | ||||||
|     IsUUID('4', { each }), |  | ||||||
|     ApiProperty({ format: 'uuid', ...apiPropertyOptions }), |  | ||||||
|     optional ? Optional({ nullable }) : IsNotEmpty(), |  | ||||||
|     each ? IsArray() : IsString(), |  | ||||||
|   ); |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| type DateOptions = { optional?: boolean; nullable?: boolean; format?: 'date' | 'date-time' }; | type DateOptions = { optional?: boolean; nullable?: boolean; format?: 'date' | 'date-time' }; | ||||||
| export const ValidateDate = (options?: DateOptions & ApiPropertyOptions) => { | export const ValidateDate = (options?: DateOptions & ApiPropertyOptions) => { | ||||||
|   const { optional, nullable, format, ...apiPropertyOptions } = { |   const { optional, nullable, format, ...apiPropertyOptions } = { | ||||||
|   | |||||||
							
								
								
									
										5
									
								
								server/test/fixtures/asset.stub.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								server/test/fixtures/asset.stub.ts
									
									
									
									
										vendored
									
									
								
							| @@ -462,7 +462,7 @@ export const assetStub = { | |||||||
|   }), |   }), | ||||||
|  |  | ||||||
|   imageFrom2015: Object.freeze({ |   imageFrom2015: Object.freeze({ | ||||||
|     id: 'asset-id-1', |     id: 'asset-id-2015', | ||||||
|     status: AssetStatus.Active, |     status: AssetStatus.Active, | ||||||
|     deviceAssetId: 'device-asset-id', |     deviceAssetId: 'device-asset-id', | ||||||
|     fileModifiedAt: new Date('2015-02-23T05:06:29.716Z'), |     fileModifiedAt: new Date('2015-02-23T05:06:29.716Z'), | ||||||
| @@ -484,6 +484,9 @@ export const assetStub = { | |||||||
|     duration: null, |     duration: null, | ||||||
|     livePhotoVideo: null, |     livePhotoVideo: null, | ||||||
|     livePhotoVideoId: null, |     livePhotoVideoId: null, | ||||||
|  |     updateId: 'foo', | ||||||
|  |     libraryId: null, | ||||||
|  |     stackId: null, | ||||||
|     sharedLinks: [], |     sharedLinks: [], | ||||||
|     originalFileName: 'asset-id.ext', |     originalFileName: 'asset-id.ext', | ||||||
|     faces: [], |     faces: [], | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| import type { AssetAction } from '$lib/constants'; | import type { AssetAction } from '$lib/constants'; | ||||||
| import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; | import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; | ||||||
| import type { AlbumResponseDto, StackResponseDto } from '@immich/sdk'; | import type { AlbumResponseDto, AssetResponseDto, StackResponseDto } from '@immich/sdk'; | ||||||
|  |  | ||||||
| type ActionMap = { | type ActionMap = { | ||||||
|   [AssetAction.ARCHIVE]: { asset: TimelineAsset }; |   [AssetAction.ARCHIVE]: { asset: TimelineAsset }; | ||||||
| @@ -15,6 +15,7 @@ type ActionMap = { | |||||||
|   [AssetAction.UNSTACK]: { assets: TimelineAsset[] }; |   [AssetAction.UNSTACK]: { assets: TimelineAsset[] }; | ||||||
|   [AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: TimelineAsset }; |   [AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: TimelineAsset }; | ||||||
|   [AssetAction.SET_STACK_PRIMARY_ASSET]: { stack: StackResponseDto }; |   [AssetAction.SET_STACK_PRIMARY_ASSET]: { stack: StackResponseDto }; | ||||||
|  |   [AssetAction.REMOVE_ASSET_FROM_STACK]: { stack: StackResponseDto | null; asset: AssetResponseDto }; | ||||||
|   [AssetAction.SET_VISIBILITY_LOCKED]: { asset: TimelineAsset }; |   [AssetAction.SET_VISIBILITY_LOCKED]: { asset: TimelineAsset }; | ||||||
|   [AssetAction.SET_VISIBILITY_TIMELINE]: { asset: TimelineAsset }; |   [AssetAction.SET_VISIBILITY_TIMELINE]: { asset: TimelineAsset }; | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -0,0 +1,31 @@ | |||||||
|  | <script lang="ts"> | ||||||
|  |   import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; | ||||||
|  |  | ||||||
|  |   import { AssetAction } from '$lib/constants'; | ||||||
|  |   import { removeAssetFromStack, type AssetResponseDto, type StackResponseDto } from '@immich/sdk'; | ||||||
|  |   import { mdiImageMinusOutline } from '@mdi/js'; | ||||||
|  |   import { t } from 'svelte-i18n'; | ||||||
|  |   import type { OnAction } from './action'; | ||||||
|  |  | ||||||
|  |   interface Props { | ||||||
|  |     asset: AssetResponseDto; | ||||||
|  |     stack: StackResponseDto; | ||||||
|  |     onAction: OnAction; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   let { asset, stack, onAction }: Props = $props(); | ||||||
|  |  | ||||||
|  |   const handleRemoveFromStack = async () => { | ||||||
|  |     await removeAssetFromStack({ | ||||||
|  |       id: stack.id, | ||||||
|  |       assetId: asset.id, | ||||||
|  |     }); | ||||||
|  |     const updatedStack = { | ||||||
|  |       ...stack, | ||||||
|  |       assets: stack.assets.filter((a) => a.id !== asset.id), | ||||||
|  |     }; | ||||||
|  |     onAction({ type: AssetAction.REMOVE_ASSET_FROM_STACK, stack: updatedStack, asset }); | ||||||
|  |   }; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <MenuOption icon={mdiImageMinusOutline} onClick={handleRemoveFromStack} text={$t('viewer_remove_from_stack')} /> | ||||||
| @@ -4,7 +4,7 @@ | |||||||
|   import { deleteStack } from '$lib/utils/asset-utils'; |   import { deleteStack } from '$lib/utils/asset-utils'; | ||||||
|   import { toTimelineAsset } from '$lib/utils/timeline-util'; |   import { toTimelineAsset } from '$lib/utils/timeline-util'; | ||||||
|   import type { StackResponseDto } from '@immich/sdk'; |   import type { StackResponseDto } from '@immich/sdk'; | ||||||
|   import { mdiImageMinusOutline } from '@mdi/js'; |   import { mdiImageOffOutline } from '@mdi/js'; | ||||||
|   import { t } from 'svelte-i18n'; |   import { t } from 'svelte-i18n'; | ||||||
|   import type { OnAction } from './action'; |   import type { OnAction } from './action'; | ||||||
|  |  | ||||||
| @@ -23,4 +23,4 @@ | |||||||
|   }; |   }; | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <MenuOption icon={mdiImageMinusOutline} onClick={handleUnstack} text={$t('unstack')} /> | <MenuOption icon={mdiImageOffOutline} onClick={handleUnstack} text={$t('unstack')} /> | ||||||
|   | |||||||
| @@ -9,6 +9,7 @@ | |||||||
|   import DownloadAction from '$lib/components/asset-viewer/actions/download-action.svelte'; |   import DownloadAction from '$lib/components/asset-viewer/actions/download-action.svelte'; | ||||||
|   import FavoriteAction from '$lib/components/asset-viewer/actions/favorite-action.svelte'; |   import FavoriteAction from '$lib/components/asset-viewer/actions/favorite-action.svelte'; | ||||||
|   import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte'; |   import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte'; | ||||||
|  |   import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/remove-asset-from-stack.svelte'; | ||||||
|   import RestoreAction from '$lib/components/asset-viewer/actions/restore-action.svelte'; |   import RestoreAction from '$lib/components/asset-viewer/actions/restore-action.svelte'; | ||||||
|   import SetAlbumCoverAction from '$lib/components/asset-viewer/actions/set-album-cover-action.svelte'; |   import SetAlbumCoverAction from '$lib/components/asset-viewer/actions/set-album-cover-action.svelte'; | ||||||
|   import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/set-person-featured-action.svelte'; |   import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/set-person-featured-action.svelte'; | ||||||
| @@ -195,6 +196,9 @@ | |||||||
|             <KeepThisDeleteOthersAction {stack} {asset} {onAction} /> |             <KeepThisDeleteOthersAction {stack} {asset} {onAction} /> | ||||||
|             {#if stack?.primaryAssetId !== asset.id} |             {#if stack?.primaryAssetId !== asset.id} | ||||||
|               <SetStackPrimaryAsset {stack} {asset} {onAction} /> |               <SetStackPrimaryAsset {stack} {asset} {onAction} /> | ||||||
|  |               {#if stack?.assets?.length > 2} | ||||||
|  |                 <RemoveAssetFromStack {asset} {stack} {onAction} /> | ||||||
|  |               {/if} | ||||||
|             {/if} |             {/if} | ||||||
|           {/if} |           {/if} | ||||||
|           {#if album} |           {#if album} | ||||||
|   | |||||||
| @@ -328,6 +328,13 @@ | |||||||
|         await handleGetAllAlbums(); |         await handleGetAllAlbums(); | ||||||
|         break; |         break; | ||||||
|       } |       } | ||||||
|  |       case AssetAction.REMOVE_ASSET_FROM_STACK: { | ||||||
|  |         stack = action.stack; | ||||||
|  |         if (stack) { | ||||||
|  |           asset = stack.assets[0]; | ||||||
|  |         } | ||||||
|  |         break; | ||||||
|  |       } | ||||||
|       case AssetAction.SET_STACK_PRIMARY_ASSET: { |       case AssetAction.SET_STACK_PRIMARY_ASSET: { | ||||||
|         stack = action.stack; |         stack = action.stack; | ||||||
|         break; |         break; | ||||||
|   | |||||||
| @@ -4,7 +4,7 @@ | |||||||
|   import type { OnStack, OnUnstack } from '$lib/utils/actions'; |   import type { OnStack, OnUnstack } from '$lib/utils/actions'; | ||||||
|   import { deleteStack, stackAssets } from '$lib/utils/asset-utils'; |   import { deleteStack, stackAssets } from '$lib/utils/asset-utils'; | ||||||
|   import { toTimelineAsset } from '$lib/utils/timeline-util'; |   import { toTimelineAsset } from '$lib/utils/timeline-util'; | ||||||
|   import { mdiImageMinusOutline, mdiImageMultipleOutline } from '@mdi/js'; |   import { mdiImageMultipleOutline, mdiImageOffOutline } from '@mdi/js'; | ||||||
|   import { t } from 'svelte-i18n'; |   import { t } from 'svelte-i18n'; | ||||||
|  |  | ||||||
|   interface Props { |   interface Props { | ||||||
| @@ -42,7 +42,7 @@ | |||||||
| </script> | </script> | ||||||
|  |  | ||||||
| {#if unstack} | {#if unstack} | ||||||
|   <MenuOption text={$t('unstack')} icon={mdiImageMinusOutline} onClick={handleUnstack} /> |   <MenuOption text={$t('unstack')} icon={mdiImageOffOutline} onClick={handleUnstack} /> | ||||||
| {:else} | {:else} | ||||||
|   <MenuOption text={$t('stack')} icon={mdiImageMultipleOutline} onClick={handleStack} /> |   <MenuOption text={$t('stack')} icon={mdiImageMultipleOutline} onClick={handleStack} /> | ||||||
| {/if} | {/if} | ||||||
|   | |||||||
| @@ -523,6 +523,23 @@ | |||||||
|         updateUnstackedAssetInTimeline(timelineManager, action.assets); |         updateUnstackedAssetInTimeline(timelineManager, action.assets); | ||||||
|         break; |         break; | ||||||
|       } |       } | ||||||
|  |       case AssetAction.REMOVE_ASSET_FROM_STACK: { | ||||||
|  |         timelineManager.addAssets([toTimelineAsset(action.asset)]); | ||||||
|  |         if (action.stack) { | ||||||
|  |           //Have to unstack then restack assets in timeline in order to update the stack count in the timeline. | ||||||
|  |           updateUnstackedAssetInTimeline( | ||||||
|  |             timelineManager, | ||||||
|  |             action.stack.assets.map((asset) => toTimelineAsset(asset)), | ||||||
|  |           ); | ||||||
|  |           updateStackedAssetInTimeline(timelineManager, { | ||||||
|  |             stack: action.stack, | ||||||
|  |             toDeleteIds: action.stack.assets | ||||||
|  |               .filter((asset) => asset.id !== action.stack?.primaryAssetId) | ||||||
|  |               .map((asset) => asset.id), | ||||||
|  |           }); | ||||||
|  |         } | ||||||
|  |         break; | ||||||
|  |       } | ||||||
|       case AssetAction.SET_STACK_PRIMARY_ASSET: { |       case AssetAction.SET_STACK_PRIMARY_ASSET: { | ||||||
|         //Have to unstack then restack assets in timeline in order for the currently removed new primary asset to be made visible. |         //Have to unstack then restack assets in timeline in order for the currently removed new primary asset to be made visible. | ||||||
|         updateUnstackedAssetInTimeline( |         updateUnstackedAssetInTimeline( | ||||||
|   | |||||||
| @@ -11,6 +11,7 @@ export enum AssetAction { | |||||||
|   UNSTACK = 'unstack', |   UNSTACK = 'unstack', | ||||||
|   KEEP_THIS_DELETE_OTHERS = 'keep-this-delete-others', |   KEEP_THIS_DELETE_OTHERS = 'keep-this-delete-others', | ||||||
|   SET_STACK_PRIMARY_ASSET = 'set-stack-primary-asset', |   SET_STACK_PRIMARY_ASSET = 'set-stack-primary-asset', | ||||||
|  |   REMOVE_ASSET_FROM_STACK = 'remove-asset-from-stack', | ||||||
|   SET_VISIBILITY_LOCKED = 'set-visibility-locked', |   SET_VISIBILITY_LOCKED = 'set-visibility-locked', | ||||||
|   SET_VISIBILITY_TIMELINE = 'set-visibility-timeline', |   SET_VISIBILITY_TIMELINE = 'set-visibility-timeline', | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user