feat(web): lighter timeline buckets (#17719)

* feat(web): lighter timeline buckets

* GalleryViewer

* weird ssr

* Remove generics from AssetInteraction

* ensure keys on getAssetInfo, alt-text

* empty - trigger ci

* re-add alt-text

* test fix

* update tests

* tests

* missing import

* fix: flappy e2e test

* lint

* revert settings

* unneeded cast

* fix after merge

* missing import

* lint

* review

* lint

* avoid abbreviations

* review comment - type safety in test

* merge conflicts

* lint

* lint/abbreviations

* fix: left-over migration

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Min Idzelis 2025-05-17 22:57:08 -04:00 committed by GitHub
parent a65c905621
commit 0bbe70e6a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 725 additions and 471 deletions

View file

@ -1,20 +1,21 @@
import type { AssetAction } from '$lib/constants';
import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import type { AlbumResponseDto } from '@immich/sdk';
type ActionMap = {
[AssetAction.ARCHIVE]: { asset: AssetResponseDto };
[AssetAction.UNARCHIVE]: { asset: AssetResponseDto };
[AssetAction.FAVORITE]: { asset: AssetResponseDto };
[AssetAction.UNFAVORITE]: { asset: AssetResponseDto };
[AssetAction.TRASH]: { asset: AssetResponseDto };
[AssetAction.DELETE]: { asset: AssetResponseDto };
[AssetAction.RESTORE]: { asset: AssetResponseDto };
[AssetAction.ADD]: { asset: AssetResponseDto };
[AssetAction.ADD_TO_ALBUM]: { asset: AssetResponseDto; album: AlbumResponseDto };
[AssetAction.UNSTACK]: { assets: AssetResponseDto[] };
[AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: AssetResponseDto };
[AssetAction.SET_VISIBILITY_LOCKED]: { asset: AssetResponseDto };
[AssetAction.SET_VISIBILITY_TIMELINE]: { asset: AssetResponseDto };
[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 };
[AssetAction.ADD]: { asset: TimelineAsset };
[AssetAction.ADD_TO_ALBUM]: { asset: TimelineAsset; album: AlbumResponseDto };
[AssetAction.UNSTACK]: { assets: TimelineAsset[] };
[AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: TimelineAsset };
[AssetAction.SET_VISIBILITY_LOCKED]: { asset: TimelineAsset };
[AssetAction.SET_VISIBILITY_TIMELINE]: { asset: TimelineAsset };
};
export type Action = {

View file

@ -6,6 +6,7 @@
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import { AssetAction } from '$lib/constants';
import { addAssetsToAlbum, addAssetsToNewAlbum } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk';
import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@ -24,14 +25,14 @@
showSelectionModal = false;
const album = await addAssetsToNewAlbum(albumName, [asset.id]);
if (album) {
onAction({ type: AssetAction.ADD_TO_ALBUM, asset, album });
onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album });
}
};
const handleAddToAlbum = async (album: AlbumResponseDto) => {
showSelectionModal = false;
await addAssetsToAlbum(album.id, [asset.id]);
onAction({ type: AssetAction.ADD_TO_ALBUM, asset, album });
onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album });
};
</script>

View file

@ -4,6 +4,7 @@
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AssetAction } from '$lib/constants';
import { toggleArchive } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk';
import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@ -18,11 +19,11 @@
const onArchive = async () => {
if (!asset.isArchived) {
preAction({ type: AssetAction.ARCHIVE, asset });
preAction({ type: AssetAction.ARCHIVE, asset: toTimelineAsset(asset) });
}
const updatedAsset = await toggleArchive(asset);
if (updatedAsset) {
onAction({ type: asset.isArchived ? AssetAction.ARCHIVE : AssetAction.UNARCHIVE, asset });
onAction({ type: asset.isArchived ? AssetAction.ARCHIVE : AssetAction.UNARCHIVE, asset: toTimelineAsset(asset) });
}
};
</script>

View file

@ -11,6 +11,7 @@
import { showDeleteModal } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { deleteAssets, type AssetResponseDto } from '@immich/sdk';
import { mdiDeleteForeverOutline, mdiDeleteOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@ -42,9 +43,9 @@
const trashAsset = async () => {
try {
preAction({ type: AssetAction.TRASH, asset });
preAction({ type: AssetAction.TRASH, asset: toTimelineAsset(asset) });
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id] } });
onAction({ type: AssetAction.TRASH, asset });
onAction({ type: AssetAction.TRASH, asset: toTimelineAsset(asset) });
notificationController.show({
message: $t('moved_to_trash'),
@ -58,7 +59,7 @@
const deleteAsset = async () => {
try {
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force: true } });
onAction({ type: AssetAction.DELETE, asset });
onAction({ type: AssetAction.DELETE, asset: toTimelineAsset(asset) });
notificationController.show({
message: $t('permanently_deleted_asset'),

View file

@ -2,19 +2,21 @@
import { shortcut } from '$lib/actions/shortcut';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { downloadFile } from '$lib/utils/asset-utils';
import type { AssetResponseDto } from '@immich/sdk';
import { getAssetInfo } from '@immich/sdk';
import { mdiFolderDownloadOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
asset: AssetResponseDto;
asset: TimelineAsset;
menuItem?: boolean;
}
let { asset, menuItem = false }: Props = $props();
const onDownloadFile = () => downloadFile(asset);
const onDownloadFile = async () => downloadFile(await getAssetInfo({ id: asset.id, key: authManager.key }));
</script>
<svelte:window use:shortcut={{ shortcut: { key: 'd', shift: true }, onShortcut: onDownloadFile }} />

View file

@ -7,6 +7,7 @@
} from '$lib/components/shared-components/notification/notification';
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 { mdiHeart, mdiHeartOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@ -30,7 +31,10 @@
asset = { ...asset, isFavorite: data.isFavorite };
onAction({ type: asset.isFavorite ? AssetAction.FAVORITE : AssetAction.UNFAVORITE, asset });
onAction({
type: asset.isFavorite ? AssetAction.FAVORITE : AssetAction.UNFAVORITE,
asset: toTimelineAsset(asset),
});
notificationController.show({
type: NotificationType.Info,

View file

@ -3,6 +3,7 @@
import { AssetAction } from '$lib/constants';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { keepThisDeleteOthers } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto, StackResponseDto } from '@immich/sdk';
import { mdiPinOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@ -29,7 +30,7 @@
const keptAsset = await keepThisDeleteOthers(asset, stack);
if (keptAsset) {
onAction({ type: AssetAction.UNSTACK, assets: [keptAsset] });
onAction({ type: AssetAction.UNSTACK, assets: [toTimelineAsset(keptAsset)] });
}
};
</script>

View file

@ -6,6 +6,7 @@
} from '$lib/components/shared-components/notification/notification';
import { AssetAction } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { restoreAssets, type AssetResponseDto } from '@immich/sdk';
import { mdiHistory } from '@mdi/js';
import { t } from 'svelte-i18n';
@ -23,7 +24,7 @@
await restoreAssets({ bulkIdsDto: { ids: [asset.id] } });
asset.isTrashed = false;
onAction({ type: AssetAction.RESTORE, asset });
onAction({ type: AssetAction.RESTORE, asset: toTimelineAsset(asset) });
notificationController.show({
type: NotificationType.Info,

View file

@ -3,14 +3,15 @@
import { AssetAction } from '$lib/constants';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { handleError } from '$lib/utils/handle-error';
import { AssetVisibility, updateAssets, Visibility, type AssetResponseDto } from '@immich/sdk';
import { AssetVisibility, updateAssets, Visibility } from '@immich/sdk';
import { mdiEyeOffOutline, mdiFolderMoveOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction, PreAction } from './action';
interface Props {
asset: AssetResponseDto;
asset: TimelineAsset;
onAction: OnAction;
preAction: PreAction;
}

View file

@ -2,6 +2,7 @@
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AssetAction } from '$lib/constants';
import { deleteStack } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { StackResponseDto } from '@immich/sdk';
import { mdiImageMinusOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@ -17,7 +18,7 @@
const handleUnstack = async () => {
const unstackedAssets = await deleteStack([stack.id]);
if (unstackedAssets) {
onAction({ type: AssetAction.UNSTACK, assets: unstackedAssets });
onAction({ type: AssetAction.UNSTACK, assets: unstackedAssets.map((asset) => toTimelineAsset(asset)) });
}
};
</script>

View file

@ -13,6 +13,7 @@ describe('AssetViewerNavBar component', () => {
showDownloadButton: false,
showMotionPlayButton: false,
showShareButton: false,
preAction: () => {},
onZoomImage: () => {},
onCopyImage: () => {},
onAction: () => {},

View file

@ -25,6 +25,7 @@
import { getAssetJobName, getSharedLink } from '$lib/utils';
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetJobName,
AssetTypeEnum,
@ -138,7 +139,7 @@
{/if}
{#if !isOwner && showDownloadButton}
<DownloadAction {asset} />
<DownloadAction asset={toTimelineAsset(asset)} />
{/if}
{#if showDetailButton}
@ -166,7 +167,7 @@
<MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
{/if}
{#if showDownloadButton}
<DownloadAction {asset} menuItem />
<DownloadAction asset={toTimelineAsset(asset)} menuItem />
{/if}
{#if !isLocked}
@ -210,7 +211,7 @@
{/if}
{#if !asset.isTrashed}
<SetVisibilityAction {asset} {onAction} {preAction} />
<SetVisibilityAction asset={toTimelineAsset(asset)} {onAction} {preAction} />
{/if}
<hr />
<MenuOption

View file

@ -10,6 +10,7 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { isShowDetail } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { user } from '$lib/stores/user.store';
@ -17,6 +18,7 @@
import { getAssetJobMessage, getSharedLink, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { SlideshowHistory } from '$lib/utils/slideshow-history';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetJobName,
AssetTypeEnum,
@ -47,7 +49,7 @@
interface Props {
asset: AssetResponseDto;
preloadAssets?: AssetResponseDto[];
preloadAssets?: TimelineAsset[];
showNavigation?: boolean;
withStacked?: boolean;
isShared?: boolean;
@ -56,10 +58,10 @@
preAction?: PreAction | undefined;
onAction?: OnAction | undefined;
showCloseButton?: boolean;
onClose: (dto: { asset: AssetResponseDto }) => void;
onClose: (asset: AssetResponseDto) => void;
onNext: () => Promise<HasAsset>;
onPrevious: () => Promise<HasAsset>;
onRandom: () => Promise<AssetResponseDto | undefined>;
onRandom: () => Promise<{ id: string } | undefined>;
copyImage?: () => Promise<void>;
}
@ -81,7 +83,7 @@
copyImage = $bindable(),
}: Props = $props();
const { setAsset } = assetViewingStore;
const { setAssetId } = assetViewingStore;
const {
restartProgress: restartSlideshowProgress,
stopProgress: stopSlideshowProgress,
@ -121,7 +123,7 @@
untrack(() => {
if (stack && stack?.assets.length > 1) {
preloadAssets.push(stack.assets[1]);
preloadAssets.push(toTimelineAsset(stack.assets[1]));
}
});
};
@ -161,7 +163,7 @@
slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
if (value === SlideshowState.PlaySlideshow) {
slideshowHistory.reset();
slideshowHistory.queue(asset);
slideshowHistory.queue(toTimelineAsset(asset));
handlePromiseError(handlePlaySlideshow());
} else if (value === SlideshowState.StopSlideshow) {
handlePromiseError(handleStopSlideshow());
@ -171,7 +173,7 @@
shuffleSlideshowUnsubscribe = slideshowNavigation.subscribe((value) => {
if (value === SlideshowNavigation.Shuffle) {
slideshowHistory.reset();
slideshowHistory.queue(asset);
slideshowHistory.queue(toTimelineAsset(asset));
}
});
@ -225,7 +227,7 @@
};
const closeViewer = () => {
onClose({ asset });
onClose(asset);
};
const closeEditor = () => {
@ -292,8 +294,7 @@
let assetViewerHtmlElement = $state<HTMLElement>();
const slideshowHistory = new SlideshowHistory((asset) => {
setAsset(asset);
$restartSlideshowProgress = true;
handlePromiseError(setAssetId(asset.id).then(() => ($restartSlideshowProgress = true)));
});
const handleVideoStarted = () => {
@ -563,8 +564,8 @@
imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }}
brokenAssetClass="text-xs"
dimmed={stackedAsset.id !== asset.id}
asset={stackedAsset}
onClick={(stackedAsset) => {
asset={toTimelineAsset(stackedAsset)}
onClick={() => {
asset = stackedAsset;
}}
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}

View file

@ -12,6 +12,7 @@
resetGlobalCropStore,
rotateDegrees,
} from '$lib/stores/asset-editor.store';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk';
import { animateCropChange, recalculateCrop } from './crop-settings';
import { cropAreaEl, cropFrame, imgElement, isResizingOrDragging, overlayEl, resetCropStore } from './crop-store';
@ -81,7 +82,7 @@
aria-label="Crop area"
type="button"
>
<img draggable="false" src={img?.src} alt={$getAltText(asset)} />
<img draggable="false" src={img?.src} alt={$getAltText(toTimelineAsset(asset))} />
<div class={`${$isResizingOrDragging ? 'resizing' : ''} crop-frame`} bind:this={$cropFrame}>
<div class="grid"></div>
<div class="corner top-left"></div>

View file

@ -3,7 +3,7 @@
import { zoomImageAction, zoomed } from '$lib/actions/zoom-image';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { photoViewerImgElement, type TimelineAsset } from '$lib/stores/assets-store.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
@ -13,9 +13,10 @@
import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { getBoundingBox } from '$lib/utils/people-utils';
import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { getAltText } from '$lib/utils/thumbnail-util';
import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { onDestroy, onMount } from 'svelte';
import { swipe, type SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n';
@ -25,7 +26,7 @@
interface Props {
asset: AssetResponseDto;
preloadAssets?: AssetResponseDto[] | undefined;
preloadAssets?: TimelineAsset[] | undefined;
element?: HTMLDivElement | undefined;
haveFadeTransition?: boolean;
sharedLink?: SharedLinkResponseDto | undefined;
@ -69,10 +70,11 @@
$boundingBoxesArray = [];
});
const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: AssetResponseDto[]) => {
const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: TimelineAsset[]) => {
for (const preloadAsset of preloadAssets || []) {
if (preloadAsset.type === AssetTypeEnum.Image) {
preloadImageUrl(getAssetUrl(preloadAsset.id, targetSize, preloadAsset.thumbhash));
if (preloadAsset.isImage) {
let img = new Image();
img.src = getAssetUrl(preloadAsset.id, targetSize, preloadAsset.thumbhash);
}
}
};
@ -197,7 +199,7 @@
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
>
<img style="display:none" src={imageLoaderUrl} alt={$getAltText(asset)} {onload} {onerror} />
<img style="display:none" src={imageLoaderUrl} alt="" {onload} {onerror} />
{#if !imageLoaded}
<div id="spinner" class="flex h-full items-center justify-center">
<LoadingSpinner />
@ -213,7 +215,7 @@
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
<img
src={assetFileUrl}
alt={$getAltText(asset)}
alt=""
class="absolute top-0 start-0 object-cover h-full w-full blur-lg"
draggable="false"
/>
@ -221,7 +223,7 @@
<img
bind:this={$photoViewerImgElement}
src={assetFileUrl}
alt={$getAltText(asset)}
alt={$getAltText(toTimelineAsset(asset))}
class="h-full w-full {$slideshowState === SlideshowState.None
? 'object-contain'
: slideshowLookCssMapping[$slideshowLook]}"