diff --git a/web/src/lib/actions/focus-trap.ts b/web/src/lib/actions/focus-trap.ts index 1a84f21729..1564dd90d0 100644 --- a/web/src/lib/actions/focus-trap.ts +++ b/web/src/lib/actions/focus-trap.ts @@ -1,4 +1,5 @@ import { shortcuts } from '$lib/actions/shortcut'; +import { getFocusable } from '$lib/utils/focus-util'; import { tick } from 'svelte'; interface Options { @@ -8,9 +9,6 @@ interface Options { active?: boolean; } -const selectors = - 'button:not([disabled], .hidden), [href]:not(.hidden), input:not([disabled], .hidden), select:not([disabled], .hidden), textarea:not([disabled], .hidden), [tabindex]:not([tabindex="-1"], .hidden)'; - export function focusTrap(container: HTMLElement, options?: Options) { const triggerElement = document.activeElement; @@ -21,7 +19,7 @@ export function focusTrap(container: HTMLElement, options?: Options) { }; const setInitialFocus = () => { - const focusableElement = container.querySelector(selectors); + const focusableElement = getFocusable(container)[0]; // Use tick() to ensure focus trap works correctly inside void tick().then(() => focusableElement?.focus()); }; @@ -30,11 +28,11 @@ export function focusTrap(container: HTMLElement, options?: Options) { setInitialFocus(); } - const getFocusableElements = (): [HTMLElement | null, HTMLElement | null] => { - const focusableElements = container.querySelectorAll(selectors); + const getFocusableElements = () => { + const focusableElements = getFocusable(container); return [ - focusableElements.item(0), // - focusableElements.item(focusableElements.length - 1), + focusableElements.at(0), // + focusableElements.at(-1), ]; }; diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index eec55ec396..91461d574d 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -595,7 +595,7 @@ id="stack-slideshow" class="z-[1002] flex place-item-center place-content-center absolute bottom-0 w-full col-span-4 col-start-1 overflow-x-auto horizontal-scrollbar" > -
+
{#each stackedAssets as stackedAsset (stackedAsset.id)}
{ diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 6bc67a5257..d3a9da3633 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -184,7 +184,9 @@ ]} /> {#if imageError} - +
+ +
{/if} diff --git a/web/src/lib/components/assets/broken-asset.svelte b/web/src/lib/components/assets/broken-asset.svelte index 31acb832e5..9ba6f24f9a 100644 --- a/web/src/lib/components/assets/broken-asset.svelte +++ b/web/src/lib/components/assets/broken-asset.svelte @@ -14,7 +14,7 @@
diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index 4f4390b23d..55357abbc0 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -21,7 +21,8 @@ border?: boolean; hiddenIconClass?: string; class?: ClassValue; - onComplete?: (() => void) | undefined; + brokenAssetClass?: ClassValue; + onComplete?: ((errored: boolean) => void) | undefined; } let { @@ -39,6 +40,7 @@ hiddenIconClass = 'text-white', onComplete = undefined, class: imageClass = '', + brokenAssetClass = '', }: Props = $props(); let { @@ -50,17 +52,17 @@ const setLoaded = () => { loaded = true; - onComplete?.(); + onComplete?.(false); }; const setErrored = () => { errored = true; - onComplete?.(); + onComplete?.(true); }; function mount(elem: HTMLImageElement) { if (elem.complete) { loaded = true; - onComplete?.(); + onComplete?.(false); } } @@ -71,6 +73,7 @@ shadow && 'shadow-lg', (circle || !heightStyle) && 'aspect-square', border && 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary', + brokenAssetClass, ] .filter(Boolean) .join(' '), diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 15d68da93b..db4077cbcf 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -25,6 +25,7 @@ import ImageThumbnail from './image-thumbnail.svelte'; import VideoThumbnail from './video-thumbnail.svelte'; import { onMount } from 'svelte'; + import { getFocusable } from '$lib/utils/focus-util'; interface Props { asset: AssetResponseDto; @@ -41,6 +42,7 @@ showArchiveIcon?: boolean; showStackedIcon?: boolean; imageClass?: ClassValue; + brokenAssetClass?: ClassValue; dimmed?: boolean; onClick?: ((asset: AssetResponseDto) => void) | undefined; onSelect?: ((asset: AssetResponseDto) => void) | undefined; @@ -67,6 +69,7 @@ onMouseEvent = undefined, handleFocus = undefined, imageClass = '', + brokenAssetClass = '', dimmed = false, }: Props = $props(); @@ -78,6 +81,7 @@ let focussableElement: HTMLElement | undefined = $state(); let mouseOver = $state(false); let loaded = $state(false); + let thumbError = $state(false); $effect(() => { if (focussed && document.activeElement !== focussableElement) { @@ -189,10 +193,10 @@ style:width="{width}px" style:height="{height}px" > - {#if !loaded && asset.thumbhash} + {#if (!loaded || thumbError) && asset.thumbhash} (loaded = true)} + onComplete={(errored) => ((loaded = true), (thumbError = errored))} /> {#if asset.type === AssetTypeEnum.Video}
diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index 584f2596ad..3457d87fdf 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -78,12 +78,18 @@ let scrubBucketPercent = $state(0); let scrubBucket: { bucketDate: string | undefined } | undefined = $state(); let scrubOverallPercent: number = $state(0); + let scrubberWidth = $state(0); // 60 is the bottom spacer element at 60px let bottomSectionHeight = 60; let leadout = $state(false); const usingMobileDevice = $derived(mobileDevice.pointerCoarse); + const maxMd = $derived(mobileDevice.maxMd); + + $effect(() => { + assetStore.rowHeight = maxMd ? 100 : 235; + }); const scrollTo = (top: number) => { element?.scrollTo({ top }); @@ -273,10 +279,21 @@ bucket = assetStore.buckets[i]; bucketHeight = assetStore.buckets[i].bucketHeight; } + let next = top - bucketHeight * maxScrollPercent; - if (next < 0 && bucket) { + // instead of checking for < 0, add a little wiggle room for subpixel resolution + if (next < -1 && bucket) { scrubBucket = bucket; - scrubBucketPercent = top / (bucketHeight * maxScrollPercent); + + // allowing next to be at least 1 may cause percent to go negative, so ensure positive percentage + scrubBucketPercent = Math.max(0, top / (bucketHeight * maxScrollPercent)); + + // compensate for lost precision/rouding errors advance to the next bucket, if present + if (scrubBucketPercent > 0.9999 && i + 1 < bucketsLength - 1) { + scrubBucket = assetStore.buckets[i + 1]; + scrubBucketPercent = 0; + } + found = true; break; } @@ -704,6 +721,7 @@ {scrubBucketPercent} {scrubBucket} {onScrub} + bind:scrubberWidth onScrubKeyDown={(evt) => { evt.preventDefault(); let amount = 50; @@ -725,15 +743,11 @@
((assetStore.viewportWidth = v), updateSlidingWindow())} + bind:clientWidth={null, (v) => ((assetStore.viewportWidth = v - scrubberWidth), updateSlidingWindow())} bind:this={element} onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())} > @@ -767,7 +781,6 @@ style:position="absolute" style:transform={`translate3d(0,${absoluteHeight}px,0)`} style:width="100%" - style:padding-left="10px" >
@@ -794,6 +807,7 @@
{/if} {/each} +
{title}
-
+