1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

restore: bulk actions (#3730)

* feat: improve bulk isArchive and isFavorite updates

* chore: open api
This commit is contained in:
Jason Rasmussen 2023-08-16 16:04:55 -04:00 committed by GitHub
parent 8568ec838a
commit bab739efbd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 442 additions and 57 deletions

View File

@ -344,6 +344,31 @@ export interface AllJobStatusResponseDto {
*/
'videoConversion': JobStatusDto;
}
/**
*
* @export
* @interface AssetBulkUpdateDto
*/
export interface AssetBulkUpdateDto {
/**
*
* @type {Array<string>}
* @memberof AssetBulkUpdateDto
*/
'ids': Array<string>;
/**
*
* @type {boolean}
* @memberof AssetBulkUpdateDto
*/
'isArchived'?: boolean;
/**
*
* @type {boolean}
* @memberof AssetBulkUpdateDto
*/
'isFavorite'?: boolean;
}
/**
*
* @export
@ -5871,6 +5896,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
options: localVarRequestOptions,
};
},
/**
*
* @param {AssetBulkUpdateDto} assetBulkUpdateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateAssets: async (assetBulkUpdateDto: AssetBulkUpdateDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'assetBulkUpdateDto' is not null or undefined
assertParamExists('updateAssets', 'assetBulkUpdateDto', assetBulkUpdateDto)
const localVarPath = `/asset`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(assetBulkUpdateDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {File} assetData
@ -6259,6 +6328,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateAsset(id, updateAssetDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {AssetBulkUpdateDto} assetBulkUpdateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async updateAssets(assetBulkUpdateDto: AssetBulkUpdateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {File} assetData
@ -6495,6 +6574,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
updateAsset(requestParameters: AssetApiUpdateAssetRequest, options?: AxiosRequestConfig): AxiosPromise<AssetResponseDto> {
return localVarFp.updateAsset(requestParameters.id, requestParameters.updateAssetDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {AssetApiUpdateAssetsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {AssetApiUploadFileRequest} requestParameters Request parameters.
@ -7011,6 +7099,20 @@ export interface AssetApiUpdateAssetRequest {
readonly updateAssetDto: UpdateAssetDto
}
/**
* Request parameters for updateAssets operation in AssetApi.
* @export
* @interface AssetApiUpdateAssetsRequest
*/
export interface AssetApiUpdateAssetsRequest {
/**
*
* @type {AssetBulkUpdateDto}
* @memberof AssetApiUpdateAssets
*/
readonly assetBulkUpdateDto: AssetBulkUpdateDto
}
/**
* Request parameters for uploadFile operation in AssetApi.
* @export
@ -7366,6 +7468,17 @@ export class AssetApi extends BaseAPI {
return AssetApiFp(this.configuration).updateAsset(requestParameters.id, requestParameters.updateAssetDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {AssetApiUpdateAssetsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {AssetApiUploadFileRequest} requestParameters Request parameters.

View File

@ -15,6 +15,7 @@ doc/AlbumCountResponseDto.md
doc/AlbumResponseDto.md
doc/AllJobStatusResponseDto.md
doc/AssetApi.md
doc/AssetBulkUpdateDto.md
doc/AssetBulkUploadCheckDto.md
doc/AssetBulkUploadCheckItem.md
doc/AssetBulkUploadCheckResponseDto.md
@ -158,6 +159,7 @@ lib/model/api_key_create_dto.dart
lib/model/api_key_create_response_dto.dart
lib/model/api_key_response_dto.dart
lib/model/api_key_update_dto.dart
lib/model/asset_bulk_update_dto.dart
lib/model/asset_bulk_upload_check_dto.dart
lib/model/asset_bulk_upload_check_item.dart
lib/model/asset_bulk_upload_check_response_dto.dart
@ -270,6 +272,7 @@ test/api_key_create_response_dto_test.dart
test/api_key_response_dto_test.dart
test/api_key_update_dto_test.dart
test/asset_api_test.dart
test/asset_bulk_update_dto_test.dart
test/asset_bulk_upload_check_dto_test.dart
test/asset_bulk_upload_check_item_test.dart
test/asset_bulk_upload_check_response_dto_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/AssetBulkUpdateDto.md generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -808,6 +808,39 @@
"tags": [
"Asset"
]
},
"put": {
"operationId": "updateAssets",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetBulkUpdateDto"
}
}
},
"required": true
},
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Asset"
]
}
},
"/asset/assetById/{id}": {
@ -4841,6 +4874,27 @@
],
"type": "object"
},
"AssetBulkUpdateDto": {
"properties": {
"ids": {
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
},
"isArchived": {
"type": "boolean"
},
"isFavorite": {
"type": "boolean"
}
},
"required": [
"ids"
],
"type": "object"
},
"AssetBulkUploadCheckDto": {
"properties": {
"assets": {

View File

@ -79,6 +79,7 @@ export interface IAssetRepository {
getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
deleteAll(ownerId: string): Promise<void>;
getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;
updateAll(ids: string[], options: Partial<AssetEntity>): Promise<void>;
save(asset: Partial<AssetEntity>): Promise<AssetEntity>;
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null>;
getMapMarkers(ownerId: string, options?: MapMarkerSearchOptions): Promise<MapMarker[]>;

View File

@ -514,4 +514,22 @@ describe(AssetService.name, () => {
expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.id, {});
});
});
describe('updateAll', () => {
it('should require asset write access for all ids', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
await expect(
sut.updateAll(authStub.admin, {
ids: ['asset-1'],
isArchived: false,
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('should update all assets', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true });
expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true });
});
});
});

View File

@ -11,6 +11,7 @@ import { HumanReadableSize, usePagination } from '../domain.util';
import { ImmichReadStream, IStorageRepository, StorageCore, StorageFolder } from '../storage';
import { IAssetRepository } from './asset.repository';
import {
AssetBulkUpdateDto,
AssetIdsDto,
DownloadArchiveInfo,
DownloadInfoDto,
@ -268,4 +269,10 @@ export class AssetService {
const stats = await this.assetRepository.getStatistics(authUser.id, dto);
return mapStats(stats);
}
async updateAll(authUser: AuthUserDto, dto: AssetBulkUpdateDto) {
const { ids, ...options } = dto;
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids);
await this.assetRepository.updateAll(ids, options);
}
}

View File

@ -0,0 +1,12 @@
import { IsBoolean, IsOptional } from 'class-validator';
import { BulkIdsDto } from '../response-dto';
export class AssetBulkUpdateDto extends BulkIdsDto {
@IsOptional()
@IsBoolean()
isFavorite?: boolean;
@IsOptional()
@IsBoolean()
isArchived?: boolean;
}

View File

@ -1,5 +1,6 @@
export * from './asset-ids.dto';
export * from './asset-statistics.dto';
export * from './asset.dto';
export * from './download.dto';
export * from './map-marker.dto';
export * from './memory-lane.dto';

View File

@ -1,4 +1,5 @@
import {
AssetBulkUpdateDto,
AssetIdsDto,
AssetResponseDto,
AssetService,
@ -15,7 +16,7 @@ import {
} from '@app/domain';
import { MapMarkerDto } from '@app/domain/asset/dto/map-marker.dto';
import { MemoryLaneResponseDto } from '@app/domain/asset/response-dto/memory-lane-response.dto';
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Query, StreamableFile } from '@nestjs/common';
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Put, Query, StreamableFile } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { Authenticated, AuthUser, SharedLinkRoute } from '../app.guard';
import { asStreamableFile, UseValidation } from '../app.utils';
@ -76,4 +77,10 @@ export class AssetController {
getByTimeBucket(@AuthUser() authUser: AuthUserDto, @Query() dto: TimeBucketAssetDto): Promise<AssetResponseDto[]> {
return this.service.getByTimeBucket(authUser, dto);
}
@Put()
@HttpCode(HttpStatus.NO_CONTENT)
updateAssets(@AuthUser() authUser: AuthUserDto, @Body() dto: AssetBulkUpdateDto): Promise<void> {
return this.service.updateAll(authUser, dto);
}
}

View File

@ -129,6 +129,10 @@ export class AssetRepository implements IAssetRepository {
});
}
async updateAll(ids: string[], options: Partial<AssetEntity>): Promise<void> {
await this.repository.update({ id: In(ids) }, options);
}
async save(asset: Partial<AssetEntity>): Promise<AssetEntity> {
const { id } = await this.repository.save(asset);
return this.repository.findOneOrFail({

View File

@ -11,6 +11,7 @@ export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
getFirstAssetForAlbumId: jest.fn(),
getLastUpdatedAssetForAlbumId: jest.fn(),
getAll: jest.fn().mockResolvedValue({ items: [], hasNextPage: false }),
updateAll: jest.fn(),
deleteAll: jest.fn(),
save: jest.fn(),
findLivePhotoMatch: jest.fn(),

View File

@ -344,6 +344,31 @@ export interface AllJobStatusResponseDto {
*/
'videoConversion': JobStatusDto;
}
/**
*
* @export
* @interface AssetBulkUpdateDto
*/
export interface AssetBulkUpdateDto {
/**
*
* @type {Array<string>}
* @memberof AssetBulkUpdateDto
*/
'ids': Array<string>;
/**
*
* @type {boolean}
* @memberof AssetBulkUpdateDto
*/
'isArchived'?: boolean;
/**
*
* @type {boolean}
* @memberof AssetBulkUpdateDto
*/
'isFavorite'?: boolean;
}
/**
*
* @export
@ -5871,6 +5896,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
options: localVarRequestOptions,
};
},
/**
*
* @param {AssetBulkUpdateDto} assetBulkUpdateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateAssets: async (assetBulkUpdateDto: AssetBulkUpdateDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'assetBulkUpdateDto' is not null or undefined
assertParamExists('updateAssets', 'assetBulkUpdateDto', assetBulkUpdateDto)
const localVarPath = `/asset`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(assetBulkUpdateDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {File} assetData
@ -6259,6 +6328,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateAsset(id, updateAssetDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {AssetBulkUpdateDto} assetBulkUpdateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async updateAssets(assetBulkUpdateDto: AssetBulkUpdateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {File} assetData
@ -6495,6 +6574,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
updateAsset(requestParameters: AssetApiUpdateAssetRequest, options?: AxiosRequestConfig): AxiosPromise<AssetResponseDto> {
return localVarFp.updateAsset(requestParameters.id, requestParameters.updateAssetDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {AssetApiUpdateAssetsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {AssetApiUploadFileRequest} requestParameters Request parameters.
@ -7011,6 +7099,20 @@ export interface AssetApiUpdateAssetRequest {
readonly updateAssetDto: UpdateAssetDto
}
/**
* Request parameters for updateAssets operation in AssetApi.
* @export
* @interface AssetApiUpdateAssetsRequest
*/
export interface AssetApiUpdateAssetsRequest {
/**
*
* @type {AssetBulkUpdateDto}
* @memberof AssetApiUpdateAssets
*/
readonly assetBulkUpdateDto: AssetBulkUpdateDto
}
/**
* Request parameters for uploadFile operation in AssetApi.
* @export
@ -7366,6 +7468,17 @@ export class AssetApi extends BaseAPI {
return AssetApiFp(this.configuration).updateAsset(requestParameters.id, requestParameters.updateAssetDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {AssetApiUpdateAssetsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {AssetApiUploadFileRequest} requestParameters Request parameters.

View File

@ -4,15 +4,15 @@
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import { api } from '@api';
import ArchiveArrowDownOutline from 'svelte-material-icons/ArchiveArrowDownOutline.svelte';
import ArchiveArrowUpOutline from 'svelte-material-icons/ArchiveArrowUpOutline.svelte';
import TimerSand from 'svelte-material-icons/TimerSand.svelte';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { OnAssetArchive, getAssetControlContext } from '../asset-select-control-bar.svelte';
import { OnArchive, getAssetControlContext } from '../asset-select-control-bar.svelte';
export let onAssetArchive: OnAssetArchive = (asset, isArchived) => {
asset.isArchived = isArchived;
};
export let onArchive: OnArchive | undefined = undefined;
export let menuItem = false;
export let unarchive = false;
@ -20,32 +20,50 @@
$: text = unarchive ? 'Unarchive' : 'Archive';
$: logo = unarchive ? ArchiveArrowUpOutline : ArchiveArrowDownOutline;
let loading = false;
const { getAssets, clearSelect } = getAssetControlContext();
const handleArchive = async () => {
const isArchived = !unarchive;
let cnt = 0;
loading = true;
for (const asset of getAssets()) {
if (asset.isArchived !== isArchived) {
api.assetApi.updateAsset({ id: asset.id, updateAssetDto: { isArchived } });
try {
const assets = Array.from(getAssets()).filter((asset) => asset.isArchived !== isArchived);
const ids = assets.map(({ id }) => id);
onAssetArchive(asset, isArchived);
cnt = cnt + 1;
if (ids.length > 0) {
await api.assetApi.updateAssets({ assetBulkUpdateDto: { ids, isArchived } });
}
for (const asset of assets) {
asset.isArchived = isArchived;
}
onArchive?.(ids, isArchived);
notificationController.show({
message: `${isArchived ? 'Archived' : 'Unarchived'} ${ids.length}`,
type: NotificationType.Info,
});
clearSelect();
} catch (error) {
handleError(error, `Unable to ${isArchived ? 'archive' : 'unarchive'}`);
} finally {
loading = false;
}
notificationController.show({
message: `${isArchived ? 'Archived' : 'Unarchived'} ${cnt}`,
type: NotificationType.Info,
});
clearSelect();
};
</script>
{#if menuItem}
<MenuOption {text} on:click={handleArchive} />
{:else}
<CircleIconButton title={text} {logo} on:click={handleArchive} />
{/if}
{#if !menuItem}
{#if loading}
<CircleIconButton title="Loading" logo={TimerSand} />
{:else}
<CircleIconButton title={text} {logo} on:click={handleArchive} />
{/if}
{/if}

View File

@ -1,23 +1,27 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import { api } from '@api';
import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
import { OnAssetDelete, getAssetControlContext } from '../asset-select-control-bar.svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import { handleError } from '../../../utils/handle-error';
import TimerSand from 'svelte-material-icons/TimerSand.svelte';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { OnAssetDelete, getAssetControlContext } from '../asset-select-control-bar.svelte';
export let onAssetDelete: OnAssetDelete;
export let menuItem = false;
const { getAssets, clearSelect } = getAssetControlContext();
let isShowConfirmation = false;
let loading = false;
const handleDelete = async () => {
loading = true;
try {
let count = 0;
@ -44,14 +48,21 @@
handleError(e, 'Error deleting assets');
} finally {
isShowConfirmation = false;
loading = false;
}
};
</script>
{#if menuItem}
<MenuOption text="Delete" on:click={() => (isShowConfirmation = true)} />
{:else}
<CircleIconButton title="Delete" logo={DeleteOutline} on:click={() => (isShowConfirmation = true)} />
{/if}
{#if !menuItem}
{#if loading}
<CircleIconButton title="Loading" logo={TimerSand} />
{:else}
<CircleIconButton title="Delete" logo={DeleteOutline} on:click={() => (isShowConfirmation = true)} />
{/if}
{/if}
{#if isShowConfirmation}

View File

@ -5,14 +5,14 @@
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import { api } from '@api';
import HeartMinusOutline from 'svelte-material-icons/HeartMinusOutline.svelte';
import HeartOutline from 'svelte-material-icons/HeartOutline.svelte';
import { OnAssetFavorite, getAssetControlContext } from '../asset-select-control-bar.svelte';
import TimerSand from 'svelte-material-icons/TimerSand.svelte';
import { OnFavorite, getAssetControlContext } from '../asset-select-control-bar.svelte';
export let onAssetFavorite: OnAssetFavorite = (asset, isFavorite) => {
asset.isFavorite = isFavorite;
};
export let onFavorite: OnFavorite | undefined = undefined;
export let menuItem = false;
export let removeFavorite: boolean;
@ -20,31 +20,50 @@
$: text = removeFavorite ? 'Remove from Favorites' : 'Favorite';
$: logo = removeFavorite ? HeartMinusOutline : HeartOutline;
let loading = false;
const { getAssets, clearSelect } = getAssetControlContext();
const handleFavorite = () => {
const handleFavorite = async () => {
const isFavorite = !removeFavorite;
loading = true;
let cnt = 0;
for (const asset of getAssets()) {
if (asset.isFavorite !== isFavorite) {
api.assetApi.updateAsset({ id: asset.id, updateAssetDto: { isFavorite } });
onAssetFavorite(asset, isFavorite);
cnt = cnt + 1;
try {
const assets = Array.from(getAssets()).filter((asset) => asset.isFavorite !== isFavorite);
const ids = assets.map(({ id }) => id);
if (ids.length > 0) {
await api.assetApi.updateAssets({ assetBulkUpdateDto: { ids, isFavorite } });
}
for (const asset of assets) {
asset.isFavorite = isFavorite;
}
onFavorite?.(ids, isFavorite);
notificationController.show({
message: isFavorite ? `Added ${ids.length} to favorites` : `Removed ${ids.length} from favorites`,
type: NotificationType.Info,
});
clearSelect();
} catch (error) {
handleError(error, `Unable to ${isFavorite ? 'add to' : 'remove from'} favorites`);
} finally {
loading = false;
}
notificationController.show({
message: isFavorite ? `Added ${cnt} to favorites` : `Removed ${cnt} from favorites`,
type: NotificationType.Info,
});
clearSelect();
};
</script>
{#if menuItem}
<MenuOption {text} on:click={handleFavorite} />
{:else}
<CircleIconButton title={text} {logo} on:click={handleFavorite} />
{/if}
{#if !menuItem}
{#if loading}
<CircleIconButton title="Loading" logo={TimerSand} />
{:else}
<CircleIconButton title={text} {logo} on:click={handleFavorite} />
{/if}
{/if}

View File

@ -2,8 +2,8 @@
import { createContext } from '$lib/utils/context';
export type OnAssetDelete = (assetId: string) => void;
export type OnAssetArchive = (asset: AssetResponseDto, archived: boolean) => void;
export type OnAssetFavorite = (asset: AssetResponseDto, favorite: boolean) => void;
export type OnArchive = (ids: string[], isArchived: boolean) => void;
export type OnFavorite = (ids: string[], favorite: boolean) => void;
export interface AssetControlContext {
// Wrap assets in a function, because context isn't reactive.

View File

@ -180,12 +180,19 @@ export class AssetStore {
this.emit(false);
}
removeAsset(assetId: string) {
removeAssets(ids: string[]) {
// TODO: this could probably be more efficient
for (const id of ids) {
this.removeAsset(id);
}
}
removeAsset(id: string) {
for (let i = 0; i < this.buckets.length; i++) {
const bucket = this.buckets[i];
for (let j = 0; j < bucket.assets.length; j++) {
const asset = bucket.assets[j];
if (asset.id !== assetId) {
if (asset.id !== id) {
continue;
}

View File

@ -37,7 +37,7 @@
{#if $isMultiSelectState}
<AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
<ArchiveAction unarchive onAssetArchive={(asset) => assetStore.removeAsset(asset.id)} />
<ArchiveAction unarchive onArchive={(ids) => assetStore.removeAssets(ids)} />
<CreateSharedLink />
<SelectAllAssets {assetStore} {assetInteractionStore} />
<AssetSelectContextMenu icon={Plus} title="Add">

View File

@ -38,7 +38,7 @@
<!-- Multiselection mode app bar -->
{#if $isMultiSelectState}
<AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
<FavoriteAction removeFavorite onAssetFavorite={(asset) => assetStore.removeAsset(asset.id)} />
<FavoriteAction removeFavorite onFavorite={(ids) => assetStore.removeAssets(ids)} />
<CreateSharedLink />
<SelectAllAssets {assetStore} {assetInteractionStore} />
<AssetSelectContextMenu icon={Plus} title="Add">

View File

@ -202,11 +202,7 @@
<AssetSelectContextMenu icon={DotsVertical} title="Add">
<DownloadAction menuItem filename="{data.person.name || 'immich'}.zip" />
<FavoriteAction menuItem removeFavorite={isAllFavorite} />
<ArchiveAction
menuItem
unarchive={isAllArchive}
onAssetArchive={(asset) => $assetStore.removeAsset(asset.id)}
/>
<ArchiveAction menuItem unarchive={isAllArchive} onArchive={(ids) => $assetStore.removeAssets(ids)} />
</AssetSelectContextMenu>
</AssetSelectControlBar>
{:else}

View File

@ -51,7 +51,7 @@
<AssetSelectContextMenu icon={DotsVertical} title="Menu">
<FavoriteAction menuItem removeFavorite={isAllFavorite} />
<DownloadAction menuItem />
<ArchiveAction menuItem onAssetArchive={(asset) => assetStore.removeAsset(asset.id)} />
<ArchiveAction menuItem onArchive={(ids) => assetStore.removeAssets(ids)} />
</AssetSelectContextMenu>
</AssetSelectControlBar>
{/if}