From 1f1d4bb7fd3234161b38d9acef0c233f7ede8166 Mon Sep 17 00:00:00 2001 From: midzelis Date: Tue, 9 Dec 2025 19:04:41 +0000 Subject: [PATCH] swipeFeedback --- web/src/lib/actions/swipe-feedback.ts | 429 ++++++++++++++++++ web/src/lib/actions/zoom-image.ts | 10 +- .../asset-viewer/asset-viewer.svelte | 33 +- .../asset-viewer/photo-viewer.svelte | 91 +++- .../asset-viewer/video-native-viewer.svelte | 8 +- .../lib/components/timeline/Timeline.svelte | 19 +- .../managers/ViewTransitionManager.svelte.ts | 12 +- web/src/lib/utils.ts | 5 +- 8 files changed, 561 insertions(+), 46 deletions(-) create mode 100644 web/src/lib/actions/swipe-feedback.ts diff --git a/web/src/lib/actions/swipe-feedback.ts b/web/src/lib/actions/swipe-feedback.ts new file mode 100644 index 0000000000..d0c3c71d93 --- /dev/null +++ b/web/src/lib/actions/swipe-feedback.ts @@ -0,0 +1,429 @@ +export interface SwipeFeedbackOptions { + /** Whether the swipe feedback is disabled */ + disabled?: boolean; + /** Callback when swipe ends with the final offset */ + onSwipeEnd?: (offsetX: number) => void; + /** Callback during swipe with current offset */ + onSwipeMove?: (offsetX: number) => void; + /** URL for the preview image shown on the left when swiping right (previous) */ + leftPreviewUrl?: string | null; + /** URL for the preview image shown on the right when swiping left (next) */ + rightPreviewUrl?: string | null; + /** Callback called before swipe commit animation starts - includes direction and preview image dimensions */ + onPreCommit?: (direction: 'left' | 'right', naturalWidth: number, naturalHeight: number) => void; + /** Callback when swipe is committed (threshold exceeded) after animation completes */ + onSwipeCommit?: (direction: 'left' | 'right') => void; + /** Threshold as a percentage of container width to commit the swipe (default: 0.25 = 25%) */ + commitThreshold?: number; + /** Current asset URL - when this changes, preview containers are reset */ + currentAssetUrl?: string | null; +} + +/** + * Action that provides visual feedback for horizontal swipe gestures. + * Allows the user to drag an element left or right (horizontal only), + * and resets the position when the drag ends. + * Optionally shows preview images on the left/right during swipe. + */ +export const swipeFeedback = (node: HTMLElement, options?: SwipeFeedbackOptions) => { + // Find the image element to apply custom transforms + const imgElement = node.querySelector('img'); + + if (!imgElement) { + console.warn('swipeFeedback: No img element found in node'); + return { + update() {}, + destroy() {}, + }; + } + + let isDragging = false; + let startX = 0; + let currentOffsetX = 0; + let thresholdCrossed = false; + let lastAssetUrl = options?.currentAssetUrl; + + // Set initial cursor + node.style.cursor = 'grab'; + + const resetPreviewContainers = () => { + // Reset transforms and opacity + if (leftPreviewContainer) { + leftPreviewContainer.style.transform = ''; + leftPreviewContainer.style.transition = ''; + leftPreviewContainer.style.zIndex = '-1'; + leftPreviewContainer.style.display = 'none'; + } + if (rightPreviewContainer) { + rightPreviewContainer.style.transform = ''; + rightPreviewContainer.style.transition = ''; + rightPreviewContainer.style.zIndex = '-1'; + rightPreviewContainer.style.display = 'none'; + } + // Reset main image + imgElement.style.transform = ''; + imgElement.style.transition = ''; + imgElement.style.opacity = ''; + currentOffsetX = 0; + }; + + // Create preview image containers + let leftPreviewContainer: HTMLDivElement | null = null; + let rightPreviewContainer: HTMLDivElement | null = null; + let leftPreviewImg: HTMLImageElement | null = null; + let rightPreviewImg: HTMLImageElement | null = null; + + const createPreviewContainer = (): { container: HTMLDivElement; img: HTMLImageElement } => { + const container = document.createElement('div'); + container.style.position = 'absolute'; + container.style.pointerEvents = 'none'; + container.style.display = 'none'; + container.style.zIndex = '-1'; + + const img = document.createElement('img'); + img.style.width = '100%'; + img.style.height = '100%'; + img.style.objectFit = 'contain'; + img.draggable = false; + img.alt = ''; + + container.append(img); + node.parentElement?.append(container); + + return { container, img }; + }; + + const ensurePreviewsCreated = () => { + // Create left preview if needed and URL is available + if (options?.leftPreviewUrl && !leftPreviewContainer) { + const preview = createPreviewContainer(); + leftPreviewContainer = preview.container; + leftPreviewImg = preview.img; + leftPreviewImg.src = options.leftPreviewUrl; + } + + // Create right preview if needed and URL is available + if (options?.rightPreviewUrl && !rightPreviewContainer) { + const preview = createPreviewContainer(); + rightPreviewContainer = preview.container; + rightPreviewImg = preview.img; + rightPreviewImg.src = options.rightPreviewUrl; + } + }; + + const updatePreviewPositions = () => { + // Get the parent container dimensions (full viewport area) + const parentElement = node.parentElement; + if (!parentElement) { + return; + } + + const parentComputedStyle = globalThis.getComputedStyle(parentElement); + const viewportWidth = Number.parseFloat(parentComputedStyle.width); + const viewportHeight = Number.parseFloat(parentComputedStyle.height); + + // Preview containers should be full viewport size + if (leftPreviewContainer) { + leftPreviewContainer.style.width = `${viewportWidth}px`; + leftPreviewContainer.style.height = `${viewportHeight}px`; + leftPreviewContainer.style.left = `${-viewportWidth}px`; + leftPreviewContainer.style.top = `0px`; + } + + if (rightPreviewContainer) { + rightPreviewContainer.style.width = `${viewportWidth}px`; + rightPreviewContainer.style.height = `${viewportHeight}px`; + rightPreviewContainer.style.left = `${viewportWidth}px`; + rightPreviewContainer.style.top = `0px`; + } + }; + + const updatePreviewVisibility = () => { + // Show left preview when swiping right (offsetX > 0) + if (leftPreviewContainer) { + leftPreviewContainer.style.display = currentOffsetX > 0 ? 'block' : 'none'; + } + + // Show right preview when swiping left (offsetX < 0) + if (rightPreviewContainer) { + rightPreviewContainer.style.display = currentOffsetX < 0 ? 'block' : 'none'; + } + }; + + const pointerDown = (event: PointerEvent) => { + if (options?.disabled) { + return; + } + + // Only handle single pointer (mouse or single touch) + if (event.isPrimary && imgElement) { + isDragging = true; + startX = event.clientX; + thresholdCrossed = false; + // Change cursor to grabbing + node.style.cursor = 'grabbing'; + // Capture pointer so we continue to receive events even if mouse moves outside element + node.setPointerCapture(event.pointerId); + + // Also add document listeners as fallback + document.addEventListener('pointerup', pointerUp); + document.addEventListener('pointercancel', pointerUp); + ensurePreviewsCreated(); + updatePreviewPositions(); + event.preventDefault(); + } + }; + + const pointerMove = (event: PointerEvent) => { + if (options?.disabled) { + return; + } + + if (isDragging && imgElement) { + currentOffsetX = event.clientX - startX; + + // Check threshold crossing for cursor feedback + const containerWidth = Number.parseFloat(globalThis.getComputedStyle(node).width); + const threshold = containerWidth * (options?.commitThreshold ?? 0.25); + const crossed = Math.abs(currentOffsetX) >= threshold; + + // Update cursor if threshold state changed + if (crossed !== thresholdCrossed) { + thresholdCrossed = crossed; + // Change cursor to indicate threshold crossed (pointer means clickable/actionable) + node.style.cursor = crossed ? 'pointer' : 'grabbing'; + } + + // Apply transform directly to the image element + // Only translate horizontally (no vertical movement) + imgElement.style.transform = `translate(${currentOffsetX}px, 0px)`; + + // Apply same transform to preview containers so they move with the swipe + if (leftPreviewContainer) { + leftPreviewContainer.style.transform = `translate(${currentOffsetX}px, 0px)`; + } + if (rightPreviewContainer) { + rightPreviewContainer.style.transform = `translate(${currentOffsetX}px, 0px)`; + } + + // Update preview visibility + updatePreviewVisibility(); + // Notify about swipe movement + options?.onSwipeMove?.(currentOffsetX); + event.preventDefault(); + } + }; + + const resetPosition = () => { + // Add smooth transition + const transitionStyle = 'transform 0.3s ease-out'; + imgElement.style.transition = transitionStyle; + if (leftPreviewContainer) { + leftPreviewContainer.style.transition = transitionStyle; + } + if (rightPreviewContainer) { + rightPreviewContainer.style.transition = transitionStyle; + } + + // Reset transforms + imgElement.style.transform = 'translate(0px, 0px)'; + if (leftPreviewContainer) { + leftPreviewContainer.style.transform = 'translate(0px, 0px)'; + } + if (rightPreviewContainer) { + rightPreviewContainer.style.transform = 'translate(0px, 0px)'; + } + + // Remove transition after animation completes + setTimeout(() => { + imgElement.style.transition = ''; + if (leftPreviewContainer) { + leftPreviewContainer.style.transition = ''; + } + if (rightPreviewContainer) { + rightPreviewContainer.style.transition = ''; + } + }, 300); + + currentOffsetX = 0; + updatePreviewVisibility(); + }; + + const completeTransition = (direction: 'left' | 'right') => { + // Get the active preview image and its dimensions + const activePreviewImg = direction === 'right' ? leftPreviewImg : rightPreviewImg; + const naturalWidth = activePreviewImg?.naturalWidth ?? 1; + const naturalHeight = activePreviewImg?.naturalHeight ?? 1; + + // Call pre-commit callback BEFORE starting the animation + // This allows the parent component to update state with the preview dimensions + options?.onPreCommit?.(direction, naturalWidth, naturalHeight); + + // Add smooth transition + const transitionStyle = 'transform 0.3s ease-out'; + imgElement.style.transition = transitionStyle; + if (leftPreviewContainer) { + leftPreviewContainer.style.transition = transitionStyle; + } + if (rightPreviewContainer) { + rightPreviewContainer.style.transition = transitionStyle; + } + + // Calculate the final offset to center the preview + const parentElement = node.parentElement; + if (!parentElement) { + return; + } + const viewportWidth = Number.parseFloat(globalThis.getComputedStyle(parentElement).width); + + // Slide everything to complete the transition + // If swiping right (direction='right'), slide everything right by viewport width + // If swiping left (direction='left'), slide everything left by viewport width + const finalOffset = direction === 'right' ? viewportWidth : -viewportWidth; + + // Listen for transition end + const handleTransitionEnd = () => { + imgElement.removeEventListener('transitionend', handleTransitionEnd); + + // Keep the preview visible by hiding the main image but showing the preview + // The preview is now centered, and we want it to stay visible while the new component loads + imgElement.style.opacity = '0'; + + // Show the preview that's now in the center + const activePreview = direction === 'right' ? leftPreviewContainer : rightPreviewContainer; + + if (activePreview) { + activePreview.style.zIndex = '1'; // Bring to front + } + + // Remove transitions + imgElement.style.transition = ''; + if (leftPreviewContainer) { + leftPreviewContainer.style.transition = ''; + } + if (rightPreviewContainer) { + rightPreviewContainer.style.transition = ''; + } + + // Trigger navigation (dimensions were already passed in onPreCommit) + options?.onSwipeCommit?.(direction); + }; + + imgElement.addEventListener('transitionend', handleTransitionEnd, { once: true }); + + // Apply the final transform to trigger animation + imgElement.style.transform = `translate(${finalOffset}px, 0px)`; + if (leftPreviewContainer) { + leftPreviewContainer.style.transform = `translate(${finalOffset}px, 0px)`; + } + if (rightPreviewContainer) { + rightPreviewContainer.style.transform = `translate(${finalOffset}px, 0px)`; + } + }; + + const pointerUp = (event: PointerEvent) => { + if (isDragging && imgElement) { + isDragging = false; + // Reset cursor + node.style.cursor = 'grab'; + // Release pointer capture + if (node.hasPointerCapture(event.pointerId)) { + node.releasePointerCapture(event.pointerId); + } + // Remove document listeners + document.removeEventListener('pointerup', pointerUp); + document.removeEventListener('pointercancel', pointerUp); + + // Get container width to calculate threshold + const containerWidth = Number.parseFloat(globalThis.getComputedStyle(node).width); + const threshold = containerWidth * (options?.commitThreshold ?? 0.25); + + // Check if swipe exceeded threshold + let committed = false; + let commitDirection: 'left' | 'right' | null = null; + + if (currentOffsetX > threshold) { + // Swiped right (show previous) + committed = true; + commitDirection = 'right'; + } else if (currentOffsetX < -threshold) { + // Swiped left (show next) + committed = true; + commitDirection = 'left'; + } + + // Call onSwipeEnd callback + options?.onSwipeEnd?.(currentOffsetX); + + // If committed, complete the transition animation + if (committed && commitDirection) { + completeTransition(commitDirection); + } else { + // If not committed, reset position with animation + resetPosition(); + } + + thresholdCrossed = false; + } + }; + + // Add event listeners + node.addEventListener('pointerdown', pointerDown); + node.addEventListener('pointermove', pointerMove); + node.addEventListener('pointerup', pointerUp); + node.addEventListener('pointercancel', pointerUp); + + return { + update(newOptions?: SwipeFeedbackOptions) { + // Check if asset URL changed - if so, reset everything + if (newOptions?.currentAssetUrl && newOptions.currentAssetUrl !== lastAssetUrl) { + resetPreviewContainers(); + lastAssetUrl = newOptions.currentAssetUrl; + } + + options = newOptions; + + // Update or create left preview + if (options?.leftPreviewUrl) { + if (leftPreviewImg) { + // Update existing + leftPreviewImg.src = options.leftPreviewUrl; + } else if (!leftPreviewContainer) { + // Create if doesn't exist + const preview = createPreviewContainer(); + leftPreviewContainer = preview.container; + leftPreviewImg = preview.img; + leftPreviewImg.src = options.leftPreviewUrl; + } + } + + // Update or create right preview + if (options?.rightPreviewUrl) { + if (rightPreviewImg) { + // Update existing + rightPreviewImg.src = options.rightPreviewUrl; + } else if (!rightPreviewContainer) { + // Create if doesn't exist + const preview = createPreviewContainer(); + rightPreviewContainer = preview.container; + rightPreviewImg = preview.img; + rightPreviewImg.src = options.rightPreviewUrl; + } + } + }, + destroy() { + node.removeEventListener('pointerdown', pointerDown); + node.removeEventListener('pointermove', pointerMove); + node.removeEventListener('pointerup', pointerUp); + node.removeEventListener('pointercancel', pointerUp); + // Clean up document listeners in case they weren't removed + document.removeEventListener('pointerup', pointerUp); + document.removeEventListener('pointercancel', pointerUp); + // Clean up preview elements + leftPreviewContainer?.remove(); + rightPreviewContainer?.remove(); + // Reset cursor + node.style.cursor = ''; + }, + }; +}; diff --git a/web/src/lib/actions/zoom-image.ts b/web/src/lib/actions/zoom-image.ts index e67d3e1928..7e173f562d 100644 --- a/web/src/lib/actions/zoom-image.ts +++ b/web/src/lib/actions/zoom-image.ts @@ -14,6 +14,8 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea setZoomImageState(state); } + node.style.overflow = 'visible'; + // Store original event handlers so we can prevent them when disabled const wheelHandler = (event: WheelEvent) => { if (options?.disabled) { @@ -21,15 +23,15 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea } }; - const pointerDownHandler = (event: PointerEvent) => { + const disabledPointerDownHandler = (event: PointerEvent) => { if (options?.disabled) { event.stopImmediatePropagation(); } }; - // Add handlers at capture phase with higher priority + // Add handlers at capture phase with higher priority for disabled state node.addEventListener('wheel', wheelHandler, { capture: true }); - node.addEventListener('pointerdown', pointerDownHandler, { capture: true }); + node.addEventListener('pointerdown', disabledPointerDownHandler, { capture: true }); const unsubscribes = [photoZoomState.subscribe(setZoomImageState), zoomImageState.subscribe(photoZoomState.set)]; @@ -39,7 +41,7 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea }, destroy() { node.removeEventListener('wheel', wheelHandler, { capture: true }); - node.removeEventListener('pointerdown', pointerDownHandler, { capture: true }); + node.removeEventListener('pointerdown', disabledPointerDownHandler, { capture: true }); for (const unsubscribe of unsubscribes) { unsubscribe(); } diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 08cc50e96b..0847988e41 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -159,10 +159,14 @@ } }; - let transitionName = $state('hero'); - let equirectangularTransitionName = $state('hero'); + let transitionName = $state(null); + let equirectangularTransitionName = $state(); let detailPanelTransitionName = $state(null); + if (viewTransitionManager.activeViewTransition) { + transitionName = 'hero'; + equirectangularTransitionName = 'hero'; + } let addInfoTransition; let finished; onMount(async () => { @@ -277,7 +281,7 @@ const tracker = new InvocationTracker(); - const navigateAsset = (order?: 'previous' | 'next', e?: Event) => { + const navigateAsset = (order?: 'previous' | 'next', skipTransition?: boolean = false) => { if (!order) { if ($slideshowState === SlideshowState.PlaySlideshow) { order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next'; @@ -286,7 +290,6 @@ } } - e?.stopPropagation(); if (tracker.isActive()) { return; } @@ -295,7 +298,9 @@ let hasNext = false; if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) { - startTransition(null, undefined); + if (!skipTransition) { + startTransition(null, undefined); + } hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next(); if (!hasNext) { const asset = await onRandom?.(); @@ -307,7 +312,7 @@ } else if (onNavigateToAsset) { // only transition if the target is already preloaded, and is in a secure context const targetAsset = order === 'previous' ? previousAsset : nextAsset; - if (!!targetAsset && globalThis.isSecureContext && preloadManager.isPreloaded(targetAsset)) { + if (!skipTransition && !!targetAsset && globalThis.isSecureContext && preloadManager.isPreloaded(targetAsset)) { const targetTransition = $slideshowState === SlideshowState.PlaySlideshow ? null : order; startTransition(targetTransition, targetAsset); } @@ -468,7 +473,7 @@ $effect(() => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions asset.id; - if (viewerKind !== 'PhotoViewer' && viewerKind !== 'ImagePanaramaViewer') { + if (viewerKind !== 'PhotoViewer' && viewerKind !== 'ImagePanaramaViewer' && viewerKind !== 'VideoViewer') { eventManager.emit('AssetViewerFree'); } }); @@ -570,9 +575,10 @@ bind:copyImage {transitionName} asset={previewStackedAsset!} - onPreviousAsset={() => navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} - haveFadeTransition={false} + {nextAsset} + {previousAsset} + onPreviousAsset={() => navigateAsset('previous', true)} + onNextAsset={() => navigateAsset('next', true)} {sharedLink} /> {:else if viewerKind === 'StackVideoViewer'} @@ -611,10 +617,11 @@ bind:zoomToggle bind:copyImage {asset} - onPreviousAsset={() => navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} + {nextAsset} + {previousAsset} + onPreviousAsset={() => navigateAsset('previous', true)} + onNextAsset={() => navigateAsset('next', true)} {sharedLink} - haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition} onFree={() => eventManager.emit('AssetViewerFree')} /> {:else if viewerKind === 'VideoViewer'} diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index c0097d6904..ac43b4c731 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -1,5 +1,6 @@ @@ -265,11 +312,21 @@ {/if}
1, + onSwipeMove: handleSwipeMove, + onSwipeEnd: handleSwipeEnd, + onPreCommit: handlePreCommit, + onSwipeCommit: handleSwipeCommit, + leftPreviewUrl: previousAssetUrl, + rightPreviewUrl: nextAssetUrl, + currentAssetUrl: imageLoaderUrl, + }} style:width={box.width + 'px'} style:height={box.height + 'px'} style:left={box.left + 'px'} style:top={box.top + 'px'} + style:overflow="visible" class="absolute" > { + box = calculateSize(); + eventManager.emit('AssetViewerFree'); + }; + const handleCanPlay = async (video: HTMLVideoElement) => { try { if (!video.paused && !isScrubbing) { @@ -165,7 +171,7 @@ controls disablePictureInPicture {...useSwipe(onSwipe)} - onloadedmetadata={() => (box = calculateSize())} + onloadedmetadata={() => handleLoadedMetadata()} oncanplay={(e) => handleCanPlay(e.currentTarget)} onended={onVideoEnded} onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)} diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index dd3b654bd4..bc16c6c295 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -724,21 +724,22 @@ // tag target on the 'old' snapshot toAssetViewerTransitionId = asset.id; - viewTransitionManager.startTransition( - new Promise((resolve) => - eventManager.once('AssetViewerFree', () => { - eventManager.emit('TransitionToAssetViewer'); - resolve(); - }), - ), - ); - eventManager.once('StartViewTransition', () => { // remove target on the 'old' view, // asset-viewer will tag new target element for 'new' snapshot toAssetViewerTransitionId = null; }); + viewTransitionManager.startTransition( + new Promise((resolve) => + eventManager.once('AssetViewerFree', () => { + toAssetViewerTransitionId = null; + eventManager.emit('TransitionToAssetViewer'); + resolve(); + }), + ), + ); + if (typeof onThumbnailClick === 'function') { onThumbnailClick(asset, timelineManager, dayGroup, _onClick); } else { diff --git a/web/src/lib/managers/ViewTransitionManager.svelte.ts b/web/src/lib/managers/ViewTransitionManager.svelte.ts index dd50cc33a9..62fecf19ff 100644 --- a/web/src/lib/managers/ViewTransitionManager.svelte.ts +++ b/web/src/lib/managers/ViewTransitionManager.svelte.ts @@ -1,6 +1,12 @@ import { eventManager } from '$lib/managers/event-manager.svelte'; class ViewTransitionManager { + #activeViewTransition = $state(null); + + get activeViewTransition() { + return this.#activeViewTransition; + } + startTransition(domUpdateComplete: Promise, finishedCallback?: () => void) { // good time to add view-transition-name styles (if needed) eventManager.emit('BeforeStartViewTransition'); @@ -16,6 +22,7 @@ class ViewTransitionManager { console.log('exception', error); } }); + this.#activeViewTransition = transition; // UpdateCallbackDone is a good time to add any view-transition-name styles // to the new DOM state, before the 'new' view snapshot is creatd // eslint-disable-next-line tscompat/tscompat @@ -34,7 +41,10 @@ class ViewTransitionManager { .then(() => eventManager.emit('Finished')) .catch((error: unknown) => console.log('exception in finished', error)); // eslint-disable-next-line tscompat/tscompat - void transition.finished.then(() => finishedCallback?.()); + void transition.finished.then(() => { + finishedCallback?.(); + this.#activeViewTransition = null; + }); } } diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 285a8de491..29cc3d24d9 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -199,10 +199,13 @@ export const getAssetUrl = ({ sharedLink, forceOriginal = false, }: { - asset: AssetResponseDto; + asset: AssetResponseDto | undefined | null; sharedLink?: SharedLinkResponseDto; forceOriginal?: boolean; }) => { + if (!asset) { + return null; + } const id = asset.id; const cacheKey = asset.thumbhash; if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {