Kaynağa Gözat

Remove/Add asset in ablum on web (#371)

* Added interaction to select multiple thumbnail

* Fixed stutter transition

* Return AlbumResponseDto after removing an asset from album

* Render correctly when an array of thumbnail is updated

* Fixed wording

* Added native dialog for removing users from album

* Fixed rendering incorrect profile image on share user select dialog
Alex 3 yıl önce
ebeveyn
işleme
052db5d748

+ 0 - 1
mobile/openapi/.openapi-generator/FILES

@@ -101,4 +101,3 @@ lib/model/user_count_response_dto.dart
 lib/model/user_response_dto.dart
 lib/model/user_response_dto.dart
 lib/model/validate_access_token_response_dto.dart
 lib/model/validate_access_token_response_dto.dart
 pubspec.yaml
 pubspec.yaml
-test/logout_response_dto_test.dart

+ 5 - 4
mobile/openapi/doc/AlbumApi.md

@@ -306,7 +306,7 @@ Name | Type | Description  | Notes
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 
 # **removeAssetFromAlbum**
 # **removeAssetFromAlbum**
-> removeAssetFromAlbum(albumId, removeAssetsDto)
+> AlbumResponseDto removeAssetFromAlbum(albumId, removeAssetsDto)
 
 
 
 
 
 
@@ -325,7 +325,8 @@ final albumId = albumId_example; // String |
 final removeAssetsDto = RemoveAssetsDto(); // RemoveAssetsDto | 
 final removeAssetsDto = RemoveAssetsDto(); // RemoveAssetsDto | 
 
 
 try {
 try {
-    api_instance.removeAssetFromAlbum(albumId, removeAssetsDto);
+    final result = api_instance.removeAssetFromAlbum(albumId, removeAssetsDto);
+    print(result);
 } catch (e) {
 } catch (e) {
     print('Exception when calling AlbumApi->removeAssetFromAlbum: $e\n');
     print('Exception when calling AlbumApi->removeAssetFromAlbum: $e\n');
 }
 }
@@ -340,7 +341,7 @@ Name | Type | Description  | Notes
 
 
 ### Return type
 ### Return type
 
 
-void (empty response body)
+[**AlbumResponseDto**](AlbumResponseDto.md)
 
 
 ### Authorization
 ### Authorization
 
 
@@ -349,7 +350,7 @@ void (empty response body)
 ### HTTP request headers
 ### HTTP request headers
 
 
  - **Content-Type**: application/json
  - **Content-Type**: application/json
- - **Accept**: Not defined
+ - **Accept**: application/json
 
 
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 

+ 9 - 1
mobile/openapi/lib/api/album_api.dart

@@ -346,11 +346,19 @@ class AlbumApi {
   /// * [String] albumId (required):
   /// * [String] albumId (required):
   ///
   ///
   /// * [RemoveAssetsDto] removeAssetsDto (required):
   /// * [RemoveAssetsDto] removeAssetsDto (required):
-  Future<void> removeAssetFromAlbum(String albumId, RemoveAssetsDto removeAssetsDto,) async {
+  Future<AlbumResponseDto?> removeAssetFromAlbum(String albumId, RemoveAssetsDto removeAssetsDto,) async {
     final response = await removeAssetFromAlbumWithHttpInfo(albumId, removeAssetsDto,);
     final response = await removeAssetFromAlbumWithHttpInfo(albumId, removeAssetsDto,);
     if (response.statusCode >= HttpStatus.badRequest) {
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
     }
     }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AlbumResponseDto',) as AlbumResponseDto;
+    
+    }
+    return null;
   }
   }
 
 
   /// Performs an HTTP 'DELETE /album/{albumId}/user/{userId}' operation and returns the [Response].
   /// Performs an HTTP 'DELETE /album/{albumId}/user/{userId}' operation and returns the [Response].

+ 9 - 4
server/apps/immich/src/api-v1/album/album-repository.ts

@@ -1,7 +1,7 @@
 import { AlbumEntity } from '@app/database/entities/album.entity';
 import { AlbumEntity } from '@app/database/entities/album.entity';
 import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
 import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
 import { UserAlbumEntity } from '@app/database/entities/user-album.entity';
 import { UserAlbumEntity } from '@app/database/entities/user-album.entity';
-import { Injectable } from '@nestjs/common';
+import { BadRequestException, Injectable } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
 import { InjectRepository } from '@nestjs/typeorm';
 import { Repository, SelectQueryBuilder, DataSource } from 'typeorm';
 import { Repository, SelectQueryBuilder, DataSource } from 'typeorm';
 import { AddAssetsDto } from './dto/add-assets.dto';
 import { AddAssetsDto } from './dto/add-assets.dto';
@@ -10,6 +10,7 @@ import { CreateAlbumDto } from './dto/create-album.dto';
 import { GetAlbumsDto } from './dto/get-albums.dto';
 import { GetAlbumsDto } from './dto/get-albums.dto';
 import { RemoveAssetsDto } from './dto/remove-assets.dto';
 import { RemoveAssetsDto } from './dto/remove-assets.dto';
 import { UpdateAlbumDto } from './dto/update-album.dto';
 import { UpdateAlbumDto } from './dto/update-album.dto';
+import { AlbumResponseDto } from './response-dto/album-response.dto';
 
 
 export interface IAlbumRepository {
 export interface IAlbumRepository {
   create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity>;
   create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity>;
@@ -18,7 +19,7 @@ export interface IAlbumRepository {
   delete(album: AlbumEntity): Promise<void>;
   delete(album: AlbumEntity): Promise<void>;
   addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise<AlbumEntity>;
   addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise<AlbumEntity>;
   removeUser(album: AlbumEntity, userId: string): Promise<void>;
   removeUser(album: AlbumEntity, userId: string): Promise<void>;
-  removeAssets(album: AlbumEntity, removeAssets: RemoveAssetsDto): Promise<boolean>;
+  removeAssets(album: AlbumEntity, removeAssets: RemoveAssetsDto): Promise<AlbumEntity>;
   addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AlbumEntity>;
   addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AlbumEntity>;
   updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise<AlbumEntity>;
   updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise<AlbumEntity>;
 }
 }
@@ -198,7 +199,7 @@ export class AlbumRepository implements IAlbumRepository {
     await this.userAlbumRepository.delete({ albumId: album.id, sharedUserId: userId });
     await this.userAlbumRepository.delete({ albumId: album.id, sharedUserId: userId });
   }
   }
 
 
-  async removeAssets(album: AlbumEntity, removeAssetsDto: RemoveAssetsDto): Promise<boolean> {
+  async removeAssets(album: AlbumEntity, removeAssetsDto: RemoveAssetsDto): Promise<AlbumEntity> {
     let deleteAssetCount = 0;
     let deleteAssetCount = 0;
     // TODO: should probably do a single delete query?
     // TODO: should probably do a single delete query?
     for (const assetId of removeAssetsDto.assetIds) {
     for (const assetId of removeAssetsDto.assetIds) {
@@ -207,7 +208,11 @@ export class AlbumRepository implements IAlbumRepository {
     }
     }
 
 
     // TODO: No need to return boolean if using a singe delete query
     // TODO: No need to return boolean if using a singe delete query
-    return deleteAssetCount == removeAssetsDto.assetIds.length;
+    if (deleteAssetCount == removeAssetsDto.assetIds.length) {
+      return this.get(album.id) as Promise<AlbumEntity>;
+    } else {
+      throw new BadRequestException('Some assets were not found in the album');
+    }
   }
   }
 
 
   async addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AlbumEntity> {
   async addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AlbumEntity> {

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

@@ -23,6 +23,7 @@ import { RemoveAssetsDto } from './dto/remove-assets.dto';
 import { UpdateAlbumDto } from './dto/update-album.dto';
 import { UpdateAlbumDto } from './dto/update-album.dto';
 import { GetAlbumsDto } from './dto/get-albums.dto';
 import { GetAlbumsDto } from './dto/get-albums.dto';
 import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
 import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
+import { AlbumResponseDto } from './response-dto/album-response.dto';
 
 
 // TODO might be worth creating a AlbumParamsDto that validates `albumId` instead of using the pipe.
 // TODO might be worth creating a AlbumParamsDto that validates `albumId` instead of using the pipe.
 @UseGuards(JwtAuthGuard)
 @UseGuards(JwtAuthGuard)
@@ -76,7 +77,7 @@ export class AlbumController {
     @GetAuthUser() authUser: AuthUserDto,
     @GetAuthUser() authUser: AuthUserDto,
     @Body(ValidationPipe) removeAssetsDto: RemoveAssetsDto,
     @Body(ValidationPipe) removeAssetsDto: RemoveAssetsDto,
     @Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
     @Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
-  ) {
+  ): Promise<AlbumResponseDto> {
     return this.albumService.removeAssetsFromAlbum(authUser, removeAssetsDto, albumId);
     return this.albumService.removeAssetsFromAlbum(authUser, removeAssetsDto, albumId);
   }
   }
 
 

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

@@ -82,9 +82,15 @@ export class AlbumService {
 
 
   // async removeUsersFromAlbum() {}
   // async removeUsersFromAlbum() {}
 
 
-  async removeAssetsFromAlbum(authUser: AuthUserDto, removeAssetsDto: RemoveAssetsDto, albumId: string): Promise<void> {
+  async removeAssetsFromAlbum(
+    authUser: AuthUserDto,
+    removeAssetsDto: RemoveAssetsDto,
+    albumId: string,
+  ): Promise<AlbumResponseDto> {
     const album = await this._getAlbum({ authUser, albumId });
     const album = await this._getAlbum({ authUser, albumId });
-    await this._albumRepository.removeAssets(album, removeAssetsDto);
+    const updateAlbum = await this._albumRepository.removeAssets(album, removeAssetsDto);
+
+    return mapAlbum(updateAlbum);
   }
   }
 
 
   async addAssetsToAlbum(
   async addAssetsToAlbum(

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
server/immich-openapi-specs.json


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

@@ -1608,7 +1608,7 @@ export const AlbumApiFp = function(configuration?: Configuration) {
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
-        async removeAssetFromAlbum(albumId: string, removeAssetsDto: RemoveAssetsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
+        async removeAssetFromAlbum(albumId: string, removeAssetsDto: RemoveAssetsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AlbumResponseDto>> {
             const localVarAxiosArgs = await localVarAxiosParamCreator.removeAssetFromAlbum(albumId, removeAssetsDto, options);
             const localVarAxiosArgs = await localVarAxiosParamCreator.removeAssetFromAlbum(albumId, removeAssetsDto, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         },
@@ -1707,7 +1707,7 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
-        removeAssetFromAlbum(albumId: string, removeAssetsDto: RemoveAssetsDto, options?: any): AxiosPromise<void> {
+        removeAssetFromAlbum(albumId: string, removeAssetsDto: RemoveAssetsDto, options?: any): AxiosPromise<AlbumResponseDto> {
             return localVarFp.removeAssetFromAlbum(albumId, removeAssetsDto, options).then((request) => request(axios, basePath));
             return localVarFp.removeAssetFromAlbum(albumId, removeAssetsDto, options).then((request) => request(axios, basePath));
         },
         },
         /**
         /**

+ 11 - 5
web/src/lib/components/album-page/album-app-bar.svelte

@@ -4,9 +4,12 @@
 	import { createEventDispatcher, onDestroy, onMount } from 'svelte';
 	import { createEventDispatcher, onDestroy, onMount } from 'svelte';
 	import Close from 'svelte-material-icons/Close.svelte';
 	import Close from 'svelte-material-icons/Close.svelte';
 	import CircleIconButton from '../shared-components/circle-icon-button.svelte';
 	import CircleIconButton from '../shared-components/circle-icon-button.svelte';
-
+	import { fly } from 'svelte/transition';
 	export let backIcon = Close;
 	export let backIcon = Close;
-	let appBarBorder = 'bg-immich-bg';
+	export let tailwindClasses = '';
+
+	let appBarBorder = 'bg-immich-bg border border-transparent';
+
 	const dispatch = createEventDispatcher();
 	const dispatch = createEventDispatcher();
 
 
 	onMount(() => {
 	onMount(() => {
@@ -15,7 +18,7 @@
 				if (window.pageYOffset > 80) {
 				if (window.pageYOffset > 80) {
 					appBarBorder = 'border border-gray-200 bg-gray-50';
 					appBarBorder = 'border border-gray-200 bg-gray-50';
 				} else {
 				} else {
-					appBarBorder = 'bg-immich-bg';
+					appBarBorder = 'bg-immich-bg border border-transparent';
 				}
 				}
 			});
 			});
 		}
 		}
@@ -28,10 +31,13 @@
 	});
 	});
 </script>
 </script>
 
 
-<div class="fixed top-0 w-full bg-transparent z-[100]">
+<div
+	transition:fly|local={{ y: 10, duration: 200 }}
+	class="fixed top-0 w-full bg-transparent z-[100]"
+>
 	<div
 	<div
 		id="asset-selection-app-bar"
 		id="asset-selection-app-bar"
-		class={`flex justify-between ${appBarBorder} rounded-lg p-2 mx-2 mt-2  transition-all place-items-center`}
+		class={`flex justify-between ${appBarBorder} rounded-lg p-2 mx-2 mt-2 transition-all place-items-center ${tailwindClasses}`}
 	>
 	>
 		<div class="flex place-items-center gap-6">
 		<div class="flex place-items-center gap-6">
 			<CircleIconButton
 			<CircleIconButton

+ 120 - 44
web/src/lib/components/album-page/album-viewer.svelte

@@ -2,7 +2,7 @@
 	import { afterNavigate, goto } from '$app/navigation';
 	import { afterNavigate, goto } from '$app/navigation';
 	import { page } from '$app/stores';
 	import { page } from '$app/stores';
 	import { AlbumResponseDto, api, AssetResponseDto, ThumbnailFormat, UserResponseDto } from '@api';
 	import { AlbumResponseDto, api, AssetResponseDto, ThumbnailFormat, UserResponseDto } from '@api';
-	import { createEventDispatcher, onMount } from 'svelte';
+	import { onMount } from 'svelte';
 	import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
 	import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
 	import Plus from 'svelte-material-icons/Plus.svelte';
 	import Plus from 'svelte-material-icons/Plus.svelte';
 	import FileImagePlusOutline from 'svelte-material-icons/FileImagePlusOutline.svelte';
 	import FileImagePlusOutline from 'svelte-material-icons/FileImagePlusOutline.svelte';
@@ -16,8 +16,8 @@
 	import UserSelectionModal from './user-selection-modal.svelte';
 	import UserSelectionModal from './user-selection-modal.svelte';
 	import ShareInfoModal from './share-info-modal.svelte';
 	import ShareInfoModal from './share-info-modal.svelte';
 	import CircleIconButton from '../shared-components/circle-icon-button.svelte';
 	import CircleIconButton from '../shared-components/circle-icon-button.svelte';
-
-	const dispatch = createEventDispatcher();
+	import Close from 'svelte-material-icons/Close.svelte';
+	import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
 	export let album: AlbumResponseDto;
 	export let album: AlbumResponseDto;
 
 
 	let isShowAssetViewer = false;
 	let isShowAssetViewer = false;
@@ -39,6 +39,9 @@
 
 
 	$: isOwned = currentUser?.id == album.ownerId;
 	$: isOwned = currentUser?.id == album.ownerId;
 
 
+	let multiSelectAsset: Set<AssetResponseDto> = new Set();
+	$: isMultiSelectionMode = multiSelectAsset.size > 0;
+
 	afterNavigate(({ from }) => {
 	afterNavigate(({ from }) => {
 		backUrl = from?.pathname ?? '/albums';
 		backUrl = from?.pathname ?? '/albums';
 
 
@@ -81,15 +84,46 @@
 		}
 		}
 	});
 	});
 
 
-	const viewAsset = (event: CustomEvent) => {
-		const { assetId, deviceId }: { assetId: string; deviceId: string } = event.detail;
+	const viewAssetHandler = (event: CustomEvent) => {
+		const { asset }: { asset: AssetResponseDto } = event.detail;
 
 
-		currentViewAssetIndex = album.assets.findIndex((a) => a.id == assetId);
+		currentViewAssetIndex = album.assets.findIndex((a) => a.id == asset.id);
 		selectedAsset = album.assets[currentViewAssetIndex];
 		selectedAsset = album.assets[currentViewAssetIndex];
 		isShowAssetViewer = true;
 		isShowAssetViewer = true;
 		pushState(selectedAsset.id);
 		pushState(selectedAsset.id);
 	};
 	};
 
 
+	const selectAssetHandler = (event: CustomEvent) => {
+		const { asset }: { asset: AssetResponseDto } = event.detail;
+		let temp = new Set(multiSelectAsset);
+
+		if (multiSelectAsset.has(asset)) {
+			temp.delete(asset);
+		} else {
+			temp.add(asset);
+		}
+
+		multiSelectAsset = temp;
+	};
+
+	const clearMultiSelectAssetAssetHandler = () => {
+		multiSelectAsset = new Set();
+	};
+
+	const removeSelectedAssetFromAlbum = async () => {
+		if (window.confirm('Do you want to remove selected assets from the album?')) {
+			try {
+				const { data } = await api.albumApi.removeAssetFromAlbum(album.id, {
+					assetIds: Array.from(multiSelectAsset).map((a) => a.id)
+				});
+
+				album = data;
+				multiSelectAsset = new Set();
+			} catch (e) {
+				console.log('Error [album-viewer] [removeAssetFromAlbum]', e);
+			}
+		}
+	};
 	const navigateAssetForward = () => {
 	const navigateAssetForward = () => {
 		try {
 		try {
 			if (currentViewAssetIndex < album.assets.length - 1) {
 			if (currentViewAssetIndex < album.assets.length - 1) {
@@ -191,32 +225,60 @@
 </script>
 </script>
 
 
 <section class="bg-immich-bg">
 <section class="bg-immich-bg">
-	<AlbumAppBar on:close-button-click={() => goto(backUrl)} backIcon={ArrowLeft}>
-		<svelte:fragment slot="trailing">
-			{#if album.assets.length > 0}
-				<CircleIconButton
-					title="Add Photos"
-					on:click={() => (isShowAssetSelection = true)}
-					logo={FileImagePlusOutline}
-				/>
-
-				<CircleIconButton
-					title="Share"
-					on:click={() => (isShowShareUserSelection = true)}
-					logo={ShareVariantOutline}
-				/>
-			{/if}
+	<!-- Multiselection mode app bar -->
+	{#if isMultiSelectionMode}
+		<AlbumAppBar
+			on:close-button-click={clearMultiSelectAssetAssetHandler}
+			backIcon={Close}
+			tailwindClasses={'bg-white shadow-md'}
+		>
+			<svelte:fragment slot="leading">
+				<p class="font-medium text-immich-primary">Selected {multiSelectAsset.size}</p>
+			</svelte:fragment>
+			<svelte:fragment slot="trailing">
+				{#if isOwned}
+					<CircleIconButton
+						title="Remove from album"
+						on:click={removeSelectedAssetFromAlbum}
+						logo={DeleteOutline}
+					/>
+				{/if}
+			</svelte:fragment>
+		</AlbumAppBar>
+	{/if}
+
+	<!-- Default app bar -->
+	{#if !isMultiSelectionMode}
+		<AlbumAppBar on:close-button-click={() => goto(backUrl)} backIcon={ArrowLeft}>
+			<svelte:fragment slot="trailing">
+				{#if album.assets.length > 0}
+					<CircleIconButton
+						title="Add Photos"
+						on:click={() => (isShowAssetSelection = true)}
+						logo={FileImagePlusOutline}
+					/>
+
+					<!-- Sharing only for owner -->
+					{#if isOwned}
+						<CircleIconButton
+							title="Share"
+							on:click={() => (isShowShareUserSelection = true)}
+							logo={ShareVariantOutline}
+						/>
+					{/if}
+				{/if}
 
 
-			{#if isCreatingSharedAlbum && album.sharedUsers.length == 0}
-				<button
-					disabled={album.assets.length == 0}
-					on:click={() => (isShowShareUserSelection = true)}
-					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">Share</span></button
-				>
-			{/if}
-		</svelte:fragment>
-	</AlbumAppBar>
+				{#if isCreatingSharedAlbum && album.sharedUsers.length == 0}
+					<button
+						disabled={album.assets.length == 0}
+						on:click={() => (isShowShareUserSelection = true)}
+						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">Share</span></button
+					>
+				{/if}
+			</svelte:fragment>
+		</AlbumAppBar>
+	{/if}
 
 
 	<section class="m-auto my-[160px] w-[60%]">
 	<section class="m-auto my-[160px] w-[60%]">
 		<input
 		<input
@@ -237,9 +299,11 @@
 		{#if album.shared}
 		{#if album.shared}
 			<div class="my-6 flex">
 			<div class="my-6 flex">
 				{#each album.sharedUsers as user}
 				{#each album.sharedUsers as user}
-					<span class="mr-1">
-						<CircleAvatar {user} on:click={() => (isShowShareInfoModal = true)} />
-					</span>
+					{#key user.id}
+						<span class="mr-1">
+							<CircleAvatar {user} on:click={() => (isShowShareInfoModal = true)} />
+						</span>
+					{/key}
 				{/each}
 				{/each}
 
 
 				<button
 				<button
@@ -255,16 +319,28 @@
 		{#if album.assets.length > 0}
 		{#if album.assets.length > 0}
 			<div class="flex flex-wrap gap-1 w-full pb-20" bind:clientWidth={viewWidth}>
 			<div class="flex flex-wrap gap-1 w-full pb-20" bind:clientWidth={viewWidth}>
 				{#each album.assets as asset}
 				{#each album.assets as asset}
-					{#if album.assets.length < 7}
-						<ImmichThumbnail
-							{asset}
-							{thumbnailSize}
-							format={ThumbnailFormat.Jpeg}
-							on:click={viewAsset}
-						/>
-					{:else}
-						<ImmichThumbnail {asset} {thumbnailSize} on:click={viewAsset} />
-					{/if}
+					{#key asset.id}
+						{#if album.assets.length < 7}
+							<ImmichThumbnail
+								{asset}
+								{thumbnailSize}
+								format={ThumbnailFormat.Jpeg}
+								on:click={(e) =>
+									isMultiSelectionMode ? selectAssetHandler(e) : viewAssetHandler(e)}
+								on:select={selectAssetHandler}
+								selected={multiSelectAsset.has(asset)}
+							/>
+						{:else}
+							<ImmichThumbnail
+								{asset}
+								{thumbnailSize}
+								on:click={(e) =>
+									isMultiSelectionMode ? selectAssetHandler(e) : viewAssetHandler(e)}
+								on:select={selectAssetHandler}
+								selected={multiSelectAsset.has(asset)}
+							/>
+						{/if}
+					{/key}
 				{/each}
 				{/each}
 			</div>
 			</div>
 		{:else}
 		{:else}

+ 7 - 5
web/src/lib/components/album-page/share-info-modal.svelte

@@ -42,11 +42,13 @@
 	};
 	};
 
 
 	const removeUser = async (userId: string) => {
 	const removeUser = async (userId: string) => {
-		try {
-			await api.albumApi.removeUserFromAlbum(album.id, userId);
-			dispatch('user-deleted', { userId });
-		} catch (e) {
-			console.error('Error [share-info-modal] [removeUser]', e);
+		if (window.confirm('Do you want to remove selected user from the album?')) {
+			try {
+				await api.albumApi.removeUserFromAlbum(album.id, userId);
+				dispatch('user-deleted', { userId });
+			} catch (e) {
+				console.error('Error [share-info-modal] [removeUser]', e);
+			}
 		}
 		}
 	};
 	};
 </script>
 </script>

+ 18 - 24
web/src/lib/components/album-page/user-selection-modal.svelte

@@ -6,7 +6,7 @@
 
 
 	export let sharedUsersInAlbum: Set<UserResponseDto>;
 	export let sharedUsersInAlbum: Set<UserResponseDto>;
 	let users: UserResponseDto[] = [];
 	let users: UserResponseDto[] = [];
-	let selectedUsers: Set<UserResponseDto> = new Set();
+	let selectedUsers: UserResponseDto[] = [];
 
 
 	const dispatch = createEventDispatcher();
 	const dispatch = createEventDispatcher();
 
 
@@ -22,23 +22,15 @@
 	});
 	});
 
 
 	const selectUser = (user: UserResponseDto) => {
 	const selectUser = (user: UserResponseDto) => {
-		const tempSelectedUsers = new Set(selectedUsers);
-
-		if (selectedUsers.has(user)) {
-			tempSelectedUsers.delete(user);
+		if (selectedUsers.includes(user)) {
+			selectedUsers = selectedUsers.filter((selectedUser) => selectedUser.id !== user.id);
 		} else {
 		} else {
-			tempSelectedUsers.add(user);
+			selectedUsers = [...selectedUsers, user];
 		}
 		}
-
-		selectedUsers = tempSelectedUsers;
 	};
 	};
 
 
 	const deselectUser = (user: UserResponseDto) => {
 	const deselectUser = (user: UserResponseDto) => {
-		const tempSelectedUsers = new Set(selectedUsers);
-
-		tempSelectedUsers.delete(user);
-
-		selectedUsers = tempSelectedUsers;
+		selectedUsers = selectedUsers.filter((selectedUser) => selectedUser.id !== user.id);
 	};
 	};
 </script>
 </script>
 
 
@@ -51,18 +43,20 @@
 	</svelte:fragment>
 	</svelte:fragment>
 
 
 	<div class=" max-h-[400px] overflow-y-auto immich-scrollbar">
 	<div class=" max-h-[400px] overflow-y-auto immich-scrollbar">
-		{#if selectedUsers.size > 0}
+		{#if selectedUsers.length > 0}
 			<div class="flex gap-4 py-2 px-5 overflow-x-auto place-items-center mb-2">
 			<div class="flex gap-4 py-2 px-5 overflow-x-auto place-items-center mb-2">
 				<p class="font-medium">To</p>
 				<p class="font-medium">To</p>
 
 
-				{#each Array.from(selectedUsers) as user}
-					<button
-						on:click={() => deselectUser(user)}
-						class="flex gap-1 place-items-center border border-gray-400 rounded-full p-1 hover:bg-gray-200 transition-colors"
-					>
-						<CircleAvatar size={28} {user} />
-						<p class="text-xs font-medium">{user.firstName} {user.lastName}</p>
-					</button>
+				{#each selectedUsers as user}
+					{#key user.id}
+						<button
+							on:click={() => deselectUser(user)}
+							class="flex gap-1 place-items-center border border-gray-400 rounded-full p-1 hover:bg-gray-200 transition-colors"
+						>
+							<CircleAvatar size={28} {user} />
+							<p class="text-xs font-medium">{user.firstName} {user.lastName}</p>
+						</button>
+					{/key}
 				{/each}
 				{/each}
 			</div>
 			</div>
 		{/if}
 		{/if}
@@ -76,7 +70,7 @@
 						on:click={() => selectUser(user)}
 						on:click={() => selectUser(user)}
 						class="w-full flex place-items-center gap-4 py-4 px-5 hover:bg-gray-200  transition-all"
 						class="w-full flex place-items-center gap-4 py-4 px-5 hover:bg-gray-200  transition-all"
 					>
 					>
-						{#if selectedUsers.has(user)}
+						{#if selectedUsers.includes(user)}
 							<span
 							<span
 								class="bg-immich-primary text-white rounded-full w-12 h-12 border flex place-items-center place-content-center text-3xl"
 								class="bg-immich-primary text-white rounded-full w-12 h-12 border flex place-items-center place-content-center text-3xl"
 								>✓</span
 								>✓</span
@@ -104,7 +98,7 @@
 			</p>
 			</p>
 		{/if}
 		{/if}
 
 
-		{#if selectedUsers.size > 0}
+		{#if selectedUsers.length > 0}
 			<div class="flex place-content-end p-5 ">
 			<div class="flex place-content-end p-5 ">
 				<button
 				<button
 					on:click={() => dispatch('add-user', { selectedUsers })}
 					on:click={() => dispatch('add-user', { selectedUsers })}

+ 9 - 3
web/src/lib/components/shared-components/immich-thumbnail.svelte

@@ -171,9 +171,14 @@
 	};
 	};
 	const thumbnailClickedHandler = () => {
 	const thumbnailClickedHandler = () => {
 		if (!isExisted) {
 		if (!isExisted) {
-			dispatch('click', { assetId: asset.id, deviceId: asset.deviceId });
+			dispatch('click', { asset });
 		}
 		}
 	};
 	};
+
+	const onIconClickedHandler = (e: MouseEvent) => {
+		e.stopPropagation();
+		dispatch('select', { asset });
+	};
 </script>
 </script>
 
 
 <IntersectionObserver once={true} let:intersecting>
 <IntersectionObserver once={true} let:intersecting>
@@ -192,7 +197,8 @@
 				in:fade={{ duration: 200 }}
 				in:fade={{ duration: 200 }}
 				class={`w-full ${getOverlaySelectorIconStyle()} via-white/0 to-white/0 absolute p-2  z-10`}
 				class={`w-full ${getOverlaySelectorIconStyle()} via-white/0 to-white/0 absolute p-2  z-10`}
 			>
 			>
-				<div
+				<button
+					on:click={onIconClickedHandler}
 					on:mouseenter={() => (mouseOverIcon = true)}
 					on:mouseenter={() => (mouseOverIcon = true)}
 					on:mouseleave={() => (mouseOverIcon = false)}
 					on:mouseleave={() => (mouseOverIcon = false)}
 					class="inline-block"
 					class="inline-block"
@@ -204,7 +210,7 @@
 					{:else}
 					{:else}
 						<CheckCircle size="24" color={mouseOverIcon ? 'white' : '#d8dadb'} />
 						<CheckCircle size="24" color={mouseOverIcon ? 'white' : '#d8dadb'} />
 					{/if}
 					{/if}
-				</div>
+				</button>
 			</div>
 			</div>
 		{/if}
 		{/if}
 
 

+ 10 - 8
web/src/routes/photos/index.svelte

@@ -58,9 +58,9 @@
 	};
 	};
 
 
 	const viewAssetHandler = (event: CustomEvent) => {
 	const viewAssetHandler = (event: CustomEvent) => {
-		const { assetId, deviceId }: { assetId: string; deviceId: string } = event.detail;
+		const { asset }: { asset: AssetResponseDto } = event.detail;
 
 
-		currentViewAssetIndex = $flattenAssetGroupByDate.findIndex((a) => a.id == assetId);
+		currentViewAssetIndex = $flattenAssetGroupByDate.findIndex((a) => a.id == asset.id);
 		selectedAsset = $flattenAssetGroupByDate[currentViewAssetIndex];
 		selectedAsset = $flattenAssetGroupByDate[currentViewAssetIndex];
 		isShowAssetViewer = true;
 		isShowAssetViewer = true;
 		pushState(selectedAsset.id);
 		pushState(selectedAsset.id);
@@ -170,12 +170,14 @@
 						<!-- Image grid -->
 						<!-- Image grid -->
 						<div class="flex flex-wrap gap-[2px]">
 						<div class="flex flex-wrap gap-[2px]">
 							{#each assetsInDateGroup as asset}
 							{#each assetsInDateGroup as asset}
-								<ImmichThumbnail
-									{asset}
-									on:mouseEvent={thumbnailMouseEventHandler}
-									on:click={viewAssetHandler}
-									{groupIndex}
-								/>
+								{#key asset.id}
+									<ImmichThumbnail
+										{asset}
+										on:mouseEvent={thumbnailMouseEventHandler}
+										on:click={viewAssetHandler}
+										{groupIndex}
+									/>
+								{/key}
 							{/each}
 							{/each}
 						</div>
 						</div>
 					</div>
 					</div>

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor