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
This commit is contained in:
Alex 2022-07-23 23:23:14 -05:00 committed by GitHub
parent a35460cb84
commit 052db5d748
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 210 additions and 104 deletions

View File

@ -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

View File

@ -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)

View File

@ -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].

View File

@ -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> {

View File

@ -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);
} }

View File

@ -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(

File diff suppressed because one or more lines are too long

View File

@ -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));
}, },
/** /**

View File

@ -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

View File

@ -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';
import Close from 'svelte-material-icons/Close.svelte';
const dispatch = createEventDispatcher(); 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 viewAssetHandler = (event: CustomEvent) => {
const { assetId, deviceId }: { assetId: string; deviceId: string } = event.detail; 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}> <!-- Multiselection mode app bar -->
<svelte:fragment slot="trailing"> {#if isMultiSelectionMode}
{#if album.assets.length > 0} <AlbumAppBar
<CircleIconButton on:close-button-click={clearMultiSelectAssetAssetHandler}
title="Add Photos" backIcon={Close}
on:click={() => (isShowAssetSelection = true)} tailwindClasses={'bg-white shadow-md'}
logo={FileImagePlusOutline} >
/> <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}
<CircleIconButton <!-- Default app bar -->
title="Share" {#if !isMultiSelectionMode}
on:click={() => (isShowShareUserSelection = true)} <AlbumAppBar on:close-button-click={() => goto(backUrl)} backIcon={ArrowLeft}>
logo={ShareVariantOutline} <svelte:fragment slot="trailing">
/> {#if album.assets.length > 0}
{/if} <CircleIconButton
title="Add Photos"
on:click={() => (isShowAssetSelection = true)}
logo={FileImagePlusOutline}
/>
{#if isCreatingSharedAlbum && album.sharedUsers.length == 0} <!-- Sharing only for owner -->
<button {#if isOwned}
disabled={album.assets.length == 0} <CircleIconButton
on:click={() => (isShowShareUserSelection = true)} title="Share"
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" on:click={() => (isShowShareUserSelection = true)}
><span class="px-2">Share</span></button logo={ShareVariantOutline}
> />
{/if} {/if}
</svelte:fragment> {/if}
</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"> {#key user.id}
<CircleAvatar {user} on:click={() => (isShowShareInfoModal = true)} /> <span class="mr-1">
</span> <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} {#key asset.id}
<ImmichThumbnail {#if album.assets.length < 7}
{asset} <ImmichThumbnail
{thumbnailSize} {asset}
format={ThumbnailFormat.Jpeg} {thumbnailSize}
on:click={viewAsset} format={ThumbnailFormat.Jpeg}
/> on:click={(e) =>
{:else} isMultiSelectionMode ? selectAssetHandler(e) : viewAssetHandler(e)}
<ImmichThumbnail {asset} {thumbnailSize} on:click={viewAsset} /> on:select={selectAssetHandler}
{/if} 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}

View File

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

View File

@ -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.includes(user)) {
selectedUsers = selectedUsers.filter((selectedUser) => selectedUser.id !== user.id);
if (selectedUsers.has(user)) {
tempSelectedUsers.delete(user);
} else { } else {
tempSelectedUsers.add(user); selectedUsers = [...selectedUsers, user];
} }
selectedUsers = tempSelectedUsers;
}; };
const deselectUser = (user: UserResponseDto) => { const deselectUser = (user: UserResponseDto) => {
const tempSelectedUsers = new Set(selectedUsers); selectedUsers = selectedUsers.filter((selectedUser) => selectedUser.id !== user.id);
tempSelectedUsers.delete(user);
selectedUsers = tempSelectedUsers;
}; };
</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} {#each selectedUsers as user}
<button {#key user.id}
on:click={() => deselectUser(user)} <button
class="flex gap-1 place-items-center border border-gray-400 rounded-full p-1 hover:bg-gray-200 transition-colors" 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> <CircleAvatar size={28} {user} />
</button> <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 })}

View File

@ -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}

View File

@ -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 {#key asset.id}
{asset} <ImmichThumbnail
on:mouseEvent={thumbnailMouseEventHandler} {asset}
on:click={viewAssetHandler} on:mouseEvent={thumbnailMouseEventHandler}
{groupIndex} on:click={viewAssetHandler}
/> {groupIndex}
/>
{/key}
{/each} {/each}
</div> </div>
</div> </div>