refactor: favorite action (#25121)

This commit is contained in:
Jason Rasmussen
2026-01-07 16:21:19 -05:00
committed by GitHub
parent 78229baeab
commit 5bb3492616
17 changed files with 111 additions and 102 deletions

10
pnpm-lock.yaml generated
View File

@@ -726,8 +726,8 @@ importers:
specifier: file:../open-api/typescript-sdk
version: link:../open-api/typescript-sdk
'@immich/ui':
specifier: ^0.52.0
version: 0.52.0(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)
specifier: ^0.53.3
version: 0.53.3(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)
'@mapbox/mapbox-gl-rtl-text':
specifier: 0.2.3
version: 0.2.3(mapbox-gl@1.13.3)
@@ -3075,8 +3075,8 @@ packages:
peerDependencies:
svelte: ^5.0.0
'@immich/ui@0.52.0':
resolution: {integrity: sha512-ECQIE5qYNpe7Q5+hifIGUDaRQXBkPOp9dvZaHELWWzAGIhbwG+mUYwMpUgU2TO7fV5u8XU6nHyBuC055zApiWQ==}
'@immich/ui@0.53.3':
resolution: {integrity: sha512-Ax7ctU9KIZgET58+PoMQnf1XDOIH76Xa341TXDfLwF96F3fQZ/v4TA7Ycb6hmTwIYGU9arIgqGqQDbuuNxc2vA==}
peerDependencies:
svelte: ^5.0.0
@@ -15078,7 +15078,7 @@ snapshots:
dependencies:
svelte: 5.46.1
'@immich/ui@0.52.0(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)':
'@immich/ui@0.53.3(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)':
dependencies:
'@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.46.1)
'@internationalized/date': 3.10.0

View File

@@ -27,7 +27,7 @@
"@formatjs/icu-messageformat-parser": "^3.0.0",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.52.0",
"@immich/ui": "^0.53.3",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.14.0",

View File

@@ -9,6 +9,6 @@
const { title, icon, color = 'secondary', onAction } = $derived(action);
</script>
{#if action.$if?.() ?? true}
{#if icon && (action.$if?.() ?? true)}
<IconButton variant="ghost" shape="round" {color} {icon} aria-label={title} onclick={() => onAction(action)} />
{/if}

View File

@@ -10,6 +10,6 @@
const { title, icon, onAction } = $derived(action);
</script>
{#if action.$if?.() ?? true}
{#if icon && (action.$if?.() ?? true)}
<IconButton {size} shape="round" color="primary" {icon} aria-label={title} onclick={() => onAction(action)} />
{/if}

View File

@@ -5,8 +5,6 @@ import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto, StackRespon
type ActionMap = {
[AssetAction.ARCHIVE]: { asset: TimelineAsset };
[AssetAction.UNARCHIVE]: { asset: TimelineAsset };
[AssetAction.FAVORITE]: { asset: TimelineAsset };
[AssetAction.UNFAVORITE]: { asset: TimelineAsset };
[AssetAction.TRASH]: { asset: TimelineAsset };
[AssetAction.DELETE]: { asset: TimelineAsset };
[AssetAction.RESTORE]: { asset: TimelineAsset };

View File

@@ -1,51 +0,0 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import { AssetAction } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
import { IconButton, toastManager } from '@immich/ui';
import { mdiHeart, mdiHeartOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
interface Props {
asset: AssetResponseDto;
onAction: OnAction;
}
let { asset, onAction }: Props = $props();
const toggleFavorite = async () => {
try {
const data = await updateAsset({
id: asset.id,
updateAssetDto: {
isFavorite: !asset.isFavorite,
},
});
asset = { ...asset, isFavorite: data.isFavorite };
onAction({
type: asset.isFavorite ? AssetAction.FAVORITE : AssetAction.UNFAVORITE,
asset: toTimelineAsset(asset),
});
toastManager.success(asset.isFavorite ? $t('added_to_favorites') : $t('removed_from_favorites'));
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: asset.isFavorite } }));
}
};
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'f' }, onShortcut: toggleFavorite }} />
<IconButton
color="secondary"
shape="round"
variant="ghost"
icon={asset.isFavorite ? mdiHeart : mdiHeartOutline}
aria-label={asset.isFavorite ? $t('unfavorite') : $t('to_favorite')}
onclick={toggleFavorite}
/>

View File

@@ -8,7 +8,6 @@
import ArchiveAction from '$lib/components/asset-viewer/actions/archive-action.svelte';
import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte';
import DownloadAction from '$lib/components/asset-viewer/actions/download-action.svelte';
import FavoriteAction from '$lib/components/asset-viewer/actions/favorite-action.svelte';
import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte';
import RatingAction from '$lib/components/asset-viewer/actions/rating-action.svelte';
import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/remove-asset-from-stack.svelte';
@@ -28,7 +27,7 @@
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { user } from '$lib/stores/user.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetJobName, getSharedLink } from '$lib/utils';
import { getAssetJobName, getSharedLink, withoutIcons } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
@@ -105,6 +104,7 @@
const Close: ActionItem = {
title: $t('go_back'),
type: $t('assets'),
icon: mdiArrowLeft,
$if: () => !!onClose,
onAction: () => onClose?.(),
@@ -113,7 +113,9 @@
const { Cast } = $derived(getGlobalActions($t));
const { Share, Offline, PlayMotionPhoto, StopMotionPhoto, Info } = $derived(getAssetActions($t, asset));
const { Share, Offline, Favorite, Unfavorite, PlayMotionPhoto, StopMotionPhoto, Info } = $derived(
getAssetActions($t, asset),
);
// $: showEditorButton =
// isOwner &&
@@ -128,7 +130,7 @@
<CommandPaletteDefaultProvider
name={$t('assets')}
actions={[Close, Share, Offline, PlayMotionPhoto, StopMotionPhoto, Info]}
actions={withoutIcons([Close, Share, Offline, Favorite, Unfavorite, PlayMotionPhoto, StopMotionPhoto, Info])}
/>
<div
@@ -172,9 +174,10 @@
{/if}
<ActionButton action={Info} />
<ActionButton action={Favorite} />
<ActionButton action={Unfavorite} />
{#if isOwner}
<FavoriteAction {asset} {onAction} />
<RatingAction {asset} {onAction} />
{/if}

View File

@@ -350,15 +350,6 @@
selectedEditType = type;
};
const handleAssetReplace = async ({ oldAssetId, newAssetId }: { oldAssetId: string; newAssetId: string }) => {
if (oldAssetId !== asset.id) {
return;
}
await new Promise((promise) => setTimeout(promise, 500));
await goto(`${AppRoute.PHOTOS}/${newAssetId}`);
};
let isFullScreen = $derived(fullscreenElement !== null);
$effect(() => {
@@ -391,9 +382,24 @@
preloadManager.preload(cursor.nextAsset);
preloadManager.preload(cursor.previousAsset);
});
const onAssetReplace = async ({ oldAssetId, newAssetId }: { oldAssetId: string; newAssetId: string }) => {
if (oldAssetId !== asset.id) {
return;
}
await new Promise((promise) => setTimeout(promise, 500));
await goto(`${AppRoute.PHOTOS}/${newAssetId}`);
};
const onAssetUpdate = (update: AssetResponseDto) => {
if (asset.id === update.id) {
asset = update;
}
};
</script>
<OnEvents onAssetReplace={handleAssetReplace} />
<OnEvents {onAssetReplace} {onAssetUpdate} />
<svelte:document bind:fullscreenElement />

View File

@@ -39,13 +39,7 @@
timelineManager?: TimelineManager;
options?: TimelineManagerOptions;
assetInteraction: AssetInteraction;
removeAction?:
| AssetAction.UNARCHIVE
| AssetAction.ARCHIVE
| AssetAction.FAVORITE
| AssetAction.UNFAVORITE
| AssetAction.SET_VISIBILITY_TIMELINE
| null;
removeAction?: AssetAction.UNARCHIVE | AssetAction.ARCHIVE | AssetAction.SET_VISIBILITY_TIMELINE | null;
withStacked?: boolean;
showArchiveIcon?: boolean;
isShared?: boolean;

View File

@@ -25,13 +25,7 @@
album?: AlbumResponseDto;
person?: PersonResponseDto;
removeAction?:
| AssetAction.UNARCHIVE
| AssetAction.ARCHIVE
| AssetAction.FAVORITE
| AssetAction.UNFAVORITE
| AssetAction.SET_VISIBILITY_TIMELINE
| null;
removeAction?: AssetAction.UNARCHIVE | AssetAction.ARCHIVE | AssetAction.SET_VISIBILITY_TIMELINE | null;
}
let {
@@ -141,8 +135,6 @@
switch (action.type) {
case AssetAction.ARCHIVE:
case AssetAction.UNARCHIVE:
case AssetAction.FAVORITE:
case AssetAction.UNFAVORITE:
case AssetAction.ADD: {
timelineManager.upsertAssets([action.asset]);
break;

View File

@@ -3,8 +3,6 @@ export const UUID_REGEX = /^[\dA-Fa-f]{8}(?:\b-[\dA-Fa-f]{4}){3}\b-[\dA-Fa-f]{12
export enum AssetAction {
ARCHIVE = 'archive',
UNARCHIVE = 'unarchive',
FAVORITE = 'favorite',
UNFAVORITE = 'unfavorite',
TRASH = 'trash',
DELETE = 'delete',
RESTORE = 'restore',

View File

@@ -3,6 +3,7 @@ import type { ReleaseEvent } from '$lib/types';
import type {
AlbumResponseDto,
ApiKeyResponseDto,
AssetResponseDto,
LibraryResponseDto,
LoginResponseDto,
QueueResponseDto,
@@ -24,6 +25,7 @@ export type Events = {
ApiKeyUpdate: [ApiKeyResponseDto];
ApiKeyDelete: [ApiKeyResponseDto];
AssetUpdate: [AssetResponseDto];
AssetReplace: [{ oldAssetId: string; newAssetId: string }];
AlbumUpdate: [AlbumResponseDto];

View File

@@ -1,5 +1,6 @@
import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.svelte';
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
@@ -93,6 +94,7 @@ export class TimelineManager extends VirtualScrollManager {
#updatingIntersections = false;
#scrollableElement: HTMLElement | undefined = $state();
#showAssetOwners = new PersistedLocalStorage<boolean>('album-show-asset-owners', false);
#unsubscribes: Array<() => void> = [];
get showAssetOwners() {
return this.#showAssetOwners.current;
@@ -108,6 +110,12 @@ export class TimelineManager extends VirtualScrollManager {
constructor() {
super();
const onAssetUpdate = (asset: AssetResponseDto) => this.upsertAssets([toTimelineAsset(asset)]);
eventManager.on('AssetUpdate', onAssetUpdate);
this.#unsubscribes.push(() => eventManager.off('AssetUpdate', onAssetUpdate));
}
override get scrollTop(): number {
@@ -269,6 +277,11 @@ export class TimelineManager extends VirtualScrollManager {
public override destroy() {
this.disconnect();
this.isInitialized = false;
for (const unsubscribe of this.#unsubscribes) {
unsubscribe();
}
super.destroy();
}

View File

@@ -3,10 +3,14 @@ import { eventManager } from '$lib/managers/event-manager.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { user as authUser } from '$lib/stores/user.store';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { AssetVisibility, copyAsset, deleteAssets, type AssetResponseDto } from '@immich/sdk';
import { modalManager, type ActionItem } from '@immich/ui';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import { AssetVisibility, copyAsset, deleteAssets, updateAsset, type AssetResponseDto } from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import {
mdiAlertOutline,
mdiHeart,
mdiHeartOutline,
mdiInformationOutline,
mdiMotionPauseOutline,
mdiMotionPlayOutline,
@@ -16,9 +20,13 @@ import type { MessageFormatter } from 'svelte-i18n';
import { get } from 'svelte/store';
export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) => {
const currentAuthUser = get(authUser);
const isOwner = !!(currentAuthUser && currentAuthUser.id === asset.ownerId);
const Share: ActionItem = {
title: $t('share'),
icon: mdiShareVariantOutline,
type: $t('assets'),
$if: () => !!(get(authUser) && !asset.isTrashed && asset.visibility !== AssetVisibility.Locked),
onAction: () => modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] }),
};
@@ -26,6 +34,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
const PlayMotionPhoto: ActionItem = {
title: $t('play_motion_photo'),
icon: mdiMotionPlayOutline,
type: $t('assets'),
$if: () => !!asset.livePhotoVideoId && !assetViewerManager.isPlayingMotionPhoto,
onAction: () => {
assetViewerManager.isPlayingMotionPhoto = true;
@@ -35,15 +44,35 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
const StopMotionPhoto: ActionItem = {
title: $t('stop_motion_photo'),
icon: mdiMotionPauseOutline,
type: $t('assets'),
$if: () => !!asset.livePhotoVideoId && assetViewerManager.isPlayingMotionPhoto,
onAction: () => {
assetViewerManager.isPlayingMotionPhoto = false;
},
};
const Favorite: ActionItem = {
title: $t('to_favorite'),
icon: mdiHeartOutline,
type: $t('assets'),
$if: () => isOwner && !asset.isFavorite,
onAction: () => handleFavorite(asset),
shortcuts: [{ key: 'f' }],
};
const Unfavorite: ActionItem = {
title: $t('unfavorite'),
icon: mdiHeart,
type: $t('assets'),
$if: () => isOwner && asset.isFavorite,
onAction: () => handleUnfavorite(asset),
shortcuts: [{ key: 'f' }],
};
const Offline: ActionItem = {
title: $t('asset_offline'),
icon: mdiAlertOutline,
type: $t('assets'),
color: 'danger',
$if: () => !!asset.isOffline,
onAction: () => assetViewerManager.toggleDetailPanel(),
@@ -52,12 +81,37 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
const Info: ActionItem = {
title: $t('info'),
icon: mdiInformationOutline,
type: $t('assets'),
$if: () => asset.hasMetadata,
onAction: () => assetViewerManager.toggleDetailPanel(),
shortcuts: [{ key: 'i' }],
};
return { Share, PlayMotionPhoto, StopMotionPhoto, Offline, Info };
return { Share, Offline, Info, Favorite, Unfavorite, PlayMotionPhoto, StopMotionPhoto };
};
const handleFavorite = async (asset: AssetResponseDto) => {
const $t = await getFormatter();
try {
const response = await updateAsset({ id: asset.id, updateAssetDto: { isFavorite: true } });
toastManager.success($t('added_to_favorites'));
eventManager.emit('AssetUpdate', response);
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: asset.isFavorite } }));
}
};
const handleUnfavorite = async (asset: AssetResponseDto) => {
const $t = await getFormatter();
try {
const response = await updateAsset({ id: asset.id, updateAssetDto: { isFavorite: false } });
toastManager.success($t('removed_from_favorites'));
eventManager.emit('AssetUpdate', response);
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: asset.isFavorite } }));
}
};
export const handleReplaceAsset = async (oldAssetId: string) => {

View File

@@ -26,7 +26,7 @@ import {
type SharedLinkResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { toastManager, type ActionItem } from '@immich/ui';
import { mdiCogRefreshOutline, mdiDatabaseRefreshOutline, mdiHeadSyncOutline, mdiImageRefreshOutline } from '@mdi/js';
import { init, register, t } from 'svelte-i18n';
import { derived, get } from 'svelte/store';
@@ -440,3 +440,6 @@ export const getReleaseType = (
};
export const semverToName = ({ major, minor, patch }: ServerVersionResponseDto) => `v${major}.${minor}.${patch}`;
export const withoutIcons = (actions: ActionItem[]): ActionItem[] =>
actions.map((action) => ({ ...action, icon: undefined }));

View File

@@ -16,7 +16,6 @@
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
import { AssetAction } from '$lib/constants';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { preferences } from '$lib/stores/user.store';
@@ -55,7 +54,6 @@
bind:timelineManager
{options}
{assetInteraction}
removeAction={AssetAction.UNFAVORITE}
onEscape={handleEscape}
>
{#snippet empty()}

View File

@@ -145,7 +145,6 @@
icon: mdiThemeLightDark,
onAction: () => themeManager.toggleTheme(),
shortcuts: { shift: true, key: 't' },
isGlobal: true,
},
];
@@ -181,7 +180,7 @@
icon: mdiServer,
onAction: () => goto(AppRoute.ADMIN_STATS),
},
].map((route) => ({ ...route, type: $t('page'), isGlobal: true, $if: () => $user?.isAdmin }));
].map((route) => ({ ...route, type: $t('page'), $if: () => $user?.isAdmin }));
const commands = $derived([...userCommands, ...adminCommands]);
</script>