mirror of
https://github.com/immich-app/immich.git
synced 2026-03-27 12:20:52 +03:00
feat(web): hero view transitions for memory viewer
Change-Id: I6221557a6b8561122baccbc651a48ae46a6a6964
This commit is contained in:
271
web/src/app.css
271
web/src/app.css
@@ -328,8 +328,189 @@
|
||||
}
|
||||
::view-transition-new(hero) {
|
||||
animation: none;
|
||||
align-content: center;
|
||||
}
|
||||
::view-transition-old(memory-overlay),
|
||||
::view-transition-old(memory-controls),
|
||||
::view-transition-new(memory-overlay),
|
||||
::view-transition-new(memory-controls) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: none;
|
||||
object-position: left top;
|
||||
}
|
||||
|
||||
html:active-view-transition-type(memory) {
|
||||
&::view-transition-group(hero),
|
||||
&::view-transition-group(hero-out) {
|
||||
animation-duration: var(--vt-duration-memory);
|
||||
animation-timing-function: var(--vt-memory-easing);
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
}
|
||||
&::view-transition-group(memory-overlay),
|
||||
&::view-transition-group(memory-controls) {
|
||||
animation: none;
|
||||
z-index: 5;
|
||||
}
|
||||
&::view-transition-group(memory-overlay-prev),
|
||||
&::view-transition-group(memory-overlay-next) {
|
||||
animation: none;
|
||||
z-index: 2;
|
||||
opacity: 0.25;
|
||||
}
|
||||
&::view-transition-image-pair(memory-overlay),
|
||||
&::view-transition-image-pair(memory-controls) {
|
||||
isolation: auto;
|
||||
}
|
||||
&::view-transition-old(memory-overlay),
|
||||
&::view-transition-old(memory-controls) {
|
||||
animation: 120ms linear fadeOut forwards;
|
||||
}
|
||||
&::view-transition-new(memory-overlay),
|
||||
&::view-transition-new(memory-controls) {
|
||||
animation: 200ms linear calc(var(--vt-duration-memory) - 200ms) fadeIn forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
&::view-transition-old(memory-overlay-prev),
|
||||
&::view-transition-old(memory-overlay-next) {
|
||||
display: none;
|
||||
}
|
||||
&::view-transition-new(memory-overlay-prev),
|
||||
&::view-transition-new(memory-overlay-next) {
|
||||
animation: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: none;
|
||||
object-position: left top;
|
||||
}
|
||||
&::view-transition-image-pair(hero) {
|
||||
isolation: auto;
|
||||
}
|
||||
&::view-transition-old(hero) {
|
||||
display: none;
|
||||
}
|
||||
&::view-transition-new(hero) {
|
||||
animation: none;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
&::view-transition-image-pair(hero-out) {
|
||||
isolation: auto;
|
||||
}
|
||||
&::view-transition-old(hero-out) {
|
||||
display: none;
|
||||
}
|
||||
&::view-transition-new(hero-out) {
|
||||
animation: var(--vt-duration-memory) var(--vt-memory-easing) dimDown forwards;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
&::view-transition-group(memory-departing) {
|
||||
animation: none;
|
||||
}
|
||||
&::view-transition-old(memory-departing) {
|
||||
animation: calc(var(--vt-duration-memory) * 0.4) linear fadeFromDim forwards;
|
||||
}
|
||||
&::view-transition-new(memory-departing) {
|
||||
animation: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
html:active-view-transition-type(memory-enter) {
|
||||
&::view-transition-group(hero) {
|
||||
animation-duration: var(--vt-duration-hero);
|
||||
animation-timing-function: var(--vt-memory-easing);
|
||||
overflow: hidden;
|
||||
}
|
||||
&::view-transition-old(hero),
|
||||
&::view-transition-new(hero) {
|
||||
animation: none;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
&::view-transition-group(memory-overlay),
|
||||
&::view-transition-group(memory-controls),
|
||||
&::view-transition-group(memory-nav-buttons) {
|
||||
animation: none;
|
||||
z-index: 5;
|
||||
}
|
||||
&::view-transition-old(memory-overlay),
|
||||
&::view-transition-old(memory-controls),
|
||||
&::view-transition-old(memory-nav-buttons) {
|
||||
animation: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
&::view-transition-new(memory-overlay),
|
||||
&::view-transition-new(memory-controls),
|
||||
&::view-transition-new(memory-nav-buttons) {
|
||||
animation: 200ms linear var(--vt-duration-hero) fadeIn forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
::view-transition-old(memory-fade-out) {
|
||||
animation: 500ms linear crossfadeOut forwards;
|
||||
}
|
||||
::view-transition-new(memory-fade-in) {
|
||||
animation: 500ms linear crossfadeIn forwards;
|
||||
}
|
||||
|
||||
html:active-view-transition-type(memory-nav-fast) {
|
||||
&::view-transition-old(memory-fade-out) {
|
||||
animation-duration: 250ms;
|
||||
}
|
||||
&::view-transition-new(memory-fade-in) {
|
||||
animation-duration: 250ms;
|
||||
}
|
||||
&::view-transition-old(memory-overlay),
|
||||
&::view-transition-old(memory-controls) {
|
||||
animation-duration: 100ms;
|
||||
}
|
||||
&::view-transition-new(memory-overlay),
|
||||
&::view-transition-new(memory-controls) {
|
||||
animation: 100ms linear 150ms fadeIn forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
html:active-view-transition-type(memory-nav) {
|
||||
&::view-transition-group(memory-overlay),
|
||||
&::view-transition-group(memory-controls) {
|
||||
animation: none;
|
||||
z-index: 5;
|
||||
}
|
||||
&::view-transition-image-pair(memory-overlay),
|
||||
&::view-transition-image-pair(memory-controls) {
|
||||
isolation: auto;
|
||||
}
|
||||
&::view-transition-old(memory-overlay),
|
||||
&::view-transition-old(memory-controls) {
|
||||
animation: 150ms linear fadeOut forwards;
|
||||
}
|
||||
&::view-transition-new(memory-overlay),
|
||||
&::view-transition-new(memory-controls) {
|
||||
animation: 200ms linear 300ms fadeIn forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
&::view-transition-group(memory-overlay-prev),
|
||||
&::view-transition-group(memory-overlay-next) {
|
||||
animation: none;
|
||||
opacity: 0.25;
|
||||
}
|
||||
&::view-transition-old(memory-overlay-prev),
|
||||
&::view-transition-old(memory-overlay-next) {
|
||||
display: none;
|
||||
}
|
||||
&::view-transition-new(memory-overlay-prev),
|
||||
&::view-transition-new(memory-overlay-next) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
::view-transition-old(next),
|
||||
::view-transition-old(next-old),
|
||||
::view-transition-new(next),
|
||||
@@ -381,6 +562,24 @@
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@keyframes fadeFromDim {
|
||||
from {
|
||||
opacity: 0.25;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dimDown {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flyInLeft {
|
||||
from {
|
||||
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
|
||||
@@ -539,36 +738,50 @@
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
::view-transition-group(previous),
|
||||
::view-transition-group(previous-old),
|
||||
::view-transition-group(next),
|
||||
::view-transition-group(next-old) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
transform: none !important;
|
||||
html:active-view-transition-type(viewer-nav) {
|
||||
&::view-transition-group(previous),
|
||||
&::view-transition-group(previous-old),
|
||||
&::view-transition-group(next),
|
||||
&::view-transition-group(next-old) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
&::view-transition-old(previous),
|
||||
&::view-transition-old(previous-old),
|
||||
&::view-transition-old(next),
|
||||
&::view-transition-old(next-old) {
|
||||
animation: var(--vt-duration-viewer-navigation) fadeOut forwards;
|
||||
transform-origin: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&::view-transition-new(previous),
|
||||
&::view-transition-new(previous-new),
|
||||
&::view-transition-new(next),
|
||||
&::view-transition-new(next-new) {
|
||||
animation: var(--vt-duration-viewer-navigation) fadeIn forwards;
|
||||
transform-origin: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
::view-transition-old(previous),
|
||||
::view-transition-old(previous-old),
|
||||
::view-transition-old(next),
|
||||
::view-transition-old(next-old) {
|
||||
animation: var(--vt-duration-viewer-navigation) fadeOut forwards;
|
||||
transform-origin: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
::view-transition-new(previous),
|
||||
::view-transition-new(previous-new),
|
||||
::view-transition-new(next),
|
||||
::view-transition-new(next-new) {
|
||||
animation: var(--vt-duration-viewer-navigation) fadeIn forwards;
|
||||
transform-origin: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
html:active-view-transition-type(memory-enter) {
|
||||
&::view-transition-group(hero) {
|
||||
animation-duration: 0s;
|
||||
}
|
||||
&::view-transition-old(hero) {
|
||||
animation: var(--vt-duration-default) fadeOut forwards;
|
||||
}
|
||||
&::view-transition-new(hero) {
|
||||
animation: var(--vt-duration-default) fadeIn forwards;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { assetViewerFadeDuration } from '$lib/constants';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { AssetMediaSize } from '@immich/sdk';
|
||||
import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
|
||||
import type { Size } from '$lib/utils/container-utils';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
asset: TimelineAsset;
|
||||
asset: AssetResponseDto;
|
||||
transitionName?: string;
|
||||
onImageLoad: () => void;
|
||||
onError?: () => void;
|
||||
}
|
||||
|
||||
const { asset, onImageLoad }: Props = $props();
|
||||
const { asset, transitionName, onImageLoad, onError }: Props = $props();
|
||||
|
||||
let assetFileUrl: string = $state('');
|
||||
let imageLoaded: boolean = $state(false);
|
||||
let loader = $state<HTMLImageElement>();
|
||||
let containerWidth = $state(0);
|
||||
let containerHeight = $state(0);
|
||||
|
||||
const onLoadCallback = () => {
|
||||
imageLoaded = true;
|
||||
assetFileUrl = imageLoaderUrl;
|
||||
onImageLoad();
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
if (loader?.complete) {
|
||||
onLoadCallback();
|
||||
}
|
||||
loader?.addEventListener('load', onLoadCallback);
|
||||
return () => {
|
||||
loader?.removeEventListener('load', onLoadCallback);
|
||||
};
|
||||
});
|
||||
|
||||
const imageLoaderUrl = $derived(getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview }));
|
||||
const container: Size = $derived({ width: containerWidth, height: containerHeight });
|
||||
</script>
|
||||
|
||||
{#if !imageLoaded}
|
||||
<!-- svelte-ignore a11y_missing_attribute -->
|
||||
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
|
||||
{/if}
|
||||
|
||||
{#if !imageLoaded}
|
||||
<DelayedLoadingSpinner />
|
||||
{:else if imageLoaded}
|
||||
<div transition:fade={{ duration: assetViewerFadeDuration }} class="h-full w-full">
|
||||
<img
|
||||
class="h-full w-full rounded-2xl object-contain transition-all"
|
||||
src={assetFileUrl}
|
||||
alt={$getAltText(asset)}
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="relative h-full w-full overflow-hidden"
|
||||
bind:clientWidth={containerWidth}
|
||||
bind:clientHeight={containerHeight}
|
||||
>
|
||||
{#if containerWidth > 0 && containerHeight > 0}
|
||||
<AdaptiveImage {asset} {container} {transitionName} showLetterboxes={false} onImageReady={onImageLoad} {onError} />
|
||||
{:else}
|
||||
<DelayedLoadingSpinner />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -20,11 +20,14 @@
|
||||
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import { QueryParameter } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { getAssetBulkActions } from '$lib/services/asset.service';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
|
||||
import { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte';
|
||||
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
@@ -52,6 +55,7 @@
|
||||
} from '@mdi/js';
|
||||
import type { NavigationTarget, Page } from '@sveltejs/kit';
|
||||
import { DateTime } from 'luxon';
|
||||
import { tick } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { Attachment } from 'svelte/attachments';
|
||||
import { Tween } from 'svelte/motion';
|
||||
@@ -64,6 +68,7 @@
|
||||
let paused = $state(false);
|
||||
let current = $state<MemoryAsset | undefined>(undefined);
|
||||
const currentAssetId = $derived(current?.asset.id);
|
||||
const currentAssetDto = $derived(current ? current.memory.assets[current.assetIndex] : undefined);
|
||||
const currentMemoryAssetFull = $derived.by(async () =>
|
||||
currentAssetId ? await getAssetInfo({ ...authManager.params, id: currentAssetId }) : undefined,
|
||||
);
|
||||
@@ -76,6 +81,14 @@
|
||||
|
||||
let isSaved = $derived(current?.memory.isSaved);
|
||||
let viewerHeight = $state(0);
|
||||
let transition = $state({
|
||||
name: undefined as string | undefined,
|
||||
previousPanel: undefined as string | undefined,
|
||||
nextPanel: undefined as string | undefined,
|
||||
active: false,
|
||||
});
|
||||
const showTransitionOverlays = $derived(transition.active || transition.name === 'hero');
|
||||
const showNavButtonOverlay = $derived(transition.name === 'hero');
|
||||
|
||||
const { isViewing } = assetViewingStore;
|
||||
const viewport: Viewport = $state({ width: 0, height: 0 });
|
||||
@@ -86,18 +99,6 @@
|
||||
let videoPlayer: HTMLVideoElement | undefined = $state();
|
||||
const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
|
||||
|
||||
const handleNavigate = async (asset?: { id: string }) => {
|
||||
if ($isViewing) {
|
||||
return asset;
|
||||
}
|
||||
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
await goto(asHref(asset));
|
||||
};
|
||||
|
||||
const setProgressDuration = (asset: TimelineAsset) => {
|
||||
if (asset.isVideo) {
|
||||
const timeParts = asset.duration!.split(':').map(Number);
|
||||
@@ -112,11 +113,177 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextAsset = () => handleNavigate(current?.next?.asset);
|
||||
const handlePreviousAsset = () => handleNavigate(current?.previous?.asset);
|
||||
const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]);
|
||||
const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]);
|
||||
const handleEscape = async () => goto(Route.photos());
|
||||
const scrollToTop = () => {
|
||||
if (window.scrollY === 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
return new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(resolve, 500);
|
||||
window.addEventListener(
|
||||
'scrollend',
|
||||
() => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const withMemoryTransition = async (
|
||||
asset: { id: string } | undefined,
|
||||
config: Omit<Parameters<typeof viewTransitionManager.startTransition>[0], 'onFinished'> & {
|
||||
onFinished?: () => void;
|
||||
},
|
||||
) => {
|
||||
if ($isViewing || !asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
await scrollToTop();
|
||||
|
||||
transition.active = true;
|
||||
viewTransitionManager
|
||||
.startTransition({
|
||||
...config,
|
||||
onFinished: () => {
|
||||
transition.previousPanel = undefined;
|
||||
transition.nextPanel = undefined;
|
||||
transition.name = undefined;
|
||||
transition.active = false;
|
||||
config.onFinished?.();
|
||||
},
|
||||
})
|
||||
.catch((error: unknown) => console.error('[Memory] transition failed:', error));
|
||||
};
|
||||
|
||||
const navigateWithTransition = (asset?: { id: string }) =>
|
||||
withMemoryTransition(asset, {
|
||||
types: ['memory-nav'],
|
||||
prepareOldSnapshot: () => {
|
||||
transition.name = 'memory-fade-out';
|
||||
},
|
||||
performUpdate: async () => {
|
||||
await goto(asHref(asset!));
|
||||
await eventManager.untilNext('ViewerOpenTransitionReady');
|
||||
},
|
||||
prepareNewSnapshot: () => {
|
||||
transition.name = 'memory-fade-in';
|
||||
},
|
||||
});
|
||||
|
||||
const handleNextAsset = () => {
|
||||
const next = current?.next;
|
||||
if (next && next.memory.id !== current?.memory.id) {
|
||||
void navigateToMemory('next', next.asset);
|
||||
} else {
|
||||
void navigateWithTransition(next?.asset);
|
||||
}
|
||||
};
|
||||
const handlePreviousAsset = () => {
|
||||
const previous = current?.previous;
|
||||
if (previous && previous.memory.id !== current?.memory.id) {
|
||||
void navigateToMemory('previous', previous.asset);
|
||||
} else {
|
||||
void navigateWithTransition(previous?.asset);
|
||||
}
|
||||
};
|
||||
const navigateToMemory = (direction: 'next' | 'previous', asset?: { id: string }) => {
|
||||
const isNext = direction === 'next';
|
||||
const useHeroMorph = !mediaQueryManager.reducedMotion;
|
||||
|
||||
return withMemoryTransition(asset, {
|
||||
types: ['memory'],
|
||||
prepareOldSnapshot: () => {
|
||||
if (useHeroMorph) {
|
||||
if (isNext) {
|
||||
transition.nextPanel = 'hero';
|
||||
transition.previousPanel = 'memory-departing';
|
||||
} else {
|
||||
transition.previousPanel = 'hero';
|
||||
transition.nextPanel = 'memory-departing';
|
||||
}
|
||||
transition.name = 'hero-out';
|
||||
} else {
|
||||
transition.name = 'memory-fade-out';
|
||||
}
|
||||
},
|
||||
performUpdate: async () => {
|
||||
transition.nextPanel = undefined;
|
||||
transition.previousPanel = undefined;
|
||||
if (useHeroMorph) {
|
||||
if (isNext) {
|
||||
transition.previousPanel = 'hero-out';
|
||||
} else {
|
||||
transition.nextPanel = 'hero-out';
|
||||
}
|
||||
}
|
||||
transition.name = useHeroMorph ? 'hero' : 'memory-fade-in';
|
||||
await goto(asHref(asset!));
|
||||
await eventManager.untilNext('ViewerOpenTransitionReady');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleNextMemory = () => void navigateToMemory('next', current?.nextMemory?.assets[0]);
|
||||
const handlePreviousMemory = () => void navigateToMemory('previous', current?.previousMemory?.assets[0]);
|
||||
const closeMemoryViewer = () => {
|
||||
if (current && current.assetIndex > 0 && !mediaQueryManager.reducedMotion) {
|
||||
const firstAsset = current.memory.assets[0];
|
||||
void withMemoryTransition(firstAsset, {
|
||||
types: ['memory-nav', 'memory-nav-fast'],
|
||||
prepareOldSnapshot: () => {
|
||||
transition.name = 'memory-fade-out';
|
||||
},
|
||||
performUpdate: async () => {
|
||||
await goto(asHref(firstAsset));
|
||||
await eventManager.untilNext('ViewerOpenTransitionReady');
|
||||
},
|
||||
prepareNewSnapshot: () => {
|
||||
transition.name = 'memory-fade-in';
|
||||
},
|
||||
onFinished: () => closeToTimeline(),
|
||||
});
|
||||
} else {
|
||||
closeToTimeline();
|
||||
}
|
||||
};
|
||||
|
||||
const closeToTimeline = () => {
|
||||
const memoryId = current?.memory.id;
|
||||
let cardImage: HTMLElement | null | undefined;
|
||||
|
||||
void viewTransitionManager.startTransition({
|
||||
types: ['memory-enter'],
|
||||
prepareOldSnapshot: () => {
|
||||
transition.name = 'hero';
|
||||
},
|
||||
performUpdate: async () => {
|
||||
transition.name = undefined;
|
||||
await goto(Route.photos());
|
||||
await tick();
|
||||
|
||||
const memoryCard = memoryId
|
||||
? document.querySelector<HTMLElement>(`[data-memory-id="${CSS.escape(memoryId)}"]`)
|
||||
: null;
|
||||
memoryCard?.scrollIntoView({ behavior: 'instant', inline: 'nearest', block: 'nearest' });
|
||||
cardImage = memoryCard?.querySelector<HTMLElement>('img');
|
||||
if (cardImage) {
|
||||
cardImage.style.viewTransitionName = 'hero';
|
||||
await tick();
|
||||
}
|
||||
},
|
||||
onFinished: () => {
|
||||
if (cardImage) {
|
||||
cardImage.style.viewTransitionName = '';
|
||||
cardImage = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleEscape = closeMemoryViewer;
|
||||
const handleSelectAll = () =>
|
||||
assetInteraction.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []);
|
||||
|
||||
@@ -160,13 +327,17 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleProgress = async (progress: number) => {
|
||||
const handleProgress = (progress: number) => {
|
||||
if (!progressBarController) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (progress === 1 && !paused) {
|
||||
await (current?.next ? handleNextAsset() : handlePromiseError(handleAction('handleProgressLast', 'pause')));
|
||||
if (progress === 1 && !paused && !transition.active) {
|
||||
if (current?.next) {
|
||||
handleNextAsset();
|
||||
} else {
|
||||
handlePromiseError(handleAction('handleProgressLast', 'pause'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -270,7 +441,18 @@
|
||||
playerInitialized = false;
|
||||
};
|
||||
|
||||
const resetAndPlay = () => {
|
||||
const resolveTransitionIfPending = () => {
|
||||
if (viewTransitionManager.activeViewTransition) {
|
||||
transition.name = 'hero';
|
||||
eventManager.emit('ViewerOpenTransitionReady');
|
||||
requestAnimationFrame(() => {
|
||||
transition.name = undefined;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMemoryImageReady = () => {
|
||||
resolveTransitionIfPending();
|
||||
handlePromiseError(handleAction('resetAndPlay', 'reset'));
|
||||
handlePromiseError(handleAction('resetAndPlay', 'play'));
|
||||
};
|
||||
@@ -285,7 +467,7 @@
|
||||
handlePromiseError(handleAction('initPlayer[AssetViewOpen]', 'pause'));
|
||||
} else if (isVideo) {
|
||||
// Image assets will start playing when the image is loaded. Only autostart video assets.
|
||||
resetAndPlay();
|
||||
handleMemoryImageReady();
|
||||
}
|
||||
playerInitialized = true;
|
||||
};
|
||||
@@ -313,7 +495,7 @@
|
||||
|
||||
$effect(() => {
|
||||
if (progressBarController) {
|
||||
handlePromiseError(handleProgress(progressBarController.current));
|
||||
handleProgress(progressBarController.current);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -382,7 +564,7 @@
|
||||
bind:clientWidth={viewport.width}
|
||||
>
|
||||
{#if current}
|
||||
<ControlAppBar onClose={() => goto(Route.photos())} forceDark multiRow>
|
||||
<ControlAppBar onClose={closeMemoryViewer} forceDark multiRow>
|
||||
{#snippet leading()}
|
||||
{#if current}
|
||||
<p class="text-lg">
|
||||
@@ -458,7 +640,11 @@
|
||||
class="ms-[-100%] box-border flex h-[calc(100vh-224px)] md:h-[calc(100vh-180px)] w-[300%] items-center justify-center gap-10 overflow-hidden"
|
||||
>
|
||||
<!-- PREVIOUS MEMORY -->
|
||||
<div class="h-1/2 w-[20vw] rounded-2xl {current.previousMemory ? 'opacity-25 hover:opacity-70' : 'opacity-0'}">
|
||||
<div
|
||||
class="h-1/2 w-[20vw] rounded-2xl opacity-25 transition-opacity duration-150 hover:opacity-70 {current.previousMemory
|
||||
? ''
|
||||
: 'opacity-0!'}"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="relative h-full w-full rounded-2xl"
|
||||
@@ -471,6 +657,7 @@
|
||||
src={getAssetMediaUrl({ id: current.previousMemory.assets[0].id, size: AssetMediaSize.Preview })}
|
||||
alt={$t('previous_memory')}
|
||||
draggable="false"
|
||||
style:view-transition-name={transition.previousPanel}
|
||||
/>
|
||||
{:else}
|
||||
<enhanced:img
|
||||
@@ -483,7 +670,10 @@
|
||||
{/if}
|
||||
|
||||
{#if current.previousMemory}
|
||||
<div class="absolute bottom-4 end-4 text-start text-white">
|
||||
<div
|
||||
class="absolute bottom-4 end-4 text-start text-white"
|
||||
style:view-transition-name={transition.active ? 'memory-overlay-prev' : undefined}
|
||||
>
|
||||
<p class="uppercase text-xs font-semibold text-gray-200">{$t('previous')}</p>
|
||||
<p class="text-xl">{$memoryLaneTitle(current.previousMemory)}</p>
|
||||
</div>
|
||||
@@ -492,39 +682,42 @@
|
||||
</div>
|
||||
|
||||
<!-- CURRENT MEMORY -->
|
||||
<div
|
||||
class="main-view relative flex h-full w-[70vw] place-content-center place-items-center rounded-2xl bg-black"
|
||||
>
|
||||
<div class="relative h-full w-full rounded-2xl bg-black">
|
||||
{#key current.asset.id}
|
||||
{#if current.asset.isVideo}
|
||||
<MemoryVideoViewer
|
||||
asset={current.asset}
|
||||
bind:videoPlayer
|
||||
videoViewerMuted={$videoViewerMuted}
|
||||
videoViewerVolume={$videoViewerVolume}
|
||||
/>
|
||||
{:else}
|
||||
<MemoryPhotoViewer asset={current.asset} onImageLoad={resetAndPlay} />
|
||||
{/if}
|
||||
{/key}
|
||||
<div class="main-view relative isolate h-full w-[70vw] rounded-2xl bg-black">
|
||||
{#key current.asset.id}
|
||||
{#if current.asset.isVideo}
|
||||
<MemoryVideoViewer
|
||||
asset={current.asset}
|
||||
bind:videoPlayer
|
||||
videoViewerMuted={$videoViewerMuted}
|
||||
videoViewerVolume={$videoViewerVolume}
|
||||
/>
|
||||
{:else if currentAssetDto}
|
||||
<MemoryPhotoViewer
|
||||
asset={currentAssetDto}
|
||||
transitionName={transition.name}
|
||||
onImageLoad={handleMemoryImageReady}
|
||||
onError={resolveTransitionIfPending}
|
||||
/>
|
||||
{/if}
|
||||
{/key}
|
||||
|
||||
<div
|
||||
class="absolute bottom-0 end-0 p-2 transition-all flex h-full justify-between flex-col items-end gap-2 dark"
|
||||
class:opacity-0={galleryInView}
|
||||
class:opacity-100={!galleryInView}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<IconButton
|
||||
icon={isSaved ? mdiHeart : mdiHeartOutline}
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
aria-label={isSaved ? $t('unfavorite') : $t('favorite')}
|
||||
onclick={() => handleSaveMemory()}
|
||||
class="w-12 h-12"
|
||||
/>
|
||||
<!-- <IconButton
|
||||
<div
|
||||
class="absolute bottom-0 end-0 p-2 transition-all flex h-full justify-between flex-col items-end gap-2 dark"
|
||||
class:opacity-0={galleryInView}
|
||||
class:opacity-100={!galleryInView}
|
||||
style:view-transition-name={showTransitionOverlays ? 'memory-controls' : undefined}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<IconButton
|
||||
icon={isSaved ? mdiHeart : mdiHeartOutline}
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
aria-label={isSaved ? $t('unfavorite') : $t('favorite')}
|
||||
onclick={() => handleSaveMemory()}
|
||||
class="w-12 h-12"
|
||||
/>
|
||||
<!-- <IconButton
|
||||
icon={mdiShareVariantOutline}
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
@@ -532,42 +725,46 @@
|
||||
color="secondary"
|
||||
aria-label={$t('share')}
|
||||
/> -->
|
||||
<ButtonContextMenu
|
||||
icon={mdiDotsVertical}
|
||||
title={$t('menu')}
|
||||
onclick={() => handlePromiseError(handleAction('ContextMenuClick', 'pause'))}
|
||||
direction="left"
|
||||
size="medium"
|
||||
align="bottom-right"
|
||||
>
|
||||
<MenuOption onClick={() => handleDeleteMemory()} text={$t('remove_memory')} icon={mdiCardsOutline} />
|
||||
<MenuOption
|
||||
onClick={() => handleDeleteMemoryAsset()}
|
||||
text={$t('remove_photo_from_memory')}
|
||||
icon={mdiImageMinusOutline}
|
||||
/>
|
||||
<!-- shortcut={{ key: 'l', shift: shared }} -->
|
||||
</ButtonContextMenu>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{#await currentMemoryAssetFull then asset}
|
||||
{#if asset}
|
||||
<IconButton
|
||||
href={Route.photos({ at: asset.stack?.primaryAssetId ?? asset.id })}
|
||||
icon={mdiImageSearch}
|
||||
aria-label={$t('view_in_timeline')}
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
/>
|
||||
{/if}
|
||||
{/await}
|
||||
</div>
|
||||
<ButtonContextMenu
|
||||
icon={mdiDotsVertical}
|
||||
title={$t('menu')}
|
||||
onclick={() => handlePromiseError(handleAction('ContextMenuClick', 'pause'))}
|
||||
direction="left"
|
||||
size="medium"
|
||||
align="bottom-right"
|
||||
>
|
||||
<MenuOption onClick={() => handleDeleteMemory()} text={$t('remove_memory')} icon={mdiCardsOutline} />
|
||||
<MenuOption
|
||||
onClick={() => handleDeleteMemoryAsset()}
|
||||
text={$t('remove_photo_from_memory')}
|
||||
icon={mdiImageMinusOutline}
|
||||
/>
|
||||
<!-- shortcut={{ key: 'l', shift: shared }} -->
|
||||
</ButtonContextMenu>
|
||||
</div>
|
||||
<!-- CONTROL BUTTONS -->
|
||||
|
||||
<div>
|
||||
{#await currentMemoryAssetFull then asset}
|
||||
{#if asset}
|
||||
<IconButton
|
||||
href={Route.photos({ at: asset.stack?.primaryAssetId ?? asset.id })}
|
||||
icon={mdiImageSearch}
|
||||
aria-label={$t('view_in_timeline')}
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
/>
|
||||
{/if}
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
<!-- CONTROL BUTTONS -->
|
||||
<div
|
||||
class="absolute inset-0 pointer-events-none"
|
||||
style:view-transition-name={showNavButtonOverlay ? 'memory-nav-buttons' : undefined}
|
||||
>
|
||||
{#if current.previous}
|
||||
<div class="absolute top-1/2 start-0 ms-4 dark">
|
||||
<div class="absolute top-1/2 inset-s-0 ms-4 dark pointer-events-auto">
|
||||
<IconButton
|
||||
shape="round"
|
||||
aria-label={$t('previous_memory')}
|
||||
@@ -581,7 +778,7 @@
|
||||
{/if}
|
||||
|
||||
{#if current.next}
|
||||
<div class="absolute top-1/2 end-0 me-4 dark">
|
||||
<div class="absolute top-1/2 inset-e-0 me-4 dark pointer-events-auto">
|
||||
<IconButton
|
||||
shape="round"
|
||||
aria-label={$t('next_memory')}
|
||||
@@ -593,25 +790,32 @@
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="absolute start-8 top-4 text-sm font-medium text-white">
|
||||
<p>
|
||||
{fromISODateTimeUTC(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, {
|
||||
locale: $locale,
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{#await currentMemoryAssetFull then asset}
|
||||
{asset?.exifInfo?.city || ''}
|
||||
{asset?.exifInfo?.country || ''}
|
||||
{/await}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="absolute start-8 top-4 text-sm font-medium text-white"
|
||||
style:view-transition-name={showTransitionOverlays ? 'memory-overlay' : undefined}
|
||||
>
|
||||
<p>
|
||||
{fromISODateTimeUTC(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, {
|
||||
locale: $locale,
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{#await currentMemoryAssetFull then asset}
|
||||
{asset?.exifInfo?.city || ''}
|
||||
{asset?.exifInfo?.country || ''}
|
||||
{/await}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NEXT MEMORY -->
|
||||
<div class="h-1/2 w-[20vw] rounded-2xl {current.nextMemory ? 'opacity-25 hover:opacity-70' : 'opacity-0'}">
|
||||
<div
|
||||
class="h-1/2 w-[20vw] rounded-2xl opacity-25 transition-opacity duration-150 hover:opacity-70 {current.nextMemory
|
||||
? ''
|
||||
: 'opacity-0!'}"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="relative h-full w-full rounded-2xl"
|
||||
@@ -624,6 +828,7 @@
|
||||
src={getAssetMediaUrl({ id: current.nextMemory.assets[0].id, size: AssetMediaSize.Preview })}
|
||||
alt={$t('next_memory')}
|
||||
draggable="false"
|
||||
style:view-transition-name={transition.nextPanel}
|
||||
/>
|
||||
{:else}
|
||||
<enhanced:img
|
||||
@@ -636,7 +841,10 @@
|
||||
{/if}
|
||||
|
||||
{#if current.nextMemory}
|
||||
<div class="absolute bottom-4 start-4 text-start text-white">
|
||||
<div
|
||||
class="absolute bottom-4 start-4 text-start text-white"
|
||||
style:view-transition-name={transition.active ? 'memory-overlay-next' : undefined}
|
||||
>
|
||||
<p class="uppercase text-xs font-semibold text-gray-200">{$t('up_next')}</p>
|
||||
<p class="text-xl">{$memoryLaneTitle(current.nextMemory)}</p>
|
||||
</div>
|
||||
@@ -680,8 +888,6 @@
|
||||
|
||||
<style>
|
||||
.main-view {
|
||||
box-shadow:
|
||||
0 4px 4px 0 rgba(0, 0, 0, 0.3),
|
||||
0 8px 12px 6px rgba(0, 0, 0, 0.15);
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3)) drop-shadow(0 2px 6px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -48,21 +48,34 @@ describe('ViewTransitionManager', () => {
|
||||
});
|
||||
|
||||
describe('when a transition is already active', () => {
|
||||
it('should skip the second transition', async () => {
|
||||
let resolveFinished!: () => void;
|
||||
const finished = new Promise<void>((resolve) => {
|
||||
resolveFinished = resolve;
|
||||
});
|
||||
let resolveUpdate!: () => void;
|
||||
const updateCallbackDone = new Promise<void>((resolve) => {
|
||||
resolveUpdate = resolve;
|
||||
it('should skip the first transition and run the second', async () => {
|
||||
let resolveFirstUpdate!: () => void;
|
||||
const firstUpdateCallbackDone = new Promise<void>((resolve) => {
|
||||
resolveFirstUpdate = resolve;
|
||||
});
|
||||
const firstFinished = new Promise<void>(() => {});
|
||||
const firstSkipTransition = vi.fn();
|
||||
|
||||
let callCount = 0;
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
|
||||
callCount++;
|
||||
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
|
||||
void updateFn();
|
||||
return { updateCallbackDone, finished, ready: Promise.resolve(), skipTransition: vi.fn() };
|
||||
if (callCount === 1) {
|
||||
return {
|
||||
updateCallbackDone: firstUpdateCallbackDone,
|
||||
finished: firstFinished,
|
||||
ready: Promise.resolve(),
|
||||
skipTransition: firstSkipTransition,
|
||||
};
|
||||
}
|
||||
return {
|
||||
updateCallbackDone: Promise.resolve(),
|
||||
finished: Promise.resolve(),
|
||||
ready: Promise.resolve(),
|
||||
skipTransition: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const secondPerformUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -75,13 +88,13 @@ describe('ViewTransitionManager', () => {
|
||||
// Flush microtasks so the first transition reaches the startViewTransition call
|
||||
await new Promise<void>((r) => queueMicrotask(r));
|
||||
|
||||
// While first is active, try a second — should be skipped
|
||||
// While first is active, start a second — should skip the first and proceed
|
||||
await manager.startTransition({ performUpdate: secondPerformUpdate });
|
||||
expect(secondPerformUpdate).not.toHaveBeenCalled();
|
||||
expect(firstSkipTransition).toHaveBeenCalledOnce();
|
||||
expect(secondPerformUpdate).toHaveBeenCalledOnce();
|
||||
|
||||
// Clean up
|
||||
resolveUpdate();
|
||||
resolveFinished();
|
||||
// Clean up first promise
|
||||
resolveFirstUpdate();
|
||||
await firstPromise;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ interface TransitionRequest {
|
||||
|
||||
export class ViewTransitionManager {
|
||||
#activeViewTransition = $state<ViewTransition | null>(null);
|
||||
#activeOnFinished: (() => void) | undefined;
|
||||
|
||||
get activeViewTransition() {
|
||||
return this.#activeViewTransition;
|
||||
@@ -23,6 +24,9 @@ export class ViewTransitionManager {
|
||||
const skipped = !!this.#activeViewTransition;
|
||||
this.#activeViewTransition?.skipTransition();
|
||||
this.#activeViewTransition = null;
|
||||
const onFinished = this.#activeOnFinished;
|
||||
this.#activeOnFinished = undefined;
|
||||
onFinished?.();
|
||||
return skipped;
|
||||
}
|
||||
|
||||
@@ -34,7 +38,7 @@ export class ViewTransitionManager {
|
||||
onFinished,
|
||||
}: TransitionRequest) {
|
||||
if (this.#activeViewTransition) {
|
||||
return;
|
||||
this.skipTransitions();
|
||||
}
|
||||
|
||||
if (!this.isSupported()) {
|
||||
@@ -68,6 +72,7 @@ export class ViewTransitionManager {
|
||||
}
|
||||
|
||||
this.#activeViewTransition = transition;
|
||||
this.#activeOnFinished = onFinished;
|
||||
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
void transition.ready.catch((error: unknown) => {
|
||||
@@ -80,8 +85,11 @@ export class ViewTransitionManager {
|
||||
void transition.finished
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
this.#activeViewTransition = null;
|
||||
onFinished?.();
|
||||
if (this.#activeViewTransition === transition) {
|
||||
this.#activeViewTransition = null;
|
||||
this.#activeOnFinished = undefined;
|
||||
onFinished?.();
|
||||
}
|
||||
});
|
||||
|
||||
// Wait only until the DOM update completes (both snapshots captured),
|
||||
|
||||
@@ -2,15 +2,16 @@ import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
export function startViewerTransition(
|
||||
assetId: string,
|
||||
function startHeroTransition(
|
||||
type: string,
|
||||
id: string,
|
||||
navigate: () => void,
|
||||
setTransitionId: (id: string | null) => void,
|
||||
) {
|
||||
void viewTransitionManager.startTransition({
|
||||
types: ['viewer'],
|
||||
types: [type],
|
||||
prepareOldSnapshot: () => {
|
||||
setTransitionId(assetId);
|
||||
setTransitionId(id);
|
||||
},
|
||||
performUpdate: async (signal) => {
|
||||
setTransitionId(null);
|
||||
@@ -23,6 +24,22 @@ export function startViewerTransition(
|
||||
});
|
||||
}
|
||||
|
||||
export function startViewerTransition(
|
||||
assetId: string,
|
||||
navigate: () => void,
|
||||
setTransitionId: (id: string | null) => void,
|
||||
) {
|
||||
startHeroTransition('viewer', assetId, navigate, setTransitionId);
|
||||
}
|
||||
|
||||
export function startMemoryTransition(
|
||||
memoryId: string,
|
||||
navigate: () => void,
|
||||
setTransitionId: (id: string | null) => void,
|
||||
) {
|
||||
startHeroTransition('memory-enter', memoryId, navigate, setTransitionId);
|
||||
}
|
||||
|
||||
let activeOverlay: HTMLElement | undefined;
|
||||
|
||||
export function removeCrossfadeOverlay() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import { beforeNavigate, goto } from '$app/navigation';
|
||||
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
|
||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
@@ -38,8 +38,9 @@
|
||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { startMemoryTransition } from '$lib/utils/transition-utils';
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel } from '@immich/ui';
|
||||
import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel, type CarouselImageItem } from '@immich/ui';
|
||||
import { mdiDotsVertical } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -99,6 +100,16 @@
|
||||
src: getAssetMediaUrl({ id: memory.assets[0].id }),
|
||||
})),
|
||||
);
|
||||
|
||||
let memoryTransitionId = $state<string | null>(null);
|
||||
|
||||
const handleMemoryCardClick = (item: CarouselImageItem) => {
|
||||
startMemoryTransition(
|
||||
item.id ?? item.href,
|
||||
() => void goto(item.href),
|
||||
(id) => (memoryTransitionId = id),
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<UserPageLayout hideNavbar={assetInteraction.selectionActive} scrollbar={false}>
|
||||
@@ -112,7 +123,33 @@
|
||||
withStacked
|
||||
>
|
||||
{#if $preferences.memories.enabled}
|
||||
<ImageCarousel {items} />
|
||||
{#snippet memoryCard(item: CarouselImageItem)}
|
||||
<a
|
||||
class="relative me-2 inline-block aspect-3/4 h-54 rounded-xl last:me-0 max-md:h-37.5 md:me-4 md:aspect-4/3 xl:aspect-video"
|
||||
href={item.href}
|
||||
data-memory-id={item.id}
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
handleMemoryCardClick(item);
|
||||
}}
|
||||
style:box-shadow="rgba(60, 64, 67, 0.3) 0px 1px 2px 0px, rgba(60, 64, 67, 0.15) 0px 1px 3px 1px"
|
||||
>
|
||||
<img
|
||||
class="h-full w-full rounded-xl object-cover"
|
||||
src={item.src}
|
||||
alt={item.alt ?? item.title}
|
||||
draggable="false"
|
||||
style:view-transition-name={memoryTransitionId === (item.id ?? item.href) ? 'hero' : undefined}
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-s-0 top-0 h-full w-full rounded-xl bg-linear-to-t from-black/40 via-transparent to-transparent transition-all hover:bg-black/20"
|
||||
></div>
|
||||
<p class="absolute inset-s-4 bottom-2 text-lg text-white max-md:text-sm">
|
||||
{item.title}
|
||||
</p>
|
||||
</a>
|
||||
{/snippet}
|
||||
<ImageCarousel {items} child={memoryCard} />
|
||||
{/if}
|
||||
{#snippet empty()}
|
||||
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} class="mt-10 mx-auto" />
|
||||
|
||||
Reference in New Issue
Block a user