Przeglądaj źródła

[WEB] Select album thumbnail (#383)

* Added context menu for album opionts

* choose asset for album thumbnail

* Refactor UpdateAlbumDto to accept albumThumbnailAssetId

* implemented changing album cover on web

* Fixed api change on mobile app
Alex 2 lat temu
rodzic
commit
ef4136d327

+ 0 - 1
mobile/lib/modules/sharing/services/shared_album.service.dart

@@ -134,7 +134,6 @@ class SharedAlbumService {
       await _apiService.albumApi.updateAlbumInfo(
       await _apiService.albumApi.updateAlbumInfo(
         albumId,
         albumId,
         UpdateAlbumDto(
         UpdateAlbumDto(
-          ownerId: ownerId,
           albumName: newAlbumTitle,
           albumName: newAlbumTitle,
         ),
         ),
       );
       );

+ 2 - 2
mobile/openapi/doc/UpdateAlbumDto.md

@@ -8,8 +8,8 @@ import 'package:openapi/api.dart';
 ## Properties
 ## Properties
 Name | Type | Description | Notes
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
 ------------ | ------------- | ------------- | -------------
-**albumName** | **String** |  | 
-**ownerId** | **String** |  | 
+**albumName** | **String** |  | [optional] 
+**albumThumbnailAssetId** | **String** |  | [optional] 
 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 
 

+ 32 - 14
mobile/openapi/lib/model/update_album_dto.dart

@@ -13,32 +13,52 @@ part of openapi.api;
 class UpdateAlbumDto {
 class UpdateAlbumDto {
   /// Returns a new [UpdateAlbumDto] instance.
   /// Returns a new [UpdateAlbumDto] instance.
   UpdateAlbumDto({
   UpdateAlbumDto({
-    required this.albumName,
-    required this.ownerId,
+    this.albumName,
+    this.albumThumbnailAssetId,
   });
   });
 
 
-  String albumName;
-
-  String ownerId;
+  ///
+  /// 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.
+  ///
+  String? albumName;
+
+  ///
+  /// 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.
+  ///
+  String? albumThumbnailAssetId;
 
 
   @override
   @override
   bool operator ==(Object other) => identical(this, other) || other is UpdateAlbumDto &&
   bool operator ==(Object other) => identical(this, other) || other is UpdateAlbumDto &&
      other.albumName == albumName &&
      other.albumName == albumName &&
-     other.ownerId == ownerId;
+     other.albumThumbnailAssetId == albumThumbnailAssetId;
 
 
   @override
   @override
   int get hashCode =>
   int get hashCode =>
     // ignore: unnecessary_parenthesis
     // ignore: unnecessary_parenthesis
-    (albumName.hashCode) +
-    (ownerId.hashCode);
+    (albumName == null ? 0 : albumName!.hashCode) +
+    (albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode);
 
 
   @override
   @override
-  String toString() => 'UpdateAlbumDto[albumName=$albumName, ownerId=$ownerId]';
+  String toString() => 'UpdateAlbumDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId]';
 
 
   Map<String, dynamic> toJson() {
   Map<String, dynamic> toJson() {
     final _json = <String, dynamic>{};
     final _json = <String, dynamic>{};
+    if (albumName != null) {
       _json[r'albumName'] = albumName;
       _json[r'albumName'] = albumName;
-      _json[r'ownerId'] = ownerId;
+    } else {
+      _json[r'albumName'] = null;
+    }
+    if (albumThumbnailAssetId != null) {
+      _json[r'albumThumbnailAssetId'] = albumThumbnailAssetId;
+    } else {
+      _json[r'albumThumbnailAssetId'] = null;
+    }
     return _json;
     return _json;
   }
   }
 
 
@@ -61,8 +81,8 @@ class UpdateAlbumDto {
       }());
       }());
 
 
       return UpdateAlbumDto(
       return UpdateAlbumDto(
-        albumName: mapValueOfType<String>(json, r'albumName')!,
-        ownerId: mapValueOfType<String>(json, r'ownerId')!,
+        albumName: mapValueOfType<String>(json, r'albumName'),
+        albumThumbnailAssetId: mapValueOfType<String>(json, r'albumThumbnailAssetId'),
       );
       );
     }
     }
     return null;
     return null;
@@ -112,8 +132,6 @@ class UpdateAlbumDto {
 
 
   /// The list of required keys that must be present in a JSON.
   /// The list of required keys that must be present in a JSON.
   static const requiredKeys = <String>{
   static const requiredKeys = <String>{
-    'albumName',
-    'ownerId',
   };
   };
 }
 }
 
 

+ 2 - 1
server/apps/immich/src/api-v1/album/album-repository.ts

@@ -237,7 +237,8 @@ export class AlbumRepository implements IAlbumRepository {
   }
   }
 
 
   updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise<AlbumEntity> {
   updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise<AlbumEntity> {
-    album.albumName = updateAlbumDto.albumName;
+    album.albumName = updateAlbumDto.albumName || album.albumName;
+    album.albumThumbnailAssetId = updateAlbumDto.albumThumbnailAssetId || album.albumThumbnailAssetId;
 
 
     return this.albumRepository.save(album);
     return this.albumRepository.save(album);
   }
   }

+ 1 - 1
server/apps/immich/src/api-v1/album/album.controller.ts

@@ -104,6 +104,6 @@ export class AlbumController {
     @Body(ValidationPipe) updateAlbumInfoDto: UpdateAlbumDto,
     @Body(ValidationPipe) updateAlbumInfoDto: UpdateAlbumDto,
     @Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
     @Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
   ) {
   ) {
-    return this.albumService.updateAlbumTitle(authUser, updateAlbumInfoDto, albumId);
+    return this.albumService.updateAlbumInfo(authUser, updateAlbumInfoDto, albumId);
   }
   }
 }
 }

+ 7 - 8
server/apps/immich/src/api-v1/album/album.service.spec.ts

@@ -260,17 +260,16 @@ describe('Album service', () => {
     const albumEntity = _getOwnedAlbum();
     const albumEntity = _getOwnedAlbum();
     const albumId = albumEntity.id;
     const albumId = albumEntity.id;
     const updatedAlbumName = 'new album name';
     const updatedAlbumName = 'new album name';
-
+    const updatedAlbumThumbnailAssetId = '69d2f917-0b31-48d8-9d7d-673b523f1aac';
     albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
     albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
     albumRepositoryMock.updateAlbum.mockImplementation(() =>
     albumRepositoryMock.updateAlbum.mockImplementation(() =>
       Promise.resolve<AlbumEntity>({ ...albumEntity, albumName: updatedAlbumName }),
       Promise.resolve<AlbumEntity>({ ...albumEntity, albumName: updatedAlbumName }),
     );
     );
 
 
-    const result = await sut.updateAlbumTitle(
+    const result = await sut.updateAlbumInfo(
       authUser,
       authUser,
       {
       {
         albumName: updatedAlbumName,
         albumName: updatedAlbumName,
-        ownerId: 'this is not used and will be removed',
       },
       },
       albumId,
       albumId,
     );
     );
@@ -280,7 +279,7 @@ describe('Album service', () => {
     expect(albumRepositoryMock.updateAlbum).toHaveBeenCalledTimes(1);
     expect(albumRepositoryMock.updateAlbum).toHaveBeenCalledTimes(1);
     expect(albumRepositoryMock.updateAlbum).toHaveBeenCalledWith(albumEntity, {
     expect(albumRepositoryMock.updateAlbum).toHaveBeenCalledWith(albumEntity, {
       albumName: updatedAlbumName,
       albumName: updatedAlbumName,
-      ownerId: 'this is not used and will be removed',
+      thumbnailAssetId: updatedAlbumThumbnailAssetId,
     });
     });
   });
   });
 
 
@@ -291,11 +290,11 @@ describe('Album service', () => {
     albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
     albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
 
 
     await expect(
     await expect(
-      sut.updateAlbumTitle(
+      sut.updateAlbumInfo(
         authUser,
         authUser,
         {
         {
           albumName: 'new album name',
           albumName: 'new album name',
-          ownerId: 'this is not used and will be removed',
+          albumThumbnailAssetId: '69d2f917-0b31-48d8-9d7d-673b523f1aac',
         },
         },
         albumId,
         albumId,
       ),
       ),
@@ -361,7 +360,7 @@ describe('Album service', () => {
   it('removes assets from owned album', async () => {
   it('removes assets from owned album', async () => {
     const albumEntity = _getOwnedAlbum();
     const albumEntity = _getOwnedAlbum();
     albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
     albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
-    albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve(true));
+    albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
 
 
     await expect(
     await expect(
       sut.removeAssetsFromAlbum(
       sut.removeAssetsFromAlbum(
@@ -381,7 +380,7 @@ describe('Album service', () => {
   it('removes assets from shared album (shared with auth user)', async () => {
   it('removes assets from shared album (shared with auth user)', async () => {
     const albumEntity = _getOwnedSharedAlbum();
     const albumEntity = _getOwnedSharedAlbum();
     albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
     albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
-    albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve(true));
+    albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
 
 
     await expect(
     await expect(
       sut.removeAssetsFromAlbum(
       sut.removeAssetsFromAlbum(

+ 6 - 5
server/apps/immich/src/api-v1/album/album.service.ts

@@ -103,16 +103,17 @@ export class AlbumService {
     return mapAlbum(updatedAlbum);
     return mapAlbum(updatedAlbum);
   }
   }
 
 
-  async updateAlbumTitle(
+  async updateAlbumInfo(
     authUser: AuthUserDto,
     authUser: AuthUserDto,
     updateAlbumDto: UpdateAlbumDto,
     updateAlbumDto: UpdateAlbumDto,
     albumId: string,
     albumId: string,
   ): Promise<AlbumResponseDto> {
   ): Promise<AlbumResponseDto> {
-    // TODO: this should not come from request DTO. To be removed from here and DTO
-    // if (authUser.id != updateAlbumDto.ownerId) {
-    //   throw new BadRequestException('Unauthorized to change album info');
-    // }
     const album = await this._getAlbum({ authUser, albumId });
     const album = await this._getAlbum({ authUser, albumId });
+
+    if (authUser.id != album.ownerId) {
+      throw new BadRequestException('Unauthorized to change album info');
+    }
+
     const updatedAlbum = await this._albumRepository.updateAlbum(album, updateAlbumDto);
     const updatedAlbum = await this._albumRepository.updateAlbum(album, updateAlbumDto);
     return mapAlbum(updatedAlbum);
     return mapAlbum(updatedAlbum);
   }
   }

+ 5 - 5
server/apps/immich/src/api-v1/album/dto/update-album.dto.ts

@@ -1,9 +1,9 @@
-import { IsNotEmpty } from 'class-validator';
+import { IsNotEmpty, IsOptional } from 'class-validator';
 
 
 export class UpdateAlbumDto {
 export class UpdateAlbumDto {
-  @IsNotEmpty()
-  albumName!: string;
+  @IsOptional()
+  albumName?: string;
 
 
-  @IsNotEmpty()
-  ownerId!: string;
+  @IsOptional()
+  albumThumbnailAssetId?: string;
 }
 }

Plik diff jest za duży
+ 0 - 0
server/immich-openapi-specs.json


+ 2 - 2
web/src/api/open-api/api.ts

@@ -1001,13 +1001,13 @@ export interface UpdateAlbumDto {
      * @type {string}
      * @type {string}
      * @memberof UpdateAlbumDto
      * @memberof UpdateAlbumDto
      */
      */
-    'albumName': string;
+    'albumName'?: string;
     /**
     /**
      * 
      * 
      * @type {string}
      * @type {string}
      * @memberof UpdateAlbumDto
      * @memberof UpdateAlbumDto
      */
      */
-    'ownerId': string;
+    'albumThumbnailAssetId'?: string;
 }
 }
 /**
 /**
  * 
  * 

+ 59 - 2
web/src/lib/components/album-page/album-viewer.svelte

@@ -18,6 +18,11 @@
 	import CircleIconButton from '../shared-components/circle-icon-button.svelte';
 	import CircleIconButton from '../shared-components/circle-icon-button.svelte';
 	import Close from 'svelte-material-icons/Close.svelte';
 	import Close from 'svelte-material-icons/Close.svelte';
 	import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
 	import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
+	import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
+	import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
+	import MenuOption from '../shared-components/context-menu/menu-option.svelte';
+	import ThumbnailSelection from './thumbnail-selection.svelte';
+
 	export let album: AlbumResponseDto;
 	export let album: AlbumResponseDto;
 
 
 	let isShowAssetViewer = false;
 	let isShowAssetViewer = false;
@@ -26,6 +31,8 @@
 	let isEditingTitle = false;
 	let isEditingTitle = false;
 	let isCreatingSharedAlbum = false;
 	let isCreatingSharedAlbum = false;
 	let isShowShareInfoModal = false;
 	let isShowShareInfoModal = false;
+	let isShowAlbumOptions = false;
+	let isShowThumbnailSelection = false;
 
 
 	let selectedAsset: AssetResponseDto;
 	let selectedAsset: AssetResponseDto;
 	let currentViewAssetIndex = 0;
 	let currentViewAssetIndex = 0;
@@ -37,6 +44,7 @@
 	let currentAlbumName = '';
 	let currentAlbumName = '';
 	let currentUser: UserResponseDto;
 	let currentUser: UserResponseDto;
 	let titleInput: HTMLInputElement;
 	let titleInput: HTMLInputElement;
+	let contextMenuPosition = { x: 0, y: 0 };
 
 
 	$: isOwned = currentUser?.id == album.ownerId;
 	$: isOwned = currentUser?.id == album.ownerId;
 
 
@@ -165,7 +173,6 @@
 		if (!isEditingTitle && currentAlbumName != album.albumName && isOwned) {
 		if (!isEditingTitle && currentAlbumName != album.albumName && isOwned) {
 			api.albumApi
 			api.albumApi
 				.updateAlbumInfo(album.id, {
 				.updateAlbumInfo(album.id, {
-					ownerId: album.ownerId,
 					albumName: album.albumName
 					albumName: album.albumName
 				})
 				})
 				.then(() => {
 				.then(() => {
@@ -238,6 +245,28 @@
 			}
 			}
 		}
 		}
 	};
 	};
+
+	const showAlbumOptionsMenu = (event: CustomEvent) => {
+		contextMenuPosition = {
+			x: event.detail.mouseEvent.x,
+			y: event.detail.mouseEvent.y
+		};
+
+		isShowAlbumOptions = !isShowAlbumOptions;
+	};
+
+	const setAlbumThumbnailHandler = (event: CustomEvent) => {
+		const { asset }: { asset: AssetResponseDto } = event.detail;
+		try {
+			api.albumApi.updateAlbumInfo(album.id, {
+				albumThumbnailAssetId: asset.id
+			});
+		} catch (e) {
+			console.log('Error [setAlbumThumbnailHandler] ', e);
+		}
+
+		isShowThumbnailSelection = false;
+	};
 </script>
 </script>
 
 
 <section class="bg-immich-bg">
 <section class="bg-immich-bg">
@@ -274,7 +303,7 @@
 						logo={FileImagePlusOutline}
 						logo={FileImagePlusOutline}
 					/>
 					/>
 
 
-					<!-- Sharing only for owner -->
+					<!-- Share and remove album -->
 					{#if isOwned}
 					{#if isOwned}
 						<CircleIconButton
 						<CircleIconButton
 							title="Share"
 							title="Share"
@@ -283,6 +312,12 @@
 						/>
 						/>
 						<CircleIconButton title="Remove album" on:click={removeAlbum} logo={DeleteOutline} />
 						<CircleIconButton title="Remove album" on:click={removeAlbum} logo={DeleteOutline} />
 					{/if}
 					{/if}
+
+					<CircleIconButton
+						title="Album options"
+						on:click={(event) => showAlbumOptionsMenu(event)}
+						logo={DotsVertical}
+					/>
 				{/if}
 				{/if}
 
 
 				{#if isCreatingSharedAlbum && album.sharedUsers.length == 0}
 				{#if isCreatingSharedAlbum && album.sharedUsers.length == 0}
@@ -418,3 +453,25 @@
 		on:user-deleted={sharedUserDeletedHandler}
 		on:user-deleted={sharedUserDeletedHandler}
 	/>
 	/>
 {/if}
 {/if}
+
+{#if isShowAlbumOptions}
+	<ContextMenu {...contextMenuPosition} on:clickoutside={() => (isShowAlbumOptions = false)}>
+		{#if isOwned}
+			<MenuOption
+				on:click={() => {
+					isShowThumbnailSelection = true;
+					isShowAlbumOptions = false;
+				}}
+				text="Set album cover"
+			/>
+		{/if}
+	</ContextMenu>
+{/if}
+
+{#if isShowThumbnailSelection}
+	<ThumbnailSelection
+		{album}
+		on:close={() => (isShowThumbnailSelection = false)}
+		on:thumbnail-selected={setAlbumThumbnailHandler}
+	/>
+{/if}

+ 1 - 1
web/src/lib/components/album-page/asset-selection.svelte

@@ -170,7 +170,7 @@
 
 
 <section
 <section
 	transition:fly={{ y: 500, duration: 100, easing: quintOut }}
 	transition:fly={{ y: 500, duration: 100, easing: quintOut }}
-	class="absolute top-0 left-0 w-full h-full  bg-immich-bg z-[9999]"
+	class="absolute top-0 left-0 w-full h-full py-[160px]  bg-immich-bg z-[9999]"
 >
 >
 	<AlbumAppBar on:close-button-click={() => dispatch('go-back')}>
 	<AlbumAppBar on:close-button-click={() => dispatch('go-back')}>
 		<svelte:fragment slot="leading">
 		<svelte:fragment slot="leading">

+ 54 - 0
web/src/lib/components/album-page/thumbnail-selection.svelte

@@ -0,0 +1,54 @@
+<script lang="ts">
+	import { AlbumResponseDto, AssetResponseDto } from '@api';
+	import { createEventDispatcher } from 'svelte';
+	import { quintOut } from 'svelte/easing';
+	import { fly } from 'svelte/transition';
+	import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
+	import AlbumAppBar from './album-app-bar.svelte';
+
+	export let album: AlbumResponseDto;
+
+	let selectedThumbnail: AssetResponseDto | undefined;
+	const dispatch = createEventDispatcher();
+
+	$: isSelected = (id: string): boolean | undefined => {
+		if (!selectedThumbnail && album.albumThumbnailAssetId == id) {
+			return true;
+		} else {
+			return selectedThumbnail?.id == id;
+		}
+	};
+</script>
+
+<section
+	transition:fly={{ y: 500, duration: 100, easing: quintOut }}
+	class="absolute top-0 left-0 w-full h-full py-[160px]  bg-immich-bg z-[9999]"
+>
+	<AlbumAppBar on:close-button-click={() => dispatch('close')}>
+		<svelte:fragment slot="leading">
+			<p class="text-lg">Select album cover</p>
+		</svelte:fragment>
+
+		<svelte:fragment slot="trailing">
+			<button
+				disabled={selectedThumbnail == undefined}
+				on:click={() => dispatch('thumbnail-selected', { asset: selectedThumbnail })}
+				class="immich-text-button border bg-immich-primary text-gray-50 hover:bg-immich-primary/75 px-6 text-sm disabled:opacity-25 disabled:bg-gray-500 disabled:cursor-not-allowed"
+				><span class="px-2">Done</span></button
+			>
+		</svelte:fragment>
+	</AlbumAppBar>
+
+	<section class="flex flex-wrap gap-14  px-20 overflow-y-auto">
+		<!-- Image grid -->
+		<div class="flex flex-wrap gap-[2px]">
+			{#each album.assets as asset}
+				<ImmichThumbnail
+					{asset}
+					on:click={() => (selectedThumbnail = asset)}
+					selected={isSelected(asset.id)}
+				/>
+			{/each}
+		</div>
+	</section>
+</section>

+ 1 - 1
web/src/routes/albums/[albumId]/index.svelte

@@ -45,6 +45,6 @@
 	<title>{album.albumName} - Immich</title>
 	<title>{album.albumName} - Immich</title>
 </svelte:head>
 </svelte:head>
 
 
-<div class="relative immich-scrollbar">
+<div class="immich-scrollbar">
 	<AlbumViewer {album} />
 	<AlbumViewer {album} />
 </div>
 </div>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików