Fix disappearing bucket, longpress, dynamic viewport sizes

This commit is contained in:
Min Idzelis 2025-04-10 01:30:00 +00:00
parent d45a4a7386
commit e33995d356
22 changed files with 256 additions and 125 deletions

View File

@ -99,7 +99,7 @@
</header> </header>
<main <main
class="relative h-screen overflow-hidden bg-immich-bg px-6 max-md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)] dark:bg-immich-dark-bg" class="relative h-dvh overflow-hidden bg-immich-bg px-6 max-md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)] dark:bg-immich-dark-bg"
> >
<AssetGrid enableRouting={true} {album} {assetStore} {assetInteraction}> <AssetGrid enableRouting={true} {album} {assetStore} {assetInteraction}>
<section class="pt-8 md:pt-24"> <section class="pt-8 md:pt-24">

View File

@ -24,6 +24,7 @@
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import ImageThumbnail from './image-thumbnail.svelte'; import ImageThumbnail from './image-thumbnail.svelte';
import VideoThumbnail from './video-thumbnail.svelte'; import VideoThumbnail from './video-thumbnail.svelte';
import { onMount } from 'svelte';
interface Props { interface Props {
asset: AssetResponseDto; asset: AssetResponseDto;
@ -124,24 +125,59 @@
mouseOver = false; mouseOver = false;
}; };
let timer: ReturnType<typeof setTimeout>;
const clearLongPressTimer = () => clearTimeout(timer);
let startX: number = 0;
let startY: number = 0;
function longPress(element: HTMLElement, { onLongPress }: { onLongPress: () => void }) { function longPress(element: HTMLElement, { onLongPress }: { onLongPress: () => void }) {
let timer: ReturnType<typeof setTimeout>; let didPress = false;
const start = (event: TouchEvent) => { const start = (evt: TouchEvent) => {
startX = evt.changedTouches[0].clientX;
startY = evt.changedTouches[0].clientY;
didPress = false;
timer = setTimeout(() => { timer = setTimeout(() => {
onLongPress(); onLongPress();
event.preventDefault(); didPress = true;
}, 350); }, 350);
}; };
const end = () => clearTimeout(timer); const click = (e: MouseEvent) => {
element.addEventListener('touchstart', start); if (!didPress) {
element.addEventListener('touchend', end); return;
}
e.stopPropagation();
e.preventDefault();
};
element.addEventListener('click', click);
element.addEventListener('touchstart', start, true);
element.addEventListener('touchend', clearLongPressTimer, true);
return { return {
destroy: () => { destroy: () => {
element.removeEventListener('touchstart', start); element.removeEventListener('click', click);
element.removeEventListener('touchend', end); element.removeEventListener('touchstart', start, true);
element.removeEventListener('touchend', clearLongPressTimer, true);
}, },
}; };
} }
function moveHandler(e: PointerEvent) {
var diffX = Math.abs(startX - e.clientX);
var diffY = Math.abs(startY - e.clientY);
if (diffX >= 10 || diffY >= 10) {
clearLongPressTimer();
}
}
onMount(() => {
document.addEventListener('scroll', clearLongPressTimer, true);
document.addEventListener('wheel', clearLongPressTimer, true);
document.addEventListener('contextmenu', clearLongPressTimer, true);
document.addEventListener('pointermove', moveHandler, true);
return () => {
document.removeEventListener('scroll', clearLongPressTimer, true);
document.removeEventListener('wheel', clearLongPressTimer, true);
document.removeEventListener('contextmenu', clearLongPressTimer, true);
document.removeEventListener('pointermove', moveHandler, true);
};
});
</script> </script>
<div <div

View File

@ -9,7 +9,7 @@
let { title, children }: Props = $props(); let { title, children }: Props = $props();
</script> </script>
<section class="min-w-screen flex min-h-dvh items-center justify-center relative"> <section class="min-w-dvw flex min-h-dvh items-center justify-center relative">
<div class="absolute -z-10 w-full h-full flex place-items-center place-content-center"> <div class="absolute -z-10 w-full h-full flex place-items-center place-content-center">
<img <img
src={immichLogo} src={immichLogo}

View File

@ -21,7 +21,7 @@
}; };
</script> </script>
<div class="h-screen w-screen"> <div class="h-dvh w-dvw">
<section class="bg-immich-bg dark:bg-immich-dark-bg"> <section class="bg-immich-bg dark:bg-immich-dark-bg">
<div class="flex place-items-center border-b px-6 py-4 dark:border-b-immich-dark-gray"> <div class="flex place-items-center border-b px-6 py-4 dark:border-b-immich-dark-gray">
<a class="flex place-items-center gap-2 hover:cursor-pointer" href="/photos"> <a class="flex place-items-center gap-2 hover:cursor-pointer" href="/photos">

View File

@ -51,7 +51,7 @@
</header> </header>
<main <main
tabindex="-1" tabindex="-1"
class="relative grid h-screen grid-cols-[theme(spacing.0)_auto] overflow-hidden bg-immich-bg max-md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)] dark:bg-immich-dark-bg md:grid-cols-[theme(spacing.64)_auto]" class="relative grid h-dvh grid-cols-[theme(spacing.0)_auto] overflow-hidden bg-immich-bg max-md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)] dark:bg-immich-dark-bg md:grid-cols-[theme(spacing.64)_auto]"
> >
{#if sidebar}{@render sidebar()}{:else if admin} {#if sidebar}{@render sidebar()}{:else if admin}
<AdminSideBar /> <AdminSideBar />

View File

@ -277,7 +277,7 @@
bucketHeight = assetStore.buckets[i].bucketHeight; bucketHeight = assetStore.buckets[i].bucketHeight;
} }
let next = top - bucketHeight * maxScrollPercent; let next = top - bucketHeight * maxScrollPercent;
if (next < 0) { if (next < 0 && bucket) {
scrubBucket = bucket; scrubBucket = bucket;
scrubBucketPercent = top / (bucketHeight * maxScrollPercent); scrubBucketPercent = top / (bucketHeight * maxScrollPercent);
found = true; found = true;
@ -698,7 +698,6 @@
{#if assetStore.buckets.length > 0} {#if assetStore.buckets.length > 0}
<Scrubber <Scrubber
invisible={showSkeleton}
{assetStore} {assetStore}
height={assetStore.viewportHeight} height={assetStore.viewportHeight}
timelineTopOffset={assetStore.topSectionHeight} timelineTopOffset={assetStore.topSectionHeight}
@ -771,8 +770,9 @@
style:position="absolute" style:position="absolute"
style:transform={`translate3d(0,${absoluteHeight}px,0)`} style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:width="100%" style:width="100%"
style:padding-left="10px"
> >
<Skeleton height={bucket.bucketHeight} title={bucket.bucketDateFormatted} /> <Skeleton height={bucket.bucketHeight - bucket.store.headerHeight} title={bucket.bucketDateFormatted} />
</div> </div>
{:else if display} {:else if display}
<div <div
@ -797,7 +797,13 @@
</div> </div>
{/if} {/if}
{/each} {/each}
<!-- <div class="h-[60px]" style:position="absolute" style:left="0" style:right="0" style:bottom="0"></div> --> <div
class="h-[60px]"
style:position="absolute"
style:left="0"
style:right="0"
style:transform={`translate3d(0,${assetStore.timelineHeight}px,0)`}
></div>
</section> </section>
</section> </section>

View File

@ -22,6 +22,11 @@
background-repeat: repeat; background-repeat: repeat;
background-size: 235px, 235px; background-size: 235px, 235px;
} }
@media (max-width: 850px) {
[data-skeleton] {
background-size: 100px, 100px;
}
}
:global(.dark) [data-skeleton] { :global(.dark) [data-skeleton] {
background-image: url('/dark_skeleton.png'); background-image: url('/dark_skeleton.png');
} }

View File

@ -66,7 +66,7 @@
aria-labelledby={ariaLabelledBy} aria-labelledby={ariaLabelledBy}
bind:this={menuElement} bind:this={menuElement}
class="{isVisible class="{isVisible
? 'max-h-screen max-h-svh' ? 'max-h-dvh max-h-svh'
: 'max-h-0'} flex flex-col transition-all duration-[250ms] ease-in-out outline-none overflow-auto" : 'max-h-0'} flex flex-col transition-all duration-[250ms] ease-in-out outline-none overflow-auto"
role="menu" role="menu"
tabindex="-1" tabindex="-1"

View File

@ -91,7 +91,7 @@
}, },
]} ]}
> >
<section class="fixed left-0 top-0 z-10 flex h-screen w-screen" {oncontextmenu} role="presentation"> <section class="fixed left-0 top-0 z-10 flex h-dvh w-dvw" {oncontextmenu} role="presentation">
<ContextMenu <ContextMenu
{direction} {direction}
{x} {x}

View File

@ -77,7 +77,7 @@
role="presentation" role="presentation"
in:fade={{ duration: 100 }} in:fade={{ duration: 100 }}
out:fade={{ duration: 100 }} out:fade={{ duration: 100 }}
class="fixed left-0 top-0 z-[9999] flex h-dvh w-screen place-content-center place-items-center bg-black/40" class="fixed left-0 top-0 z-[9999] flex h-dvh w-dvw place-content-center place-items-center bg-black/40"
onkeydown={(event) => { onkeydown={(event) => {
event.stopPropagation(); event.stopPropagation();
}} }}

View File

@ -58,7 +58,7 @@
<section <section
id="dashboard-navbar" id="dashboard-navbar"
class="fixed z-[900] max-md:h-[var(--navbar-height-md)] h-[var(--navbar-height)] w-screen text-sm" class="fixed z-[900] max-md:h-[var(--navbar-height-md)] h-[var(--navbar-height)] w-dvw text-sm"
> >
<SkipLink text={$t('skip_to_content')} /> <SkipLink text={$t('skip_to_content')} />
<div <div

View File

@ -26,7 +26,7 @@
</script> </script>
{#if showing} {#if showing}
<div class="absolute left-0 top-0 z-[999999999] h-[3px] w-screen bg-white"> <div class="absolute left-0 top-0 z-[999999999] h-[3px] w-dvw bg-white">
<span class="absolute h-[3px] bg-immich-primary" style:width={`${$progress}%`}></span> <span class="absolute h-[3px] bg-immich-primary" style:width={`${$progress}%`}></span>
</div> </div>
{/if} {/if}

View File

@ -41,15 +41,33 @@
let isHover = $state(false); let isHover = $state(false);
let isDragging = $state(false); let isDragging = $state(false);
let isHoverOnPaddingTop = $state(false);
let isHoverOnPaddingBottom = $state(false);
let hoverY = $state(0); let hoverY = $state(0);
let clientY = 0; let clientY = 0;
let windowHeight = $state(0); let windowHeight = $state(0);
let scrollBar: HTMLElement | undefined = $state(); let scrollBar: HTMLElement | undefined = $state();
const toScrollY = (percent: number) => percent * (height - HOVER_DATE_HEIGHT * 2); const toScrollY = (percent: number) => percent * (height - (PADDING_TOP + PADDING_BOTTOM));
const toTimelineY = (scrollY: number) => scrollY / (height - HOVER_DATE_HEIGHT * 2); const toTimelineY = (scrollY: number) => scrollY / (height - (PADDING_TOP + PADDING_BOTTOM));
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
const width = $derived.by(() => {
if (isDragging) {
return '100vw';
}
if (usingMobileDevice) {
if (assetStore.scrolling) {
return '20px';
}
return '0px';
}
return '60px';
});
const HOVER_DATE_HEIGHT = 31.75; const HOVER_DATE_HEIGHT = 31.75;
const PADDING_TOP = $derived(usingMobileDevice ? 25 : HOVER_DATE_HEIGHT);
const PADDING_BOTTOM = $derived(usingMobileDevice ? 25 : 10);
const MIN_YEAR_LABEL_DISTANCE = 16; const MIN_YEAR_LABEL_DISTANCE = 16;
const MIN_DOT_DISTANCE = 8; const MIN_DOT_DISTANCE = 8;
@ -83,7 +101,7 @@
return offset - 2; return offset - 2;
} else { } else {
// 2px is the height of the indicator // 2px is the height of the indicator
return scrubOverallPercent * (height - HOVER_DATE_HEIGHT * 2) - 2; return scrubOverallPercent * (height - (PADDING_TOP + PADDING_BOTTOM)) - 2;
} }
}; };
let scrollY = $derived(toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent)); let scrollY = $derived(toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent));
@ -146,7 +164,15 @@
}; };
let activeSegment: HTMLElement | undefined = $state(); let activeSegment: HTMLElement | undefined = $state();
const segments = $derived(calculateSegments(assetStore.scrubberBuckets)); const segments = $derived(calculateSegments(assetStore.scrubberBuckets));
const hoverLabel = $derived(activeSegment?.dataset.label); const hoverLabel = $derived.by(() => {
if (isHoverOnPaddingTop) {
return segments.at(0)?.dateFormatted;
}
if (isHoverOnPaddingBottom) {
return segments.at(-1)?.dateFormatted;
}
return activeSegment?.dataset.label;
});
const bucketDate = $derived(activeSegment?.dataset.timeSegmentBucketDate); const bucketDate = $derived(activeSegment?.dataset.timeSegmentBucketDate);
const scrollHoverLabel = $derived.by(() => { const scrollHoverLabel = $derived.by(() => {
const y = scrollY; const y = scrollY;
@ -160,6 +186,62 @@
return ''; return '';
}); });
const findElement = (elements: Element[], ...ids: string[]) => {
if (ids.length === 0) {
return undefined;
}
const result = elements.find((element) => {
if (element instanceof HTMLElement && element.dataset.id) {
return ids.includes(element.dataset.id);
}
return false;
});
return result as HTMLElement | undefined;
};
const getActive = (x: number, y: number) => {
const elements = document.elementsFromPoint(x, y);
const element = findElement(elements, 'time-segment', 'lead-in', 'lead-out');
if (element) {
const segment = element as HTMLElement;
const sr = segment.getBoundingClientRect();
const sy = sr.y;
const relativeY = y - sy;
const bucketPercentY = relativeY / sr.height;
return {
isOnPaddingTop: false,
isOnPaddingBottom: false,
segment,
bucketPercentY,
};
}
// check if padding
const bar = findElement(elements, 'immich-scrubbable-scrollbar');
let isOnPaddingTop = false;
let isOnPaddingBottom = false;
if (bar) {
const segment = bar as HTMLElement;
const sr = segment.getBoundingClientRect();
if (y < sr.top + PADDING_TOP) {
isOnPaddingTop = true;
}
if (y > sr.bottom - PADDING_BOTTOM - 1) {
isOnPaddingBottom = true;
}
}
return {
isOnPaddingTop,
isOnPaddingBottom,
segment: undefined,
bucketPercentY: 0,
};
};
const handleMouseEvent = (event: { clientY: number; isDragging?: boolean }) => { const handleMouseEvent = (event: { clientY: number; isDragging?: boolean }) => {
const wasDragging = isDragging; const wasDragging = isDragging;
@ -172,28 +254,14 @@
const rect = scrollBar.getBoundingClientRect()!; const rect = scrollBar.getBoundingClientRect()!;
const lower = 0; const lower = 0;
const upper = rect?.height - HOVER_DATE_HEIGHT * 2; const upper = rect?.height - (PADDING_TOP + PADDING_BOTTOM);
hoverY = clamp(clientY - rect?.top - HOVER_DATE_HEIGHT, lower, upper); hoverY = clamp(clientY - rect?.top - PADDING_TOP, lower, upper);
const x = rect!.left + rect!.width / 2; const x = rect!.left + rect!.width / 2;
const elems = document.elementsFromPoint(x, clientY); // console.log('hoverY is', hoverY, clientY);
const segment = elems.find(({ id }) => id === 'time-segment'); const { segment, bucketPercentY, isOnPaddingTop, isOnPaddingBottom } = getActive(x, clientY);
let bucketPercentY = 0; activeSegment = segment;
if (segment) { isHoverOnPaddingTop = isOnPaddingTop;
activeSegment = segment as HTMLElement; isHoverOnPaddingBottom = isOnPaddingBottom;
const sr = segment.getBoundingClientRect();
const sy = sr.y;
const relativeY = clientY - sy;
bucketPercentY = relativeY / sr.height;
} else {
const leadin = elems.find(({ id }) => id === 'lead-in');
if (leadin) {
activeSegment = leadin as HTMLElement;
} else {
activeSegment = undefined;
bucketPercentY = 0;
}
}
const scrollPercent = toTimelineY(hoverY); const scrollPercent = toTimelineY(hoverY);
if (wasDragging === false && isDragging) { if (wasDragging === false && isDragging) {
@ -225,9 +293,8 @@
return; return;
} }
const elements = document.elementsFromPoint(touch.clientX, touch.clientY); const elements = document.elementsFromPoint(touch.clientX, touch.clientY);
const isHoverScrollbar = elements.some(({ id }) => { const isHoverScrollbar =
return id === 'immich-scrubbable-scrollbar' || id === 'time-label'; findElement(elements, 'immich-scrubbable-scrollbar', 'time-label', 'lead-in', 'lead-out') !== undefined;
});
isHover = isHoverScrollbar; isHover = isHoverScrollbar;
@ -253,22 +320,25 @@
handleMouseEvent({ handleMouseEvent({
clientY: touch.clientY, clientY: touch.clientY,
}); });
event.preventDefault();
} else { } else {
isHover = false; isHover = false;
} }
}; };
onMount(() => { onMount(() => {
const opts = { document.addEventListener('touchmove', onTouchMove, true);
passive: false,
};
globalThis.addEventListener('touchmove', onTouchMove, opts);
return () => { return () => {
globalThis.removeEventListener('touchmove', onTouchMove); document.removeEventListener('touchmove', onTouchMove);
};
});
onMount(() => {
document.addEventListener('touchstart', onTouchStart, true);
document.addEventListener('touchend', onTouchEnd, true);
return () => {
document.addEventListener('touchstart', onTouchStart, true);
document.addEventListener('touchend', onTouchEnd, true);
}; };
}); });
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
const width = $derived(isDragging ? '100vw' : usingMobileDevice ? '20px' : '60px');
</script> </script>
<svelte:window <svelte:window
@ -276,9 +346,6 @@
onmousemove={({ clientY }) => (isDragging || isHover) && handleMouseEvent({ clientY })} onmousemove={({ clientY }) => (isDragging || isHover) && handleMouseEvent({ clientY })}
onmousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })} onmousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })}
onmouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })} onmouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })}
ontouchstart={onTouchStart}
ontouchend={onTouchEnd}
ontouchcancel={onTouchEnd}
/> />
<div <div
@ -286,13 +353,13 @@
tabindex="-1" tabindex="-1"
role="scrollbar" role="scrollbar"
aria-controls="time-label" aria-controls="time-label"
aria-valuenow={scrollY + HOVER_DATE_HEIGHT} aria-valuenow={scrollY + PADDING_TOP}
aria-valuemax={toScrollY(100)} aria-valuemax={toScrollY(100)}
aria-valuemin={toScrollY(0)} aria-valuemin={toScrollY(0)}
id="immich-scrubbable-scrollbar" data-id="immich-scrubbable-scrollbar"
class="absolute right-0 z-[1] select-none bg-immich-bg hover:cursor-row-resize" class="absolute right-0 z-[1] select-none bg-immich-bg hover:cursor-row-resize"
style:padding-top={HOVER_DATE_HEIGHT + 'px'} style:padding-top={PADDING_TOP + 'px'}
style:padding-bottom={HOVER_DATE_HEIGHT + 'px'} style:padding-bottom={PADDING_BOTTOM + 'px'}
style:width style:width
style:height={height + 'px'} style:height={height + 'px'}
style:background-color={isDragging ? 'transparent' : 'transparent'} style:background-color={isDragging ? 'transparent' : 'transparent'}
@ -319,7 +386,7 @@
<div <div
id="time-label" id="time-label"
class="rounded-l-full w-[32px] pl-2 text-white bg-immich-primary dark:bg-gray-600 hover:cursor-pointer select-none" class="rounded-l-full w-[32px] pl-2 text-white bg-immich-primary dark:bg-gray-600 hover:cursor-pointer select-none"
style:top="{scrollY + HOVER_DATE_HEIGHT - 25}px" style:top="{PADDING_TOP + (scrollY - 50 / 2)}px"
style:height="50px" style:height="50px"
style:right="0" style:right="0"
style:position="absolute" style:position="absolute"
@ -345,9 +412,9 @@
{#if !usingMobileDevice && !isDragging} {#if !usingMobileDevice && !isDragging}
<div <div
class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary" class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary"
style:top="{scrollY + HOVER_DATE_HEIGHT}px" style:top="{scrollY + PADDING_TOP}px"
> >
{#if assetStore.scrolling && scrollHoverLabel} {#if assetStore.scrolling && scrollHoverLabel && !isHover}
<p <p
transition:fade={{ duration: 200 }} transition:fade={{ duration: 200 }}
class="truncate pointer-events-none absolute right-0 bottom-0 z-[100] min-w-20 max-w-64 w-fit rounded-tl-md border-b-2 border-immich-primary bg-immich-bg/80 py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:bg-immich-dark-gray/80 dark:text-immich-dark-fg" class="truncate pointer-events-none absolute right-0 bottom-0 z-[100] min-w-20 max-w-64 w-fit rounded-tl-md border-b-2 border-immich-primary bg-immich-bg/80 py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:bg-immich-dark-gray/80 dark:text-immich-dark-fg"
@ -357,7 +424,13 @@
{/if} {/if}
</div> </div>
{/if} {/if}
<div id="lead-in" class="relative" style:height={relativeTopOffset + 'px'} data-label={segments.at(0)?.dateFormatted}> <div
class="relative z-10"
style:height={relativeTopOffset + 'px'}
data-id="lead-in"
data-time-segment-bucket-date={segments.at(0)?.date}
data-label={segments.at(0)?.dateFormatted}
>
{#if relativeTopOffset > 6} {#if relativeTopOffset > 6}
<div class="absolute right-[0.75rem] h-[4px] w-[4px] rounded-full bg-gray-300"></div> <div class="absolute right-[0.75rem] h-[4px] w-[4px] rounded-full bg-gray-300"></div>
{/if} {/if}
@ -365,28 +438,26 @@
<!-- Time Segment --> <!-- Time Segment -->
{#each segments as segment (segment.date)} {#each segments as segment (segment.date)}
<div <div
id="time-segment"
class="relative" class="relative"
data-id="time-segment"
data-time-segment-bucket-date={segment.date} data-time-segment-bucket-date={segment.date}
data-label={segment.dateFormatted} data-label={segment.dateFormatted}
style:height={segment.height + 'px'} style:height={segment.height + 'px'}
> >
{#if !usingMobileDevice && segment.hasLabel} {#if !usingMobileDevice}
<div class="absolute right-[1.25rem] top-[-16px] z-10 text-[12px] dark:text-immich-dark-fg font-immich-mono"> {#if segment.hasLabel}
{segment.date.year} <div class="absolute right-[1.25rem] top-[-16px] z-10 text-[12px] dark:text-immich-dark-fg font-immich-mono">
</div> {segment.date.year}
{/if} </div>
{#if !usingMobileDevice && segment.hasDot} {/if}
<div class="absolute right-[0.75rem] bottom-0 h-[4px] w-[4px] rounded-full bg-gray-300"></div> {#if segment.hasDot}
<div class="absolute right-[0.75rem] bottom-0 h-[4px] w-[4px] rounded-full bg-gray-300"></div>
{/if}
{/if} {/if}
</div> </div>
{/each} {/each}
<div id="lead-out" class="relative" style:height={relativeBottomOffset + 'px'}></div> <div data-id="lead-out" class="relative" style:height={relativeBottomOffset + 'px'}></div>
</div> </div>
<style> <style>
#immich-scrubbable-scrollbar,
#time-segment {
contain: layout size style;
}
</style> </style>

View File

@ -30,10 +30,6 @@ const {
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM }, TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
} = TUNABLES; } = TUNABLES;
const THUMBNAIL_HEIGHT = 235;
const GAP = 12;
const HEADER = 49; //(1.5rem)
type AssetApiGetTimeBucketsRequest = Parameters<typeof getTimeBuckets>[0]; type AssetApiGetTimeBucketsRequest = Parameters<typeof getTimeBuckets>[0];
export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'> & { export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'> & {
timelineAlbumId?: string; timelineAlbumId?: string;
@ -83,8 +79,8 @@ class IntersectingAsset {
} }
const store = this.#group.bucket.store; const store = this.#group.bucket.store;
const topWindow = store.visibleWindow.top - HEADER - INTERSECTION_EXPAND_TOP; const topWindow = store.visibleWindow.top - store.headerHeight - INTERSECTION_EXPAND_TOP;
const bottomWindow = store.visibleWindow.bottom + HEADER + INTERSECTION_EXPAND_BOTTOM; const bottomWindow = store.visibleWindow.bottom + store.headerHeight + INTERSECTION_EXPAND_BOTTOM;
const positionTop = this.#group.absoluteDateGroupTop + this.position.top; const positionTop = this.#group.absoluteDateGroupTop + this.position.top;
const positionBottom = positionTop + this.position.height; const positionBottom = positionTop + this.position.height;
@ -97,7 +93,7 @@ class IntersectingAsset {
position: CommonPosition | undefined = $state(); position: CommonPosition | undefined = $state();
asset: AssetResponseDto | undefined = $state(); asset: AssetResponseDto | undefined = $state();
id: string = $derived.by(() => this.asset!.id); id: string | undefined = $derived(this.asset?.id);
constructor(group: AssetDateGroup, asset: AssetResponseDto) { constructor(group: AssetDateGroup, asset: AssetResponseDto) {
this.#group = group; this.#group = group;
@ -244,6 +240,7 @@ export class AssetBucket {
*/ */
#bucketHeight: number = $state(0); #bucketHeight: number = $state(0);
#top: number = $state(0); #top: number = $state(0);
#initialCount: number = 0; #initialCount: number = 0;
#sortOrder: AssetOrder = AssetOrder.Desc; #sortOrder: AssetOrder = AssetOrder.Desc;
percent: number = $state(0); percent: number = $state(0);
@ -284,6 +281,7 @@ export class AssetBucket {
this.isLoaded = true; this.isLoaded = true;
}, },
() => { () => {
this.dateGroups = [];
this.isLoaded = false; this.isLoaded = false;
}, },
this.handleLoadError, this.handleLoadError,
@ -449,19 +447,21 @@ export class AssetBucket {
const { store, percent } = this; const { store, percent } = this;
const index = store.buckets.indexOf(this); const index = store.buckets.indexOf(this);
const bucketHeightDelta = height - this.#bucketHeight; const bucketHeightDelta = height - this.#bucketHeight;
this.#bucketHeight = height;
const prevBucket = store.buckets[index - 1]; const prevBucket = store.buckets[index - 1];
if (prevBucket) { if (prevBucket) {
this.#top = prevBucket.#top + prevBucket.#bucketHeight; const newTop = prevBucket.#top + prevBucket.#bucketHeight;
} if (this.#top !== newTop) {
if (bucketHeightDelta) { this.#top = newTop;
let cursor = index + 1; }
while (cursor < store.buckets.length) { }
const nextBucket = this.store.buckets[cursor]; for (let cursor = index + 1; cursor < store.buckets.length; cursor++) {
nextBucket.#top += bucketHeightDelta; const bucket = this.store.buckets[cursor];
cursor++; const newTop = bucket.#top + bucketHeightDelta;
if (bucket.#top !== newTop) {
bucket.#top = newTop;
} }
} }
this.#bucketHeight = height;
if (store.topIntersectingBucket) { if (store.topIntersectingBucket) {
const currentIndex = store.buckets.indexOf(store.topIntersectingBucket); const currentIndex = store.buckets.indexOf(store.topIntersectingBucket);
// if the bucket is 'before' the last intersecting bucket in the sliding window // if the bucket is 'before' the last intersecting bucket in the sliding window
@ -470,9 +470,8 @@ export class AssetBucket {
if (currentIndex > 0) { if (currentIndex > 0) {
if (index < currentIndex) { if (index < currentIndex) {
store.compensateScrollCallback?.({ delta: bucketHeightDelta }); store.compensateScrollCallback?.({ delta: bucketHeightDelta });
} else if (currentIndex == currentIndex) { } else if (currentIndex == currentIndex && percent > 0) {
this.store.updateIntersections(); const top = this.top + height * percent;
const top = this.#top + height * percent;
store.compensateScrollCallback?.({ top }); store.compensateScrollCallback?.({ top });
} }
} }
@ -482,10 +481,7 @@ export class AssetBucket {
return this.#bucketHeight; return this.#bucketHeight;
} }
set top(top: number) { get top(): number {
this.#top = top;
}
get top() {
return this.#top + this.store.topSectionHeight; return this.#top + this.store.topSectionHeight;
} }
@ -502,7 +498,7 @@ export class AssetBucket {
for (const group of this.dateGroups) { for (const group of this.dateGroups) {
const intersectingAsset = group.intersetingAssets.find((asset) => asset.id === assetId); const intersectingAsset = group.intersetingAssets.find((asset) => asset.id === assetId);
if (intersectingAsset) { if (intersectingAsset) {
return this.top + group.top + intersectingAsset.position!.top + HEADER; return this.top + group.top + intersectingAsset.position!.top + this.store.headerHeight;
} }
} }
return -1; return -1;
@ -593,13 +589,16 @@ export class AssetStore {
// --- private // --- private
static #INIT_OPTIONS = {}; static #INIT_OPTIONS = {};
#rowHeight = 235;
#viewportHeight = $state(0); #viewportHeight = $state(0);
#viewportWidth = $state(0); #viewportWidth = $state(0);
#scrollTop = $state(0); #scrollTop = $state(0);
#pendingChanges: PendingChange[] = []; #pendingChanges: PendingChange[] = [];
#unsubscribers: Unsubscriber[] = []; #unsubscribers: Unsubscriber[] = [];
#rowHeight = 235;
#headerHeight = $state(49);
#gap = $state(12);
#options: AssetStoreOptions = AssetStore.#INIT_OPTIONS; #options: AssetStoreOptions = AssetStore.#INIT_OPTIONS;
#scrolling = $state(false); #scrolling = $state(false);
@ -609,6 +608,22 @@ export class AssetStore {
constructor() {} constructor() {}
set headerHeight(value) {
this.#headerHeight = value;
}
get headerHeight() {
return this.#headerHeight;
}
set gap(value) {
this.#gap = value;
}
get gap() {
return this.#gap;
}
set scrolling(value: boolean) { set scrolling(value: boolean) {
this.#scrolling = value; this.#scrolling = value;
if (value) { if (value) {
@ -834,11 +849,6 @@ export class AssetStore {
this.#updateViewportGeometry(false); this.#updateViewportGeometry(false);
} }
updateLayoutOptions(options: AssetStoreLayoutOptions) {
this.#rowHeight = options.rowHeight;
this.refreshLayout();
}
async #init(options: AssetStoreOptions) { async #init(options: AssetStoreOptions) {
// doing the following outside of the task reduces flickr // doing the following outside of the task reduces flickr
this.isInitialized = false; this.isInitialized = false;
@ -924,9 +934,9 @@ export class AssetStore {
// optimize - if bucket already has data, no need to create estimates // optimize - if bucket already has data, no need to create estimates
const viewportWidth = this.viewportWidth; const viewportWidth = this.viewportWidth;
if (!bucket.isBucketHeightActual) { if (!bucket.isBucketHeightActual) {
const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10); const unwrappedWidth = (3 / 2) * bucket.bucketCount * this.#rowHeight * (7 / 10);
const rows = Math.ceil(unwrappedWidth / viewportWidth); const rows = Math.ceil(unwrappedWidth / viewportWidth);
const height = 51 + Math.max(1, rows) * THUMBNAIL_HEIGHT; const height = 51 + Math.max(1, rows) * this.#rowHeight;
bucket.bucketHeight = height; bucket.bucketHeight = height;
} }
return; return;
@ -952,7 +962,7 @@ export class AssetStore {
assetGroup.layout(options); assetGroup.layout(options);
rowSpaceRemaining[dateGroupRow] -= assetGroup.width - 1; rowSpaceRemaining[dateGroupRow] -= assetGroup.width - 1;
if (dateGroupCol > 0) { if (dateGroupCol > 0) {
rowSpaceRemaining[dateGroupRow] -= GAP; rowSpaceRemaining[dateGroupRow] -= this.gap;
} }
if (rowSpaceRemaining[dateGroupRow] >= 0) { if (rowSpaceRemaining[dateGroupRow] >= 0) {
assetGroup.row = dateGroupRow; assetGroup.row = dateGroupRow;
@ -962,7 +972,7 @@ export class AssetStore {
dateGroupCol++; dateGroupCol++;
cummulativeWidth += assetGroup.width + GAP; cummulativeWidth += assetGroup.width + this.gap;
} else { } else {
// starting a new row, we need to update the last col of the previous row // starting a new row, we need to update the last col of the previous row
cummulativeWidth = 0; cummulativeWidth = 0;
@ -976,10 +986,10 @@ export class AssetStore {
dateGroupCol++; dateGroupCol++;
cummulativeHeight += lastRowHeight; cummulativeHeight += lastRowHeight;
assetGroup.top = cummulativeHeight; assetGroup.top = cummulativeHeight;
cummulativeWidth += assetGroup.width + GAP; cummulativeWidth += assetGroup.width + this.gap;
lastRow = assetGroup.row - 1; lastRow = assetGroup.row - 1;
} }
lastRowHeight = assetGroup.height + HEADER; lastRowHeight = assetGroup.height + this.headerHeight;
} }
if (lastRow === 0 || lastRow !== bucket.lastDateGroup?.row) { if (lastRow === 0 || lastRow !== bucket.lastDateGroup?.row) {
cummulativeHeight += lastRowHeight; cummulativeHeight += lastRowHeight;

View File

@ -61,6 +61,9 @@ export class CancellableTask {
try { try {
await f(cancelToken.signal); await f(cancelToken.signal);
if (cancelToken.signal.aborted) {
return 'CANCELED';
}
this.#transitionToExecuted(); this.#transitionToExecuted();
return 'LOADED'; return 'LOADED';
} catch (error) { } catch (error) {

View File

@ -605,7 +605,7 @@
{/if} {/if}
<main <main
class="relative h-screen overflow-hidden bg-immich-bg px-6 max-md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)] dark:bg-immich-dark-bg" class="relative h-dvh overflow-hidden bg-immich-bg px-6 max-md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)] dark:bg-immich-dark-bg"
> >
<AssetGrid <AssetGrid
enableRouting={viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : true} enableRouting={viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : true}

View File

@ -34,7 +34,7 @@
}; };
</script> </script>
<main class="grid h-screen bg-immich-bg pt-18 dark:bg-immich-dark-bg"> <main class="grid h-dvh bg-immich-bg pt-18 dark:bg-immich-dark-bg">
{#if assetInteraction.selectionActive} {#if assetInteraction.selectionActive}
<AssetSelectControlBar <AssetSelectControlBar
assets={assetInteraction.selectedAssets} assets={assetInteraction.selectedAssets}

View File

@ -486,7 +486,7 @@
</header> </header>
<main <main
class="relative h-screen overflow-hidden bg-immich-bg tall:ml-4 md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)] dark:bg-immich-dark-bg" class="relative h-dvh overflow-hidden bg-immich-bg tall:ml-4 md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)] dark:bg-immich-dark-bg"
use:scrollMemoryClearer={{ use:scrollMemoryClearer={{
routeStartsWith: AppRoute.PEOPLE, routeStartsWith: AppRoute.PEOPLE,
beforeClear: () => { beforeClear: () => {

View File

@ -6,7 +6,7 @@
<title>Oops! Error - Immich</title> <title>Oops! Error - Immich</title>
</svelte:head> </svelte:head>
<section class="flex flex-col px-4 h-screen w-screen place-content-center place-items-center"> <section class="flex flex-col px-4 h-dvh w-dvw place-content-center place-items-center">
<h1 class="py-10 text-4xl text-immich-primary dark:text-immich-dark-primary">Page not found :/</h1> <h1 class="py-10 text-4xl text-immich-primary dark:text-immich-dark-primary">Page not found :/</h1>
{#if page.error?.message} {#if page.error?.message}
<h2 class="text-xl text-immich-fg dark:text-immich-dark-fg">{page.error.message}</h2> <h2 class="text-xl text-immich-fg dark:text-immich-dark-fg">{page.error.message}</h2>

View File

@ -70,7 +70,7 @@
</ControlAppBar> </ControlAppBar>
</header> </header>
<main <main
class="relative h-screen overflow-hidden bg-immich-bg px-6 max-md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)] dark:bg-immich-dark-bg sm:px-12 md:px-24 lg:px-40" class="relative h-dvh overflow-hidden bg-immich-bg px-6 max-md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)] dark:bg-immich-dark-bg sm:px-12 md:px-24 lg:px-40"
> >
<div class="flex flex-col items-center justify-center mt-20"> <div class="flex flex-col items-center justify-center mt-20">
<div class="text-2xl font-bold text-immich-primary dark:text-immich-dark-primary">{$t('password_required')}</div> <div class="text-2xl font-bold text-immich-primary dark:text-immich-dark-primary">{$t('password_required')}</div>

View File

@ -4,7 +4,7 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
</script> </script>
<section class="flex h-screen w-screen place-content-center place-items-center"> <section class="flex h-dvh w-dvw place-content-center place-items-center">
<div class="flex max-w-[350px] flex-col place-items-center gap-10 text-center"> <div class="flex max-w-[350px] flex-col place-items-center gap-10 text-center">
<div class="flex place-content-center place-items-center"> <div class="flex place-content-center place-items-center">
<Logo variant="icon" class="text-center" size="landing" /> <Logo variant="icon" class="text-center" size="landing" />

View File

@ -56,7 +56,7 @@
const SvelteComponent = $derived(onboardingSteps[index].component); const SvelteComponent = $derived(onboardingSteps[index].component);
</script> </script>
<section id="onboarding-page" class="min-w-screen flex min-h-screen p-4"> <section id="onboarding-page" class="min-w-dvw flex min-h-dvh p-4">
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
<div class="w-full bg-gray-300 dark:bg-gray-600 rounded-md h-2"> <div class="w-full bg-gray-300 dark:bg-gray-600 rounded-md h-2">
<div <div
@ -64,7 +64,7 @@
style="width: {(index / (onboardingSteps.length - 1)) * 100}%" style="width: {(index / (onboardingSteps.length - 1)) * 100}%"
></div> ></div>
</div> </div>
<div class="w-full min-w-screen py-8 flex h-full place-content-center place-items-center"> <div class="w-full min-w-dvw py-8 flex h-full place-content-center place-items-center">
<SvelteComponent onDone={handleDoneClicked} onPrevious={handlePrevious} /> <SvelteComponent onDone={handleDoneClicked} onPrevious={handlePrevious} />
</div> </div>
</div> </div>