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

@ -8,7 +8,7 @@
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { Viewport } from '$lib/stores/assets-store.svelte';
import type { TimelineAsset, Viewport } from '$lib/stores/assets-store.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils';
@ -18,7 +18,8 @@
import { handleError } from '$lib/utils/handle-error';
import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
import { navigate } from '$lib/utils/navigation';
import { type AssetResponseDto } from '@immich/sdk';
import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetVisibility, type AssetResponseDto } from '@immich/sdk';
import { debounce } from 'lodash-es';
import { t } from 'svelte-i18n';
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
@ -26,7 +27,7 @@
import Portal from '../portal/portal.svelte';
interface Props {
assets: AssetResponseDto[];
assets: (TimelineAsset | AssetResponseDto)[];
assetInteraction: AssetInteraction;
disableAssetSelect?: boolean;
showArchiveIcon?: boolean;
@ -34,9 +35,9 @@
onIntersected?: (() => void) | undefined;
showAssetName?: boolean;
isShowDeleteConfirmation?: boolean;
onPrevious?: (() => Promise<AssetResponseDto | undefined>) | undefined;
onNext?: (() => Promise<AssetResponseDto | undefined>) | undefined;
onRandom?: (() => Promise<AssetResponseDto | undefined>) | undefined;
onPrevious?: (() => Promise<{ id: string } | undefined>) | undefined;
onNext?: (() => Promise<{ id: string } | undefined>) | undefined;
onRandom?: (() => Promise<{ id: string } | undefined>) | undefined;
pageHeaderOffset?: number;
slidingWindowOffset?: number;
}
@ -57,7 +58,7 @@
pageHeaderOffset = 0,
}: Props = $props();
let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore;
let { isViewing: isViewerOpen, asset: viewingAsset, setAssetId } = assetViewingStore;
let geometry: CommonJustifiedLayout | undefined = $state();
@ -83,20 +84,27 @@
if (geometry) {
containerHeight = geometry.containerHeight;
containerWidth = geometry.containerWidth;
for (const [i, asset] of assets.entries()) {
const layout = {
asset,
top: geometry.getTop(i),
left: geometry.getLeft(i),
width: geometry.getWidth(i),
height: geometry.getHeight(i),
};
// 54 is the content height of the asset-selection-app-bar
const layoutTopWithOffset = layout.top + pageHeaderOffset;
const layoutBottom = layoutTopWithOffset + layout.height;
for (const [index, asset] of assets.entries()) {
const top = geometry.getTop(index);
const left = geometry.getLeft(index);
const width = geometry.getWidth(index);
const height = geometry.getHeight(index);
const layoutTopWithOffset = top + pageHeaderOffset;
const layoutBottom = layoutTopWithOffset + height;
const display = layoutTopWithOffset < slidingWindow.bottom && layoutBottom > slidingWindow.top;
assetLayout.push({ ...layout, display });
const layout = {
asset,
top,
left,
width,
height,
display,
};
assetLayout.push(layout);
}
}
@ -109,7 +117,7 @@
let currentViewAssetIndex = 0;
let shiftKeyIsDown = $state(false);
let lastAssetMouseEvent: AssetResponseDto | null = $state(null);
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
let slidingWindow = $state({ top: 0, bottom: 0 });
const updateSlidingWindow = () => {
@ -139,14 +147,14 @@
}
}
});
const viewAssetHandler = async (asset: AssetResponseDto) => {
const viewAssetHandler = async (asset: TimelineAsset) => {
currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id);
setAsset(assets[currentViewAssetIndex]);
await setAssetId(assets[currentViewAssetIndex].id);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
};
const selectAllAssets = () => {
assetInteraction.selectAssets(assets);
assetInteraction.selectAssets(assets.map((a) => toTimelineAsset(a)));
};
const deselectAllAssets = () => {
@ -168,7 +176,7 @@
}
};
const handleSelectAssets = (asset: AssetResponseDto) => {
const handleSelectAssets = (asset: TimelineAsset) => {
if (!asset) {
return;
}
@ -191,14 +199,14 @@
assetInteraction.setAssetSelectionStart(deselect ? null : asset);
};
const handleSelectAssetCandidates = (asset: AssetResponseDto | null) => {
const handleSelectAssetCandidates = (asset: TimelineAsset | null) => {
if (asset) {
selectAssetCandidates(asset);
}
lastAssetMouseEvent = asset;
};
const selectAssetCandidates = (endAsset: AssetResponseDto) => {
const selectAssetCandidates = (endAsset: TimelineAsset) => {
if (!shiftKeyIsDown) {
return;
}
@ -215,12 +223,12 @@
[start, end] = [end, start];
}
assetInteraction.setAssetSelectionCandidates(assets.slice(start, end + 1));
assetInteraction.setAssetSelectionCandidates(assets.slice(start, end + 1).map((a) => toTimelineAsset(a)));
};
const onSelectStart = (e: Event) => {
const onSelectStart = (event: Event) => {
if (assetInteraction.selectionActive && shiftKeyIsDown) {
e.preventDefault();
event.preventDefault();
}
};
@ -253,7 +261,10 @@
};
const toggleArchive = async () => {
const ids = await archiveAssets(assetInteraction.selectedAssets, !assetInteraction.isAllArchived);
const ids = await archiveAssets(
assetInteraction.selectedAssets,
assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive,
);
if (ids) {
assets = assets.filter((asset) => !ids.includes(asset.id));
deselectAllAssets();
@ -275,7 +286,7 @@
isShortcutModalOpen = false;
};
let shortcutList = $derived(
const shortcutList = $derived(
(() => {
if ($isViewerOpen) {
return [];
@ -305,7 +316,7 @@
const handleNext = async (): Promise<boolean> => {
try {
let asset: AssetResponseDto | undefined;
let asset: { id: string } | undefined;
if (onNext) {
asset = await onNext();
} else {
@ -329,9 +340,9 @@
}
};
const handleRandom = async (): Promise<AssetResponseDto | undefined> => {
const handleRandom = async (): Promise<{ id: string } | undefined> => {
try {
let asset: AssetResponseDto | undefined;
let asset: { id: string } | undefined;
if (onRandom) {
asset = await onRandom();
} else {
@ -355,7 +366,7 @@
const handlePrevious = async (): Promise<boolean> => {
try {
let asset: AssetResponseDto | undefined;
let asset: { id: string } | undefined;
if (onPrevious) {
asset = await onPrevious();
} else {
@ -379,9 +390,9 @@
}
};
const navigateToAsset = async (asset?: AssetResponseDto) => {
const navigateToAsset = async (asset?: { id: string }) => {
if (asset && asset.id !== $viewingAsset.id) {
setAsset(asset);
await setAssetId(asset.id);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
}
};
@ -392,7 +403,7 @@
case AssetAction.DELETE:
case AssetAction.TRASH: {
assets.splice(
assets.findIndex((a) => a.id === action.asset.id),
assets.findIndex((currentAsset) => currentAsset.id === action.asset.id),
1,
);
if (assets.length === 0) {
@ -400,21 +411,21 @@
} else if (currentViewAssetIndex === assets.length) {
await handlePrevious();
} else {
setAsset(assets[currentViewAssetIndex]);
await setAssetId(assets[currentViewAssetIndex].id);
}
break;
}
}
};
const assetMouseEventHandler = (asset: AssetResponseDto | null) => {
const assetMouseEventHandler = (asset: TimelineAsset | null) => {
if (assetInteraction.selectionActive) {
handleSelectAssetCandidates(asset);
}
};
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map((selectedAsset) => selectedAsset.id));
$effect(() => {
if (!lastAssetMouseEvent) {
@ -457,39 +468,38 @@
style:height={assetLayouts.containerHeight + 'px'}
style:width={assetLayouts.containerWidth - 1 + 'px'}
>
{#each assetLayouts.assetLayout as layout, index (layout.asset.id + '-' + index)}
{@const asset = layout.asset}
{#each assetLayouts.assetLayout as layout, layoutIndex (layout.asset.id + '-' + layoutIndex)}
{@const currentAsset = layout.asset}
{#if layout.display}
<div
class="absolute"
style:overflow="clip"
style="width: {layout.width}px; height: {layout.height}px; top: {layout.top}px; left: {layout.left}px"
title={showAssetName ? asset.originalFileName : ''}
>
<Thumbnail
readonly={disableAssetSelect}
onClick={(asset) => {
onClick={() => {
if (assetInteraction.selectionActive) {
handleSelectAssets(asset);
handleSelectAssets(toTimelineAsset(currentAsset));
return;
}
void viewAssetHandler(asset);
void viewAssetHandler(toTimelineAsset(currentAsset));
}}
onSelect={(asset) => handleSelectAssets(asset)}
onMouseEvent={() => assetMouseEventHandler(asset)}
onSelect={() => handleSelectAssets(toTimelineAsset(currentAsset))}
onMouseEvent={() => assetMouseEventHandler(toTimelineAsset(currentAsset))}
{showArchiveIcon}
{asset}
selected={assetInteraction.hasSelectedAsset(asset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
asset={toTimelineAsset(currentAsset)}
selected={assetInteraction.hasSelectedAsset(currentAsset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(currentAsset.id)}
thumbnailWidth={layout.width}
thumbnailHeight={layout.height}
/>
{#if showAssetName}
{#if showAssetName && !isTimelineAsset(currentAsset)}
<div
class="absolute text-center p-1 text-xs font-mono font-semibold w-full bottom-0 bg-gradient-to-t bg-slate-50/75 overflow-clip text-ellipsis whitespace-pre-wrap"
>
{asset.originalFileName}
{currentAsset.originalFileName}
</div>
{/if}
</div>