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
53 changed files with 725 additions and 471 deletions
@@ -1,11 +1,12 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import type { OnArchive } from '$lib/utils/actions';
import { archiveAssets } from '$lib/utils/asset-utils';
import { AssetVisibility, Visibility } from '@immich/sdk';
import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline, 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';
import { archiveAssets } from '$lib/utils/asset-utils';
import { t } from 'svelte-i18n';
interface Props {
onArchive?: OnArchive;
@@ -23,10 +24,10 @@
const { clearSelect, getOwnedAssets } = getAssetControlContext();
const handleArchive = async () => {
const isArchived = !unarchive;
const assets = [...getOwnedAssets()].filter((asset) => asset.isArchived !== isArchived);
const isArchived = unarchive ? Visibility.Timeline : Visibility.Archive;
const assets = [...getOwnedAssets()].filter((asset) => asset.visibility !== isArchived);
loading = true;
const ids = await archiveAssets(assets, isArchived);
const ids = await archiveAssets(assets, isArchived as unknown as AssetVisibility);
if (ids) {
onArchive?.(ids, isArchived);
clearSelect();
@@ -6,9 +6,9 @@
} from '$lib/components/shared-components/notification/notification';
import { getAssetJobIcon, getAssetJobMessage, getAssetJobName } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { AssetJobName, AssetTypeEnum, runAssetJobs } from '@immich/sdk';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import { AssetJobName, runAssetJobs } from '@immich/sdk';
import { t } from 'svelte-i18n';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
interface Props {
jobs?: AssetJobName[];
@@ -19,7 +19,7 @@
const { clearSelect, getOwnedAssets } = getAssetControlContext();
let isAllVideos = $derived([...getOwnedAssets()].every((asset) => asset.type === AssetTypeEnum.Video));
const isAllVideos = $derived([...getOwnedAssets()].every((asset) => asset.isVideo));
const handleRunJob = async (name: AssetJobName) => {
try {
@@ -4,11 +4,11 @@
import { getSelectedAssets } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { updateAssets } from '@immich/sdk';
import { mdiCalendarEditOutline } from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import { mdiCalendarEditOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
menuItem?: boolean;
}
@@ -1,11 +1,14 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { downloadArchive, downloadFile } from '$lib/utils/asset-utils';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { shortcut } from '$lib/actions/shortcut';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { downloadArchive, downloadFile } from '$lib/utils/asset-utils';
import { getAssetInfo } from '@immich/sdk';
import { mdiCloudDownloadOutline, mdiFileDownloadOutline, mdiFolderDownloadOutline } 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';
interface Props {
filename?: string;
@@ -20,7 +23,8 @@
const assets = [...getAssets()];
if (assets.length === 1) {
clearSelect();
await downloadFile(assets[0]);
let asset = await getAssetInfo({ id: assets[0].id, key: authManager.key });
await downloadFile(asset);
return;
}
@@ -1,12 +1,16 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { getAssetControlContext } from '$lib/components/photos-page/asset-select-control-bar.svelte';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { OnLink, OnUnlink } from '$lib/utils/actions';
import { handleError } from '$lib/utils/handle-error';
import { AssetTypeEnum, getAssetInfo, updateAsset } from '@immich/sdk';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAssetInfo, updateAsset } from '@immich/sdk';
import { mdiLinkOff, 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';
interface Props {
onLink: OnLink;
@@ -28,14 +32,14 @@
const handleLink = async () => {
let [still, motion] = [...getOwnedAssets()];
if (still.type === AssetTypeEnum.Video) {
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: stillResponse, motion });
onLink({ still: toTimelineAsset(stillResponse), motion: motion as TimelineAsset });
clearSelect();
} catch (error) {
handleError(error, $t('errors.unable_to_link_motion_video'));
@@ -46,17 +50,18 @@
const handleUnlink = async () => {
const [still] = [...getOwnedAssets()];
const motionId = still?.livePhotoVideoId;
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({ id: motionId });
onUnlink({ still: stillResponse, motion: motionResponse });
const motionResponse = await getAssetInfo({ id: motionId, key: authManager.key });
onUnlink({ still: toTimelineAsset(stillResponse), motion: toTimelineAsset(motionResponse) });
clearSelect();
} catch (error) {
handleError(error, $t('errors.unable_to_unlink_motion_video'));
@@ -1,9 +1,10 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '$lib/components/photos-page/asset-select-control-bar.svelte';
import { mdiImageMinusOutline, mdiImageMultipleOutline } from '@mdi/js';
import { stackAssets, deleteStack } from '$lib/utils/asset-utils';
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 { mdiImageMinusOutline, mdiImageMultipleOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
@@ -34,7 +35,7 @@
}
const unstackedAssets = await deleteStack([stack.id]);
if (unstackedAssets) {
onUnstack?.(unstackedAssets);
onUnstack?.(unstackedAssets.map((a) => toTimelineAsset(a)));
}
clearSelect();
};
@@ -7,10 +7,11 @@
assetsSnapshot,
type AssetStore,
isSelectingAllAssets,
type TimelineAsset,
} from '$lib/stores/assets-store.svelte';
import { navigate } from '$lib/utils/navigation';
import { getDateLocaleString } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import { fly, scale } from 'svelte/transition';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
@@ -30,9 +31,9 @@
assetStore: AssetStore;
assetInteraction: AssetInteraction;
onSelect: ({ title, assets }: { title: string; assets: AssetResponseDto[] }) => void;
onSelectAssets: (asset: AssetResponseDto) => void;
onSelectAssetCandidates: (asset: AssetResponseDto | null) => void;
onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
onSelectAssets: (asset: TimelineAsset) => void;
onSelectAssetCandidates: (asset: TimelineAsset | null) => void;
}
let {
@@ -53,7 +54,7 @@
const transitionDuration = $derived.by(() => (bucket.store.suspendTransitions && !$isUploading ? 0 : 150));
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const onClick = (assetStore: AssetStore, assets: AssetResponseDto[], groupTitle: string, asset: AssetResponseDto) => {
const onClick = (assetStore: AssetStore, assets: TimelineAsset[], groupTitle: string, asset: TimelineAsset) => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(assetStore, asset, assets, groupTitle);
return;
@@ -61,12 +62,12 @@
void navigate({ targetRoute: 'current', assetId: asset.id });
};
const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => onSelect({ title, assets });
const handleSelectGroup = (title: string, assets: TimelineAsset[]) => onSelect({ title, assets });
const assetSelectHandler = (
assetStore: AssetStore,
asset: AssetResponseDto,
assetsInDateGroup: AssetResponseDto[],
asset: TimelineAsset,
assetsInDateGroup: TimelineAsset[],
groupTitle: string,
) => {
onSelectAssets(asset);
@@ -90,7 +91,7 @@
}
};
const assetMouseEventHandler = (groupTitle: string, asset: AssetResponseDto | null) => {
const assetMouseEventHandler = (groupTitle: string, asset: TimelineAsset | null) => {
// Show multi select icon on hover on date group
hoveredDateGroup = groupTitle;
@@ -8,11 +8,18 @@
import Scrubber from '$lib/components/shared-components/scrubber/scrubber.svelte';
import { AppRoute, AssetAction } from '$lib/constants';
import { albumMapViewManager } from '$lib/managers/album-view-map.manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
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 { AssetBucket, assetsSnapshot, AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import {
AssetBucket,
assetsSnapshot,
AssetStore,
isSelectingAllAssets,
type TimelineAsset,
} from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { searchStore } from '$lib/stores/search.svelte';
@@ -23,7 +30,7 @@
import { focusNext } from '$lib/utils/focus-util';
import { navigate } from '$lib/utils/navigation';
import { type ScrubberListener } from '$lib/utils/timeline-util';
import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk';
import { AssetVisibility, getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { onMount, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
import Portal from '../shared-components/portal/portal.svelte';
@@ -52,7 +59,7 @@
album?: AlbumResponseDto | null;
person?: PersonResponseDto | null;
isShowDeleteConfirmation?: boolean;
onSelect?: (asset: AssetResponseDto) => void;
onSelect?: (asset: TimelineAsset) => void;
onEscape?: () => void;
children?: Snippet;
empty?: Snippet;
@@ -358,7 +365,10 @@
};
const toggleArchive = async () => {
await archiveAssets(assetInteraction.selectedAssets, !assetInteraction.isAllArchived);
await archiveAssets(
assetInteraction.selectedAssets,
assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive,
);
assetStore.updateAssets(assetInteraction.selectedAssets);
deselectAllAssets();
};
@@ -369,7 +379,7 @@
}
};
const handleSelectAsset = (asset: AssetResponseDto) => {
const handleSelectAsset = (asset: TimelineAsset) => {
if (!assetStore.albumAssets.has(asset.id)) {
assetInteraction.selectAsset(asset);
}
@@ -380,7 +390,8 @@
if (previousAsset) {
const preloadAsset = await assetStore.getPreviousAsset(previousAsset);
assetViewingStore.setAsset(previousAsset, preloadAsset ? [preloadAsset] : []);
const asset = await getAssetInfo({ id: previousAsset.id, key: authManager.key });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: previousAsset.id });
}
@@ -391,7 +402,8 @@
const nextAsset = await assetStore.getNextAsset($viewingAsset);
if (nextAsset) {
const preloadAsset = await assetStore.getNextAsset(nextAsset);
assetViewingStore.setAsset(nextAsset, preloadAsset ? [preloadAsset] : []);
const asset = await getAssetInfo({ id: nextAsset.id, key: authManager.key });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: nextAsset.id });
}
@@ -403,14 +415,14 @@
if (randomAsset) {
const preloadAsset = await assetStore.getNextAsset(randomAsset);
assetViewingStore.setAsset(randomAsset, preloadAsset ? [preloadAsset] : []);
const asset = await getAssetInfo({ id: randomAsset.id, key: authManager.key });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
return asset;
}
return randomAsset;
};
const handleClose = async ({ asset }: { asset: AssetResponseDto }) => {
const handleClose = async (asset: { id: string }) => {
assetViewingStore.showAssetViewer(false);
showSkeleton = true;
$gridScrollTarget = { at: asset.id };
@@ -428,7 +440,7 @@
case AssetAction.SET_VISIBILITY_TIMELINE: {
// find the next asset to show or close the viewer
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await handleNext()) || (await handlePrevious()) || (await handleClose({ asset: action.asset }));
(await handleNext()) || (await handlePrevious()) || (await handleClose(action.asset));
// delete after find the next one
assetStore.removeAssets([action.asset.id]);
@@ -458,7 +470,7 @@
}
};
let lastAssetMouseEvent: AssetResponseDto | null = $state(null);
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
let shiftKeyIsDown = $state(false);
@@ -488,14 +500,14 @@
}
};
const handleSelectAssetCandidates = (asset: AssetResponseDto | null) => {
const handleSelectAssetCandidates = (asset: TimelineAsset | null) => {
if (asset) {
selectAssetCandidates(asset);
}
lastAssetMouseEvent = asset;
};
const handleGroupSelect = (assetStore: AssetStore, group: string, assets: AssetResponseDto[]) => {
const handleGroupSelect = (assetStore: AssetStore, group: string, assets: TimelineAsset[]) => {
if (assetInteraction.selectedGroup.has(group)) {
assetInteraction.removeGroupFromMultiselectGroup(group);
for (const asset of assets) {
@@ -515,7 +527,7 @@
}
};
const handleSelectAssets = async (asset: AssetResponseDto) => {
const handleSelectAssets = async (asset: TimelineAsset) => {
if (!asset) {
return;
}
@@ -598,7 +610,7 @@
assetInteraction.setAssetSelectionStart(deselect ? null : asset);
};
const selectAssetCandidates = (endAsset: AssetResponseDto) => {
const selectAssetCandidates = (endAsset: TimelineAsset) => {
if (!shiftKeyIsDown) {
return;
}
@@ -4,8 +4,8 @@
export interface AssetControlContext {
// Wrap assets in a function, because context isn't reactive.
getAssets: () => AssetResponseDto[]; // All assets includes partners' assets
getOwnedAssets: () => AssetResponseDto[]; // Only assets owned by the user
getAssets: () => TimelineAsset[]; // All assets includes partners' assets
getOwnedAssets: () => TimelineAsset[]; // Only assets owned by the user
clearSelect: () => void;
}
@@ -14,13 +14,13 @@
</script>
<script lang="ts">
import type { AssetResponseDto } from '@immich/sdk';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { mdiClose } from '@mdi/js';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
import type { Snippet } from 'svelte';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
interface Props {
assets: AssetResponseDto[];
assets: TimelineAsset[];
clearSelect: () => void;
ownerId?: string | undefined;
children?: Snippet;
@@ -5,6 +5,7 @@
import { memoryStore } from '$lib/stores/memory.store.svelte';
import { getAssetThumbnailUrl, memoryLaneTitle } from '$lib/utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
@@ -82,7 +83,7 @@
<img
class="h-full w-full rounded-xl object-cover"
src={getAssetThumbnailUrl(memory.assets[0].id)}
alt={$t('memory_lane_title', { values: { title: $getAltText(memory.assets[0]) } })}
alt={$t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } })}
draggable="false"
/>
<div