Files
immich/web/src/lib/components/timeline/Month.svelte
T
midzelis e79a98fa82 feat(web): view transitions from timeline to viewer
Change-Id: I0a37b417ee4c247dcc93d442c976eede6a6a6964
2026-03-24 15:43:06 +00:00

140 lines
5.3 KiB
Svelte

<script lang="ts">
import { focusAsset } from '$lib/components/timeline/actions/focus-actions';
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetsSnapshot, filterIntersecting } from '$lib/managers/timeline-manager/utils.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
import type { CommonPosition } from '$lib/utils/layout-utils';
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
import { Icon } from '@immich/ui';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import { onMount, tick, type Snippet } from 'svelte';
type Props = {
toAssetViewerTransitionId?: string | null;
thumbnail: Snippet<[{ asset: TimelineAsset; position: CommonPosition; dayGroup: DayGroup; groupIndex: number }]>;
customThumbnailLayout?: Snippet<[TimelineAsset]>;
singleSelect: boolean;
assetInteraction: AssetInteraction;
monthGroup: MonthGroup;
onDayGroupSelect: (dayGroup: DayGroup, assets: TimelineAsset[]) => void;
};
let {
toAssetViewerTransitionId,
thumbnail: thumbnailWithGroup,
customThumbnailLayout,
singleSelect,
assetInteraction,
monthGroup,
onDayGroupSelect,
}: Props = $props();
let { isUploading } = uploadAssetsStore;
let hoveredDayGroup = $state<string | null>(null);
const transitionDuration = $derived(monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150);
const getDayGroupFullDate = (dayGroup: DayGroup): string => {
const { month, year } = dayGroup.monthGroup.yearMonth;
const date = fromTimelinePlainDate({
year,
month,
day: dayGroup.day,
});
return getDateLocaleString(date);
};
let toTimelineTransitionAssetId = $state<string | null>(null);
let animationTargetAssetId = $derived(toTimelineTransitionAssetId ?? toAssetViewerTransitionId ?? null);
const transitionToTimelineCallback = ({ id }: { id: string }) => {
const asset = monthGroup.findAssetById({ id });
if (!asset) {
return;
}
void viewTransitionManager.startTransition({
types: ['timeline'],
performUpdate: async () => {
eventManager.emit('ViewerCloseTransitionReady');
const event = await eventManager.untilNext('TimelineLoaded');
toTimelineTransitionAssetId = event.id;
await tick();
},
onFinished: () => {
toTimelineTransitionAssetId = null;
focusAsset(asset.id);
},
});
};
if (viewTransitionManager.isSupported()) {
onMount(() => eventManager.on({ ViewerCloseTransition: transitionToTimelineCallback }));
}
</script>
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
{@const isDayGroupSelected = assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<section
class={[
{ 'transition-all': !monthGroup.timelineManager.suspendTransitions },
!monthGroup.timelineManager.suspendTransitions && `delay-${transitionDuration}`,
]}
data-group
style:position="absolute"
style:inset-inline-start={dayGroup.start + 'px'}
style:top={dayGroup.top + 'px'}
onmouseenter={() => (hoveredDayGroup = dayGroup.groupTitle)}
onmouseleave={() => (hoveredDayGroup = null)}
>
<!-- Day title -->
<div
class="flex pt-7 pb-5 max-md:pt-5 max-md:pb-3 h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm"
style:width={dayGroup.width + 'px'}
>
{#if !singleSelect}
<div
class="hover:cursor-pointer transition-all duration-200 ease-out overflow-hidden w-0"
class:w-8={hoveredDayGroup === dayGroup.groupTitle || assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
onclick={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))}
onkeydown={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))}
>
{#if isDayGroupSelected}
<Icon icon={mdiCheckCircle} size="24" class="text-primary" />
{:else}
<Icon icon={mdiCircleOutline} size="24" class="text-light-500" />
{/if}
</div>
{/if}
<span class="w-full truncate first-letter:capitalize" title={getDayGroupFullDate(dayGroup)}>
{dayGroup.groupTitle}
</span>
</div>
<AssetLayout
{animationTargetAssetId}
suspendTransitions={monthGroup.timelineManager.suspendTransitions}
viewerAssets={dayGroup.viewerAssets}
height={dayGroup.height}
width={dayGroup.width}
{customThumbnailLayout}
>
{#snippet thumbnail({ asset, position })}
{@render thumbnailWithGroup({ asset, position, dayGroup, groupIndex })}
{/snippet}
</AssetLayout>
</section>
{/each}
<style>
section {
contain: layout paint style;
}
</style>