refactor: rename timeline actions (#22086)

This commit is contained in:
Jason Rasmussen
2025-09-16 13:37:01 -04:00
committed by GitHub
parent 449368eee7
commit c21860fb97
34 changed files with 149 additions and 149 deletions
@@ -0,0 +1,46 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
import type { OnAddToAlbum } from '$lib/utils/actions';
import { addAssetsToAlbum, addAssetsToAlbums } from '$lib/utils/asset-utils';
import { modalManager } from '@immich/ui';
import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
shared?: boolean;
onAddToAlbum?: OnAddToAlbum;
}
let { shared = false, onAddToAlbum = () => {} }: Props = $props();
const { getAssets } = getAssetControlContext();
const onClick = async () => {
const albums = await modalManager.show(AlbumPickerModal, { shared });
if (!albums || albums.length === 0) {
return;
}
const assetIds = [...getAssets()].map(({ id }) => id);
if (albums.length === 1) {
const album = albums[0];
await addAssetsToAlbum(album.id, assetIds);
onAddToAlbum(assetIds, album.id);
} else {
await addAssetsToAlbums(
albums.map(({ id }) => id),
assetIds,
);
onAddToAlbum(assetIds, albums[0].id);
}
};
</script>
<MenuOption
{onClick}
text={shared ? $t('add_to_shared_album') : $t('add_to_album')}
icon={shared ? mdiShareVariantOutline : mdiImageAlbum}
shortcut={{ key: 'l', shift: shared }}
/>
@@ -0,0 +1,56 @@
<script lang="ts">
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import type { OnArchive } from '$lib/utils/actions';
import { archiveAssets } from '$lib/utils/asset-utils';
import { AssetVisibility } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline, mdiTimerSand } from '@mdi/js';
import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
interface Props {
onArchive?: OnArchive;
menuItem?: boolean;
unarchive?: boolean;
}
let { onArchive, menuItem = false, unarchive = false }: Props = $props();
let text = $derived(unarchive ? $t('unarchive') : $t('to_archive'));
let icon = $derived(unarchive ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline);
let loading = $state(false);
const { clearSelect, getOwnedAssets } = getAssetControlContext();
const handleArchive = async () => {
const isArchived = unarchive ? AssetVisibility.Timeline : AssetVisibility.Archive;
const assets = [...getOwnedAssets()].filter((asset) => asset.visibility !== isArchived);
loading = true;
const ids = await archiveAssets(assets, isArchived as AssetVisibility);
if (ids) {
onArchive?.(ids, isArchived ? AssetVisibility.Archive : AssetVisibility.Timeline);
clearSelect();
}
loading = false;
};
</script>
{#if menuItem}
<MenuOption {text} {icon} onClick={handleArchive} />
{/if}
{#if !menuItem}
{#if loading}
<IconButton
shape="round"
color="secondary"
variant="ghost"
aria-label={$t('loading')}
icon={mdiTimerSand}
onclick={() => {}}
/>
{:else}
<IconButton shape="round" color="secondary" variant="ghost" aria-label={text} {icon} onclick={handleArchive} />
{/if}
{/if}
@@ -0,0 +1,40 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { getAssetJobIcon, getAssetJobMessage, getAssetJobName } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { AssetJobName, runAssetJobs } from '@immich/sdk';
import { t } from 'svelte-i18n';
interface Props {
jobs?: AssetJobName[];
}
let { jobs = [AssetJobName.RegenerateThumbnail, AssetJobName.RefreshMetadata, AssetJobName.TranscodeVideo] }: Props =
$props();
const { clearSelect, getOwnedAssets } = getAssetControlContext();
const isAllVideos = $derived([...getOwnedAssets()].every((asset) => asset.isVideo));
const handleRunJob = async (name: AssetJobName) => {
try {
const ids = [...getOwnedAssets()].map(({ id }) => id);
await runAssetJobs({ assetJobsDto: { assetIds: ids, name } });
notificationController.show({ message: $getAssetJobMessage(name), type: NotificationType.Info });
clearSelect();
} catch (error) {
handleError(error, $t('errors.unable_to_submit_job'));
}
};
</script>
{#each jobs as job (job)}
{#if isAllVideos || job !== AssetJobName.TranscodeVideo}
<MenuOption text={$getAssetJobName(job)} icon={getAssetJobIcon(job)} onClick={() => handleRunJob(job)} />
{/if}
{/each}
@@ -0,0 +1,85 @@
<script lang="ts">
import ChangeDate, {
type AbsoluteResult,
type RelativeResult,
} from '$lib/components/shared-components/change-date.svelte';
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { user } from '$lib/stores/user.store';
import { getSelectedAssets } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { fromTimelinePlainDateTime } from '$lib/utils/timeline-util.js';
import { updateAssets } from '@immich/sdk';
import { mdiCalendarEditOutline } from '@mdi/js';
import { DateTime, Duration } from 'luxon';
import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
interface Props {
menuItem?: boolean;
}
let { menuItem = false }: Props = $props();
const { clearSelect, getOwnedAssets } = getAssetControlContext();
let isShowChangeDate = $state(false);
let currentInterval = $derived.by(() => {
if (isShowChangeDate) {
const ids = getSelectedAssets(getOwnedAssets(), $user);
const assets = getOwnedAssets().filter((asset) => ids.includes(asset.id));
const imageTimestamps = assets.map((asset) => {
let localDateTime = fromTimelinePlainDateTime(asset.localDateTime);
let fileCreatedAt = fromTimelinePlainDateTime(asset.fileCreatedAt);
let offsetMinutes = localDateTime.diff(fileCreatedAt, 'minutes').shiftTo('minutes').minutes;
const timeZone = `UTC${offsetMinutes >= 0 ? '+' : ''}${Duration.fromObject({ minutes: offsetMinutes }).toFormat('hh:mm')}`;
return fileCreatedAt.setZone('utc', { keepLocalTime: true }).setZone(timeZone);
});
let minTimestamp = imageTimestamps[0];
let maxTimestamp = imageTimestamps[0];
for (let current of imageTimestamps) {
if (current < minTimestamp) {
minTimestamp = current;
}
if (current > maxTimestamp) {
maxTimestamp = current;
}
}
return { start: minTimestamp, end: maxTimestamp };
}
return undefined;
});
const handleConfirm = async (result: AbsoluteResult | RelativeResult) => {
isShowChangeDate = false;
const ids = getSelectedAssets(getOwnedAssets(), $user);
try {
if (result.mode === 'absolute') {
await updateAssets({ assetBulkUpdateDto: { ids, dateTimeOriginal: result.date } });
} else if (result.mode === 'relative') {
await updateAssets({
assetBulkUpdateDto: {
ids,
dateTimeRelative: result.duration,
timeZone: result.timeZone,
},
});
}
} catch (error) {
handleError(error, $t('errors.unable_to_change_date'));
}
clearSelect();
};
</script>
{#if menuItem}
<MenuOption text={$t('change_date')} icon={mdiCalendarEditOutline} onClick={() => (isShowChangeDate = true)} />
{/if}
{#if isShowChangeDate}
<ChangeDate
initialDate={DateTime.now()}
{currentInterval}
onConfirm={handleConfirm}
onCancel={() => (isShowChangeDate = false)}
/>
{/if}
@@ -0,0 +1,37 @@
<script lang="ts">
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import AssetUpdateDescriptionConfirmModal from '$lib/modals/AssetUpdateDescriptionConfirmModal.svelte';
import { user } from '$lib/stores/user.store';
import { getSelectedAssets } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { updateAssets } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { mdiText } from '@mdi/js';
import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
interface Props {
menuItem?: boolean;
}
let { menuItem = false }: Props = $props();
const { clearSelect, getOwnedAssets } = getAssetControlContext();
const handleUpdateDescription = async () => {
const description = await modalManager.show(AssetUpdateDescriptionConfirmModal);
if (description) {
const ids = getSelectedAssets(getOwnedAssets(), $user);
try {
await updateAssets({ assetBulkUpdateDto: { ids, description } });
} catch (error) {
handleError(error, $t('errors.unable_to_change_description'));
}
clearSelect();
}
};
</script>
{#if menuItem}
<MenuOption text={$t('change_description')} icon={mdiText} onClick={() => handleUpdateDescription()} />
{/if}
@@ -0,0 +1,48 @@
<script lang="ts">
import ChangeLocation from '$lib/components/shared-components/change-location.svelte';
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { user } from '$lib/stores/user.store';
import { getSelectedAssets } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { updateAssets } from '@immich/sdk';
import { mdiMapMarkerMultipleOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
interface Props {
menuItem?: boolean;
}
let { menuItem = false }: Props = $props();
const { clearSelect, getOwnedAssets } = getAssetControlContext();
let isShowChangeLocation = $state(false);
async function handleConfirm(point?: { lng: number; lat: number }) {
isShowChangeLocation = false;
if (!point) {
return;
}
const ids = getSelectedAssets(getOwnedAssets(), $user);
try {
await updateAssets({ assetBulkUpdateDto: { ids, latitude: point.lat, longitude: point.lng } });
clearSelect();
} catch (error) {
handleError(error, $t('errors.unable_to_update_location'));
}
}
</script>
{#if menuItem}
<MenuOption
text={$t('change_location')}
icon={mdiMapMarkerMultipleOutline}
onClick={() => (isShowChangeLocation = true)}
/>
{/if}
{#if isShowChangeLocation}
<ChangeLocation onClose={handleConfirm} />
{/if}
@@ -0,0 +1,30 @@
<script lang="ts">
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import QrCodeModal from '$lib/modals/QrCodeModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { makeSharedLinkUrl } from '$lib/utils';
import { IconButton, modalManager } from '@immich/ui';
import { mdiShareVariantOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
const { getAssets } = getAssetControlContext();
const handleClick = async () => {
const sharedLink = await modalManager.show(SharedLinkCreateModal, {
assetIds: [...getAssets()].map(({ id }) => id),
});
if (sharedLink) {
await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink) });
}
};
</script>
<IconButton
shape="round"
color="secondary"
variant="ghost"
aria-label={$t('share')}
icon={mdiShareVariantOutline}
onclick={handleClick}
/>
@@ -0,0 +1,74 @@
<script lang="ts">
import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { featureFlags } from '$lib/stores/server-config.store';
import { type OnDelete, type OnUndoDelete, deleteAssets } from '$lib/utils/actions';
import { IconButton } from '@immich/ui';
import { mdiDeleteForeverOutline, mdiDeleteOutline, mdiTimerSand } from '@mdi/js';
import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
interface Props {
onAssetDelete: OnDelete;
onUndoDelete?: OnUndoDelete | undefined;
menuItem?: boolean;
force?: boolean;
}
let { onAssetDelete, onUndoDelete = undefined, menuItem = false, force = !$featureFlags.trash }: Props = $props();
const { clearSelect, getOwnedAssets } = getAssetControlContext();
let isShowConfirmation = $state(false);
let loading = $state(false);
let label = $derived(force ? $t('permanently_delete') : $t('delete'));
const handleTrash = async () => {
if (force) {
isShowConfirmation = true;
return;
}
await handleDelete();
};
const handleDelete = async () => {
loading = true;
const assets = [...getOwnedAssets()];
await deleteAssets(force, onAssetDelete, assets, onUndoDelete);
clearSelect();
isShowConfirmation = false;
loading = false;
};
</script>
{#if menuItem}
<MenuOption text={label} icon={mdiDeleteOutline} onClick={handleTrash} />
{:else if loading}
<IconButton
shape="round"
color="secondary"
variant="ghost"
aria-label={$t('loading')}
icon={mdiTimerSand}
onclick={() => {}}
/>
{:else}
<IconButton
shape="round"
color="secondary"
variant="ghost"
aria-label={label}
icon={mdiDeleteForeverOutline}
onclick={handleTrash}
/>
{/if}
{#if isShowConfirmation}
<DeleteAssetDialog
size={getOwnedAssets().length}
onConfirm={handleDelete}
onCancel={() => (isShowConfirmation = false)}
/>
{/if}
@@ -0,0 +1,49 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { downloadArchive, downloadFile } from '$lib/utils/asset-utils';
import { getAssetInfo } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiDownload } from '@mdi/js';
import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
interface Props {
filename?: string;
menuItem?: boolean;
}
let { filename = 'immich.zip', menuItem = false }: Props = $props();
const { getAssets, clearSelect } = getAssetControlContext();
const handleDownloadFiles = async () => {
const assets = [...getAssets()];
if (assets.length === 1) {
clearSelect();
let asset = await getAssetInfo({ ...authManager.params, id: assets[0].id });
await downloadFile(asset);
return;
}
clearSelect();
await downloadArchive(filename, { assetIds: assets.map((asset) => asset.id) });
};
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'd', shift: true }, onShortcut: handleDownloadFiles }} />
{#if menuItem}
<MenuOption text={$t('download')} icon={mdiDownload} onClick={handleDownloadFiles} />
{:else}
<IconButton
shape="round"
color="secondary"
variant="ghost"
aria-label={$t('download')}
icon={mdiDownload}
onclick={handleDownloadFiles}
/>
{/if}
@@ -0,0 +1,82 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import type { OnFavorite } from '$lib/utils/actions';
import { handleError } from '$lib/utils/handle-error';
import { updateAssets } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiHeartMinusOutline, mdiHeartOutline, mdiTimerSand } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
onFavorite?: OnFavorite;
menuItem?: boolean;
removeFavorite: boolean;
}
let { onFavorite, menuItem = false, removeFavorite }: Props = $props();
let text = $derived(removeFavorite ? $t('remove_from_favorites') : $t('to_favorite'));
let icon = $derived(removeFavorite ? mdiHeartMinusOutline : mdiHeartOutline);
let loading = $state(false);
const { clearSelect, getOwnedAssets } = getAssetControlContext();
const handleFavorite = async () => {
const isFavorite = !removeFavorite;
loading = true;
try {
const assets = [...getOwnedAssets()].filter((asset) => asset.isFavorite !== isFavorite);
const ids = assets.map(({ id }) => id);
if (ids.length > 0) {
await updateAssets({ assetBulkUpdateDto: { ids, isFavorite } });
}
for (const asset of assets) {
asset.isFavorite = isFavorite;
}
onFavorite?.(ids, isFavorite);
notificationController.show({
message: isFavorite
? $t('added_to_favorites_count', { values: { count: ids.length } })
: $t('removed_from_favorites_count', { values: { count: ids.length } }),
type: NotificationType.Info,
});
clearSelect();
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: isFavorite } }));
} finally {
loading = false;
}
};
</script>
{#if menuItem}
<MenuOption {text} {icon} onClick={handleFavorite} />
{/if}
{#if !menuItem}
{#if loading}
<IconButton
shape="round"
color="secondary"
variant="ghost"
aria-label={$t('loading')}
icon={mdiTimerSand}
onclick={() => {}}
/>
{:else}
<IconButton shape="round" color="secondary" variant="ghost" aria-label={text} {icon} onclick={handleFavorite} />
{/if}
{/if}
@@ -0,0 +1,90 @@
<script lang="ts">
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { OnLink, OnUnlink } from '$lib/utils/actions';
import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAssetInfo, updateAsset } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiLinkOff, mdiMotionPlayOutline, mdiTimerSand } from '@mdi/js';
import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
interface Props {
onLink: OnLink;
onUnlink: OnUnlink;
menuItem?: boolean;
unlink?: boolean;
}
let { onLink, onUnlink, menuItem = false, unlink = false }: Props = $props();
let loading = $state(false);
let text = $derived(unlink ? $t('unlink_motion_video') : $t('link_motion_video'));
let icon = $derived(unlink ? mdiLinkOff : mdiMotionPlayOutline);
const { clearSelect, getOwnedAssets } = getAssetControlContext();
const onClick = () => (unlink ? handleUnlink() : handleLink());
const handleLink = async () => {
let [still, motion] = [...getOwnedAssets()];
if ((still as TimelineAsset).isVideo) {
[still, motion] = [motion, still];
}
try {
loading = true;
const stillResponse = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: motion.id } });
onLink({ still: toTimelineAsset(stillResponse), motion: motion as TimelineAsset });
clearSelect();
} catch (error) {
handleError(error, $t('errors.unable_to_link_motion_video'));
} finally {
loading = false;
}
};
const handleUnlink = async () => {
const [still] = [...getOwnedAssets()];
if (!still) {
return;
}
const motionId = still.livePhotoVideoId;
if (!motionId) {
return;
}
try {
loading = true;
const stillResponse = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: null } });
const motionResponse = await getAssetInfo({ ...authManager.params, id: motionId });
onUnlink({ still: toTimelineAsset(stillResponse), motion: toTimelineAsset(motionResponse) });
clearSelect();
} catch (error) {
handleError(error, $t('errors.unable_to_unlink_motion_video'));
} finally {
loading = false;
}
};
</script>
{#if menuItem}
<MenuOption {text} {icon} {onClick} />
{/if}
{#if !menuItem}
{#if loading}
<IconButton
shape="round"
color="secondary"
variant="ghost"
aria-label={$t('loading')}
icon={mdiTimerSand}
onclick={() => {}}
/>
{:else}
<IconButton shape="round" color="secondary" variant="ghost" aria-label={text} {icon} onclick={onClick} />
{/if}
{/if}
@@ -0,0 +1,71 @@
<script lang="ts">
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { getAlbumInfo, removeAssetFromAlbum, type AlbumResponseDto } from '@immich/sdk';
import { IconButton, modalManager } from '@immich/ui';
import { mdiDeleteOutline, mdiImageRemoveOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
interface Props {
album: AlbumResponseDto;
onRemove: ((assetIds: string[]) => void) | undefined;
menuItem?: boolean;
}
let { album = $bindable(), onRemove, menuItem = false }: Props = $props();
const { getAssets, clearSelect } = getAssetControlContext();
const removeFromAlbum = async () => {
const isConfirmed = await modalManager.showDialog({
prompt: $t('remove_assets_album_confirmation', { values: { count: getAssets().length } }),
});
if (!isConfirmed) {
return;
}
try {
const ids = [...getAssets()].map((a) => a.id);
const results = await removeAssetFromAlbum({
id: album.id,
bulkIdsDto: { ids },
});
album = await getAlbumInfo({ id: album.id });
onRemove?.(ids);
const count = results.filter(({ success }) => success).length;
notificationController.show({
type: NotificationType.Info,
message: $t('assets_removed_count', { values: { count } }),
});
clearSelect();
} catch (error) {
console.error('Error [album-viewer] [removeAssetFromAlbum]', error);
notificationController.show({
type: NotificationType.Error,
message: $t('errors.error_removing_assets_from_album'),
});
}
};
</script>
{#if menuItem}
<MenuOption text={$t('remove_from_album')} icon={mdiImageRemoveOutline} onClick={removeFromAlbum} />
{:else}
<IconButton
shape="round"
color="secondary"
variant="ghost"
aria-label={$t('remove_from_album')}
icon={mdiDeleteOutline}
onclick={removeFromAlbum}
/>
{/if}
@@ -0,0 +1,68 @@
<script lang="ts">
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { handleError } from '$lib/utils/handle-error';
import { removeSharedLinkAssets, type SharedLinkResponseDto } from '@immich/sdk';
import { IconButton, modalManager } from '@immich/ui';
import { mdiDeleteOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import { NotificationType, notificationController } from '../../shared-components/notification/notification';
interface Props {
sharedLink: SharedLinkResponseDto;
}
let { sharedLink = $bindable() }: Props = $props();
const { getAssets, clearSelect } = getAssetControlContext();
const handleRemove = async () => {
const isConfirmed = await modalManager.showDialog({
title: $t('remove_assets_title'),
prompt: $t('remove_assets_shared_link_confirmation', { values: { count: getAssets().length } }),
confirmText: $t('remove'),
});
if (!isConfirmed) {
return;
}
try {
const results = await removeSharedLinkAssets({
...authManager.params,
id: sharedLink.id,
assetIdsDto: {
assetIds: [...getAssets()].map((asset) => asset.id),
},
});
for (const result of results) {
if (!result.success) {
continue;
}
sharedLink.assets = sharedLink.assets.filter((asset) => asset.id !== result.assetId);
}
const count = results.filter((item) => item.success).length;
notificationController.show({
type: NotificationType.Info,
message: $t('assets_removed_count', { values: { count } }),
});
clearSelect();
} catch (error) {
handleError(error, $t('errors.unable_to_remove_assets_from_shared_link'));
}
};
</script>
<IconButton
shape="round"
color="secondary"
variant="ghost"
aria-label={$t('remove_from_shared_link')}
onclick={handleRemove}
icon={mdiDeleteOutline}
/>
@@ -0,0 +1,55 @@
<script lang="ts">
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import type { OnRestore } from '$lib/utils/actions';
import { handleError } from '$lib/utils/handle-error';
import { restoreAssets } from '@immich/sdk';
import { Button } from '@immich/ui';
import { mdiHistory } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
onRestore: OnRestore | undefined;
}
let { onRestore }: Props = $props();
const { getAssets, clearSelect } = getAssetControlContext();
let loading = $state(false);
const handleRestore = async () => {
loading = true;
try {
const ids = [...getAssets()].map((a) => a.id);
await restoreAssets({ bulkIdsDto: { ids } });
onRestore?.(ids);
notificationController.show({
message: $t('assets_restored_count', { values: { count: ids.length } }),
type: NotificationType.Info,
});
clearSelect();
} catch (error) {
handleError(error, $t('errors.unable_to_restore_assets'));
} finally {
loading = false;
}
};
</script>
<Button
leadingIcon={mdiHistory}
disabled={loading}
size="medium"
color="secondary"
variant="ghost"
onclick={handleRestore}
>
{$t('restore')}
</Button>
@@ -0,0 +1,46 @@
<script lang="ts">
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { cancelMultiselect, selectAllAssets } from '$lib/utils/asset-utils';
import { Button, IconButton } from '@immich/ui';
import { mdiSelectAll, mdiSelectRemove } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
timelineManager: TimelineManager;
assetInteraction: AssetInteraction;
withText?: boolean;
}
let { timelineManager, assetInteraction, withText = false }: Props = $props();
const handleSelectAll = async () => {
await selectAllAssets(timelineManager, assetInteraction);
};
const handleCancel = () => {
cancelMultiselect(assetInteraction);
};
</script>
{#if withText}
<Button
leadingIcon={$isSelectingAllAssets ? mdiSelectRemove : mdiSelectAll}
size="medium"
color="secondary"
variant="ghost"
onclick={$isSelectingAllAssets ? handleCancel : handleSelectAll}
>
{$isSelectingAllAssets ? $t('unselect_all') : $t('select_all')}
</Button>
{:else}
<IconButton
shape="round"
color="secondary"
variant="ghost"
aria-label={$isSelectingAllAssets ? $t('unselect_all') : $t('select_all')}
icon={$isSelectingAllAssets ? mdiSelectRemove : mdiSelectAll}
onclick={$isSelectingAllAssets ? handleCancel : handleSelectAll}
/>
{/if}
@@ -0,0 +1,71 @@
<script lang="ts">
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import type { OnSetVisibility } from '$lib/utils/actions';
import { handleError } from '$lib/utils/handle-error';
import { AssetVisibility, updateAssets } from '@immich/sdk';
import { Button, modalManager } from '@immich/ui';
import { mdiLockOpenVariantOutline, mdiLockOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
onVisibilitySet: OnSetVisibility;
menuItem?: boolean;
unlock?: boolean;
}
let { onVisibilitySet, menuItem = false, unlock = false }: Props = $props();
let loading = $state(false);
const { getAssets } = getAssetControlContext();
const setLockedVisibility = async () => {
const isConfirmed = await modalManager.showDialog({
title: unlock ? $t('remove_from_locked_folder') : $t('move_to_locked_folder'),
prompt: unlock ? $t('remove_from_locked_folder_confirmation') : $t('move_to_locked_folder_confirmation'),
confirmText: $t('move'),
confirmColor: unlock ? 'danger' : 'primary',
icon: unlock ? mdiLockOpenVariantOutline : mdiLockOutline,
});
if (!isConfirmed) {
return;
}
try {
loading = true;
const assetIds = getAssets().map(({ id }) => id);
await updateAssets({
assetBulkUpdateDto: {
ids: assetIds,
visibility: unlock ? AssetVisibility.Timeline : AssetVisibility.Locked,
},
});
onVisibilitySet(assetIds);
} catch (error) {
handleError(error, $t('errors.unable_to_save_settings'));
} finally {
loading = false;
}
};
</script>
{#if menuItem}
<MenuOption
onClick={setLockedVisibility}
text={unlock ? $t('move_off_locked_folder') : $t('move_to_locked_folder')}
icon={unlock ? mdiLockOpenVariantOutline : mdiLockOutline}
/>
{:else}
<Button
leadingIcon={unlock ? mdiLockOpenVariantOutline : mdiLockOutline}
disabled={loading}
size="medium"
color="secondary"
variant="ghost"
onclick={setLockedVisibility}
>
{unlock ? $t('move_off_locked_folder') : $t('move_to_locked_folder')}
</Button>
{/if}
@@ -0,0 +1,48 @@
<script lang="ts">
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import type { OnStack, OnUnstack } from '$lib/utils/actions';
import { deleteStack, stackAssets } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { mdiImageMultipleOutline, mdiImageOffOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
unstack?: boolean;
onStack: OnStack | undefined;
onUnstack: OnUnstack | undefined;
}
let { unstack = false, onStack, onUnstack }: Props = $props();
const { clearSelect, getOwnedAssets } = getAssetControlContext();
const handleStack = async () => {
const selectedAssets = [...getOwnedAssets()];
const result = await stackAssets(selectedAssets);
onStack?.(result);
clearSelect();
};
const handleUnstack = async () => {
const selectedAssets = [...getOwnedAssets()];
if (selectedAssets.length !== 1) {
return;
}
const { stack } = selectedAssets[0];
if (!stack) {
return;
}
const unstackedAssets = await deleteStack([stack.id]);
if (unstackedAssets) {
onUnstack?.(unstackedAssets.map((a) => toTimelineAsset(a)));
}
clearSelect();
};
</script>
{#if unstack}
<MenuOption text={$t('unstack')} icon={mdiImageOffOutline} onClick={handleUnstack} />
{:else}
<MenuOption text={$t('stack')} icon={mdiImageMultipleOutline} onClick={handleStack} />
{/if}
@@ -0,0 +1,39 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import { IconButton, modalManager } from '@immich/ui';
import { mdiTagMultipleOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
interface Props {
menuItem?: boolean;
}
let { menuItem = false }: Props = $props();
const text = $t('tag');
const icon = mdiTagMultipleOutline;
const { clearSelect, getOwnedAssets } = getAssetControlContext();
const handleTagAssets = async () => {
const assets = [...getOwnedAssets()];
const success = await modalManager.show(AssetTagModal, { assetIds: assets.map(({ id }) => id) });
if (success) {
clearSelect();
}
};
</script>
<svelte:document use:shortcut={{ shortcut: { key: 't' }, onShortcut: handleTagAssets }} />
{#if menuItem}
<MenuOption {text} {icon} onClick={handleTagAssets} />
{/if}
{#if !menuItem}
<IconButton shape="round" color="secondary" variant="ghost" aria-label={text} {icon} onclick={handleTagAssets} />
{/if}
@@ -0,0 +1,79 @@
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { moveFocus } from '$lib/utils/focus-util';
import { InvocationTracker } from '$lib/utils/invocationTracker';
import { tick } from 'svelte';
const tracker = new InvocationTracker();
const getFocusedThumb = () => {
const current = document.activeElement as HTMLElement | undefined;
if (current && current.dataset.thumbnailFocusContainer !== undefined) {
return current;
}
};
export const focusNextAsset = () =>
moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'next');
export const focusPreviousAsset = () =>
moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'previous');
const queryHTMLElement = (query: string) => document.querySelector(query) as HTMLElement;
export const setFocusToAsset = (scrollToAsset: (asset: TimelineAsset) => boolean, asset: TimelineAsset) => {
const scrolled = scrollToAsset(asset);
if (scrolled) {
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
element?.focus();
}
};
export const setFocusTo = async (
scrollToAsset: (asset: TimelineAsset) => boolean,
store: TimelineManager,
direction: 'earlier' | 'later',
interval: 'day' | 'month' | 'year' | 'asset',
) => {
if (tracker.isActive()) {
// there are unfinished running invocations, so return early
return;
}
const thumb = getFocusedThumb();
if (!thumb) {
return direction === 'earlier' ? focusNextAsset() : focusPreviousAsset();
}
const invocation = tracker.startInvocation();
const id = thumb.dataset.asset;
if (!thumb || !id) {
invocation.endInvocation();
return;
}
const asset =
direction === 'earlier'
? await store.getEarlierAsset({ id }, interval)
: await store.getLaterAsset({ id }, interval);
if (!invocation.isStillValid()) {
return;
}
if (!asset) {
invocation.endInvocation();
return;
}
const scrolled = scrollToAsset(asset);
if (scrolled) {
await tick();
if (!invocation.isStillValid()) {
return;
}
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
element?.focus();
}
invocation.endInvocation();
};