feat(web): manually link live photos (#12514)

feat(web,server): manually link live photos
This commit is contained in:
Jason Rasmussen 2024-09-10 08:51:11 -04:00 committed by GitHub
parent 12bfb19852
commit 27050af57b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 178 additions and 36 deletions

View File

@ -545,6 +545,38 @@ describe('/asset', () => {
expect(status).toEqual(200); expect(status).toEqual(200);
}); });
it('should not allow linking two photos', async () => {
const { status, body } = await request(app)
.put(`/assets/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ livePhotoVideoId: user1Assets[1].id });
expect(body).toEqual(errorDto.badRequest('Live photo video must be a video'));
expect(status).toEqual(400);
});
it('should not allow linking a video owned by another user', async () => {
const asset = await utils.createAsset(user2.accessToken, { assetData: { filename: 'example.mp4' } });
const { status, body } = await request(app)
.put(`/assets/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ livePhotoVideoId: asset.id });
expect(body).toEqual(errorDto.badRequest('Live photo video does not belong to the user'));
expect(status).toEqual(400);
});
it('should link a motion photo', async () => {
const asset = await utils.createAsset(user1.accessToken, { assetData: { filename: 'example.mp4' } });
const { status, body } = await request(app)
.put(`/assets/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ livePhotoVideoId: asset.id });
expect(status).toEqual(200);
expect(body).toMatchObject({ id: user1Assets[0].id, livePhotoVideoId: asset.id });
});
it('should update date time original when sidecar file contains DateTimeOriginal', async () => { it('should update date time original when sidecar file contains DateTimeOriginal', async () => {
const sidecarData = `<?xpacket begin='?' id='W5M0MpCehiHzreSzNTczkc9d'?> const sidecarData = `<?xpacket begin='?' id='W5M0MpCehiHzreSzNTczkc9d'?>
<x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='Image::ExifTool 12.40'> <x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='Image::ExifTool 12.40'>

View File

@ -18,6 +18,7 @@ class UpdateAssetDto {
this.isArchived, this.isArchived,
this.isFavorite, this.isFavorite,
this.latitude, this.latitude,
this.livePhotoVideoId,
this.longitude, this.longitude,
this.rating, this.rating,
}); });
@ -62,6 +63,14 @@ class UpdateAssetDto {
/// ///
num? latitude; num? latitude;
///
/// 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? livePhotoVideoId;
/// ///
/// Please note: This property should have been non-nullable! Since the specification file /// 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 /// does not include a default value (using the "default:" property), however, the generated
@ -87,6 +96,7 @@ class UpdateAssetDto {
other.isArchived == isArchived && other.isArchived == isArchived &&
other.isFavorite == isFavorite && other.isFavorite == isFavorite &&
other.latitude == latitude && other.latitude == latitude &&
other.livePhotoVideoId == livePhotoVideoId &&
other.longitude == longitude && other.longitude == longitude &&
other.rating == rating; other.rating == rating;
@ -98,11 +108,12 @@ class UpdateAssetDto {
(isArchived == null ? 0 : isArchived!.hashCode) + (isArchived == null ? 0 : isArchived!.hashCode) +
(isFavorite == null ? 0 : isFavorite!.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode) +
(latitude == null ? 0 : latitude!.hashCode) + (latitude == null ? 0 : latitude!.hashCode) +
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
(longitude == null ? 0 : longitude!.hashCode) + (longitude == null ? 0 : longitude!.hashCode) +
(rating == null ? 0 : rating!.hashCode); (rating == null ? 0 : rating!.hashCode);
@override @override
String toString() => 'UpdateAssetDto[dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, rating=$rating]'; String toString() => 'UpdateAssetDto[dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, livePhotoVideoId=$livePhotoVideoId, longitude=$longitude, rating=$rating]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -131,6 +142,11 @@ class UpdateAssetDto {
} else { } else {
// json[r'latitude'] = null; // json[r'latitude'] = null;
} }
if (this.livePhotoVideoId != null) {
json[r'livePhotoVideoId'] = this.livePhotoVideoId;
} else {
// json[r'livePhotoVideoId'] = null;
}
if (this.longitude != null) { if (this.longitude != null) {
json[r'longitude'] = this.longitude; json[r'longitude'] = this.longitude;
} else { } else {
@ -157,6 +173,7 @@ class UpdateAssetDto {
isArchived: mapValueOfType<bool>(json, r'isArchived'), isArchived: mapValueOfType<bool>(json, r'isArchived'),
isFavorite: mapValueOfType<bool>(json, r'isFavorite'), isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
latitude: num.parse('${json[r'latitude']}'), latitude: num.parse('${json[r'latitude']}'),
livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
longitude: num.parse('${json[r'longitude']}'), longitude: num.parse('${json[r'longitude']}'),
rating: num.parse('${json[r'rating']}'), rating: num.parse('${json[r'rating']}'),
); );

View File

@ -12241,6 +12241,10 @@
"latitude": { "latitude": {
"type": "number" "type": "number"
}, },
"livePhotoVideoId": {
"format": "uuid",
"type": "string"
},
"longitude": { "longitude": {
"type": "number" "type": "number"
}, },

View File

@ -427,6 +427,7 @@ export type UpdateAssetDto = {
isArchived?: boolean; isArchived?: boolean;
isFavorite?: boolean; isFavorite?: boolean;
latitude?: number; latitude?: number;
livePhotoVideoId?: string;
longitude?: number; longitude?: number;
rating?: number; rating?: number;
}; };

View File

@ -68,6 +68,9 @@ export class UpdateAssetDto extends UpdateAssetBase {
@Optional() @Optional()
@IsString() @IsString()
description?: string; description?: string;
@ValidateUUID({ optional: true })
livePhotoVideoId?: string;
} }
export class RandomAssetsDto { export class RandomAssetsDto {

View File

@ -17,9 +17,10 @@ type EmitEventMap = {
'album.update': [{ id: string; updatedBy: string }]; 'album.update': [{ id: string; updatedBy: string }];
'album.invite': [{ id: string; userId: string }]; 'album.invite': [{ id: string; userId: string }];
// tag events // asset events
'asset.tag': [{ assetId: string }]; 'asset.tag': [{ assetId: string }];
'asset.untag': [{ assetId: string }]; 'asset.untag': [{ assetId: string }];
'asset.hide': [{ assetId: string; userId: string }];
// session events // session events
'session.delete': [{ sessionId: string }]; 'session.delete': [{ sessionId: string }];

View File

@ -36,7 +36,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { requireAccess, requireUploadAccess } from 'src/utils/access'; import { requireAccess, requireUploadAccess } from 'src/utils/access';
import { getAssetFiles } from 'src/utils/asset.util'; import { getAssetFiles, onBeforeLink } from 'src/utils/asset.util';
import { CacheControl, ImmichFileResponse } from 'src/utils/file'; import { CacheControl, ImmichFileResponse } from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types'; import { mimeTypes } from 'src/utils/mime-types';
import { fromChecksum } from 'src/utils/request'; import { fromChecksum } from 'src/utils/request';
@ -158,20 +158,10 @@ export class AssetMediaService {
this.requireQuota(auth, file.size); this.requireQuota(auth, file.size);
if (dto.livePhotoVideoId) { if (dto.livePhotoVideoId) {
const motionAsset = await this.assetRepository.getById(dto.livePhotoVideoId); await onBeforeLink(
if (!motionAsset) { { asset: this.assetRepository, event: this.eventRepository },
throw new BadRequestException('Live photo video not found'); { userId: auth.user.id, livePhotoVideoId: dto.livePhotoVideoId },
} );
if (motionAsset.type !== AssetType.VIDEO) {
throw new BadRequestException('Live photo video must be a video');
}
if (motionAsset.ownerId !== auth.user.id) {
throw new BadRequestException('Live photo video does not belong to the user');
}
if (motionAsset.isVisible) {
await this.assetRepository.update({ id: motionAsset.id, isVisible: false });
this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, auth.user.id, motionAsset.id);
}
} }
const asset = await this.create(auth.user.id, dto, file, sidecarFile); const asset = await this.create(auth.user.id, dto, file, sidecarFile);

View File

@ -39,7 +39,7 @@ import { IStackRepository } from 'src/interfaces/stack.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { requireAccess } from 'src/utils/access'; import { requireAccess } from 'src/utils/access';
import { getAssetFiles, getMyPartnerIds } from 'src/utils/asset.util'; import { getAssetFiles, getMyPartnerIds, onBeforeLink } from 'src/utils/asset.util';
import { usePagination } from 'src/utils/pagination'; import { usePagination } from 'src/utils/pagination';
export class AssetService { export class AssetService {
@ -159,6 +159,14 @@ export class AssetService {
await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: [id] });
const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto; const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto;
if (rest.livePhotoVideoId) {
await onBeforeLink(
{ asset: this.assetRepository, event: this.eventRepository },
{ userId: auth.user.id, livePhotoVideoId: rest.livePhotoVideoId },
);
}
await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating }); await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating });
await this.assetRepository.update({ id, ...rest }); await this.assetRepository.update({ id, ...rest });

View File

@ -8,7 +8,7 @@ import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMapRepository } from 'src/interfaces/map.interface'; import { IMapRepository } from 'src/interfaces/map.interface';
@ -220,11 +220,10 @@ describe(MetadataService.name, () => {
await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe( await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(
JobStatus.SUCCESS, JobStatus.SUCCESS,
); );
expect(eventMock.clientSend).toHaveBeenCalledWith( expect(eventMock.emit).toHaveBeenCalledWith('asset.hide', {
ClientEvent.ASSET_HIDDEN, userId: assetStub.livePhotoMotionAsset.ownerId,
assetStub.livePhotoMotionAsset.ownerId, assetId: assetStub.livePhotoMotionAsset.id,
assetStub.livePhotoMotionAsset.id, });
);
}); });
it('should search by libraryId', async () => { it('should search by libraryId', async () => {

View File

@ -17,7 +17,7 @@ import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { ArgOf, IEventRepository } from 'src/interfaces/event.interface';
import { import {
IBaseJob, IBaseJob,
IEntityJob, IEntityJob,
@ -186,8 +186,7 @@ export class MetadataService {
await this.assetRepository.update({ id: motionAsset.id, isVisible: false }); await this.assetRepository.update({ id: motionAsset.id, isVisible: false });
await this.albumRepository.removeAsset(motionAsset.id); await this.albumRepository.removeAsset(motionAsset.id);
// Notify clients to hide the linked live photo asset await this.eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId: motionAsset.ownerId });
this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, motionAsset.ownerId, motionAsset.id);
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }

View File

@ -58,6 +58,12 @@ export class NotificationService {
} }
} }
@OnEmit({ event: 'asset.hide' })
onAssetHide({ assetId, userId }: ArgOf<'asset.hide'>) {
// Notify clients to hide the linked live photo asset
this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, userId, assetId);
}
@OnEmit({ event: 'user.signup' }) @OnEmit({ event: 'user.signup' })
async onUserSignup({ notify, id, tempPassword }: ArgOf<'user.signup'>) { async onUserSignup({ notify, id, tempPassword }: ArgOf<'user.signup'>) {
if (notify) { if (notify) {

View File

@ -1,8 +1,11 @@
import { BadRequestException } from '@nestjs/common';
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { AssetFileType, Permission } from 'src/enum'; import { AssetFileType, AssetType, Permission } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { checkAccess } from 'src/utils/access'; import { checkAccess } from 'src/utils/access';
@ -130,3 +133,24 @@ export const getMyPartnerIds = async ({ userId, repository, timelineEnabled }: P
return [...partnerIds]; return [...partnerIds];
}; };
export const onBeforeLink = async (
{ asset: assetRepository, event: eventRepository }: { asset: IAssetRepository; event: IEventRepository },
{ userId, livePhotoVideoId }: { userId: string; livePhotoVideoId: string },
) => {
const motionAsset = await assetRepository.getById(livePhotoVideoId);
if (!motionAsset) {
throw new BadRequestException('Live photo video not found');
}
if (motionAsset.type !== AssetType.VIDEO) {
throw new BadRequestException('Live photo video must be a video');
}
if (motionAsset.ownerId !== userId) {
throw new BadRequestException('Live photo video does not belong to the user');
}
if (motionAsset?.isVisible) {
await assetRepository.update({ id: livePhotoVideoId, isVisible: false });
await eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId });
}
};

View File

@ -0,0 +1,44 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import type { OnLink } from '$lib/utils/actions';
import { AssetTypeEnum, updateAsset } from '@immich/sdk';
import { mdiMotionPlayOutline, mdiTimerSand } from '@mdi/js';
import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
export let onLink: OnLink;
export let menuItem = false;
let loading = false;
const text = $t('link_motion_video');
const icon = mdiMotionPlayOutline;
const { clearSelect, getOwnedAssets } = getAssetControlContext();
const handleLink = async () => {
let [still, motion] = [...getOwnedAssets()];
if (still.type === AssetTypeEnum.Video) {
[still, motion] = [motion, still];
}
loading = true;
const response = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: motion.id } });
onLink(response);
clearSelect();
loading = false;
};
</script>
{#if menuItem}
<MenuOption {text} {icon} onClick={handleLink} />
{/if}
{#if !menuItem}
{#if loading}
<CircleIconButton title={$t('loading')} icon={mdiTimerSand} />
{:else}
<CircleIconButton title={text} {icon} on:click={handleLink} />
{/if}
{/if}

View File

@ -785,6 +785,7 @@
"library_options": "Library options", "library_options": "Library options",
"light": "Light", "light": "Light",
"like_deleted": "Like deleted", "like_deleted": "Like deleted",
"link_motion_video": "Link motion video",
"link_options": "Link options", "link_options": "Link options",
"link_to_oauth": "Link to OAuth", "link_to_oauth": "Link to OAuth",
"linked_oauth_account": "Linked OAuth account", "linked_oauth_account": "Linked OAuth account",

View File

@ -6,6 +6,7 @@ import { handleError } from './handle-error';
export type OnDelete = (assetIds: string[]) => void; export type OnDelete = (assetIds: string[]) => void;
export type OnRestore = (ids: string[]) => void; export type OnRestore = (ids: string[]) => void;
export type OnLink = (asset: AssetResponseDto) => void;
export type OnArchive = (ids: string[], isArchived: boolean) => void; export type OnArchive = (ids: string[], isArchived: boolean) => void;
export type OnFavorite = (ids: string[], favorite: boolean) => void; export type OnFavorite = (ids: string[], favorite: boolean) => void;
export type OnStack = (ids: string[]) => void; export type OnStack = (ids: string[]) => void;

View File

@ -2,30 +2,32 @@
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte'; import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte'; import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte';
import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte'; import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte'; import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte';
import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte'; import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte'; import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte'; import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte'; import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
import StackAction from '$lib/components/photos-page/actions/stack-action.svelte'; import LinkLivePhotoAction from '$lib/components/photos-page/actions/link-live-photo-action.svelte';
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import StackAction from '$lib/components/photos-page/actions/stack-action.svelte';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import MemoryLane from '$lib/components/photos-page/memory-lane.svelte'; import MemoryLane from '$lib/components/photos-page/memory-lane.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
import { AssetStore } from '$lib/stores/assets.store';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { mdiDotsVertical, mdiPlus } from '@mdi/js'; import { AssetStore } from '$lib/stores/assets.store';
import { preferences, user } from '$lib/stores/user.store'; import { preferences, user } from '$lib/stores/user.store';
import { t } from 'svelte-i18n'; import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; import { t } from 'svelte-i18n';
let { isViewing: showAssetViewer } = assetViewingStore; let { isViewing: showAssetViewer } = assetViewingStore;
const assetStore = new AssetStore({ isArchived: false, withStacked: true, withPartners: true }); const assetStore = new AssetStore({ isArchived: false, withStacked: true, withPartners: true });
@ -51,6 +53,13 @@
} }
}; };
const handleLink = (asset: AssetResponseDto) => {
if (asset.livePhotoVideoId) {
assetStore.removeAssets([asset.livePhotoVideoId]);
}
assetStore.updateAssets([asset]);
};
onDestroy(() => { onDestroy(() => {
assetStore.destroy(); assetStore.destroy();
}); });
@ -78,6 +87,9 @@
onUnstack={(assets) => assetStore.addAssets(assets)} onUnstack={(assets) => assetStore.addAssets(assets)}
/> />
{/if} {/if}
{#if $selectedAssets.size === 2 && [...$selectedAssets].some((asset) => asset.type === AssetTypeEnum.Image && [...$selectedAssets].some((asset) => asset.type === AssetTypeEnum.Video))}
<LinkLivePhotoAction menuItem onLink={handleLink} />
{/if}
<ChangeDate menuItem /> <ChangeDate menuItem />
<ChangeLocation menuItem /> <ChangeLocation menuItem />
<ArchiveAction menuItem onArchive={(assetIds) => assetStore.removeAssets(assetIds)} /> <ArchiveAction menuItem onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />