mirror of
https://github.com/immich-app/immich.git
synced 2025-05-31 12:15:47 -04:00
feat: scruabbable timeline POC
This commit is contained in:
parent
4ef7eb56a3
commit
6156464ddd
37
web/src/lib/components/PhotoThumbnail.svelte
Normal file
37
web/src/lib/components/PhotoThumbnail.svelte
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { thumbhash } from '$lib/actions/thumbhash';
|
||||||
|
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||||
|
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||||
|
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||||
|
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
asset: AssetResponseDto;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { asset, width, height }: Props = $props();
|
||||||
|
|
||||||
|
let loaded = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !loaded && asset.thumbhash}
|
||||||
|
<canvas
|
||||||
|
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
|
||||||
|
class="absolute object-cover z-10"
|
||||||
|
style:width="{width}px"
|
||||||
|
style:height="{height}px"
|
||||||
|
out:fade={{ duration: 150 }}
|
||||||
|
></canvas>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<ImageThumbnail
|
||||||
|
url={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
|
||||||
|
altText={$getAltText(asset)}
|
||||||
|
widthStyle="{width}px"
|
||||||
|
heightStyle="{height}px"
|
||||||
|
curve={false}
|
||||||
|
onComplete={() => (loaded = true)}
|
||||||
|
/>
|
249
web/src/lib/components/PhotoTimeline.svelte
Normal file
249
web/src/lib/components/PhotoTimeline.svelte
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import PhotoThumbnail from '$lib/components/PhotoThumbnail.svelte';
|
||||||
|
import type { AbortError } from '$lib/utils';
|
||||||
|
import { getAssetRatio } from '$lib/utils/asset-utils';
|
||||||
|
import { getTimeBucket, getTimeBuckets, TimeBucketSize, type AssetResponseDto } from '@immich/sdk';
|
||||||
|
import justifiedLayout from 'justified-layout';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
const VIEWPORT_PADDING = 500;
|
||||||
|
|
||||||
|
let scrollEl: HTMLDivElement;
|
||||||
|
// let scrollEl: HTMLDivElement;
|
||||||
|
let sectionsEl: HTMLDivElement;
|
||||||
|
let wrapperHeight = 0;
|
||||||
|
let isDirty = true;
|
||||||
|
let sections: Section[] = [];
|
||||||
|
let visibleSections: Section[] = $state.raw([]);
|
||||||
|
let bucketSize = TimeBucketSize.Month;
|
||||||
|
let colors = ['bg-green-500', 'bg-red-500', 'bg-yellow-500', 'bg-violet-500'];
|
||||||
|
|
||||||
|
type SectionStatus = 'placeholder' | 'loading' | 'loaded';
|
||||||
|
type Section = {
|
||||||
|
timeBucket: string;
|
||||||
|
height: number;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
status: SectionStatus;
|
||||||
|
color: string;
|
||||||
|
layout?: ReturnType<typeof justifiedLayout>;
|
||||||
|
assetCount: number;
|
||||||
|
assets: AssetResponseDto[];
|
||||||
|
abort?: AbortController;
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetHeight = 235;
|
||||||
|
|
||||||
|
const onLoad = async () => {
|
||||||
|
const buckets = await getTimeBuckets({ size: TimeBucketSize.Month });
|
||||||
|
const newSections: Section[] = [];
|
||||||
|
let timelineHeight = 0;
|
||||||
|
|
||||||
|
for (const [i, bucket] of buckets.entries()) {
|
||||||
|
if (!bucket.count) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetCount = bucket.count;
|
||||||
|
const unwrappedWidth = (3 / 2) * assetCount * targetHeight * (7 / 10);
|
||||||
|
const rows = Math.ceil(unwrappedWidth / scrollEl.clientWidth);
|
||||||
|
const height = rows * targetHeight;
|
||||||
|
|
||||||
|
newSections.push({
|
||||||
|
timeBucket: bucket.timeBucket,
|
||||||
|
status: 'placeholder',
|
||||||
|
start: timelineHeight,
|
||||||
|
height,
|
||||||
|
color: colors[i % (colors.length - 1)],
|
||||||
|
end: timelineHeight + height,
|
||||||
|
assetCount,
|
||||||
|
assets: [],
|
||||||
|
});
|
||||||
|
timelineHeight += height;
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapperHeight = timelineHeight;
|
||||||
|
sectionsEl.style.height = `${wrapperHeight}px`;
|
||||||
|
sections = newSections;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isInRange = (value: number, start: number, end: number) => value > start && value < end;
|
||||||
|
|
||||||
|
const onRender = () => {
|
||||||
|
if (!scrollEl || !scrollEl || sections.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollPosition = scrollEl.scrollTop;
|
||||||
|
if (!isDirty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isDirty = false;
|
||||||
|
|
||||||
|
const viewportHeight = scrollEl.clientHeight;
|
||||||
|
const padding = viewportHeight;
|
||||||
|
const paddedViewportStart = scrollPosition - padding;
|
||||||
|
const paddedViewportEnd = scrollPosition + viewportHeight + padding;
|
||||||
|
|
||||||
|
// find sections that are visible in the viewport +/- `padding`
|
||||||
|
const included: Section[] = [];
|
||||||
|
|
||||||
|
let newWrapperHeight = 0;
|
||||||
|
|
||||||
|
for (const section of sections) {
|
||||||
|
section.start = newWrapperHeight;
|
||||||
|
section.end = section.start + section.height;
|
||||||
|
|
||||||
|
newWrapperHeight += section.height;
|
||||||
|
|
||||||
|
const isIncluded =
|
||||||
|
// start of section is in viewport
|
||||||
|
(section.start > paddedViewportStart && section.start < paddedViewportEnd) ||
|
||||||
|
// end of section is in viewport
|
||||||
|
(section.end > paddedViewportStart && section.end < paddedViewportEnd) ||
|
||||||
|
// viewport is contained in the section
|
||||||
|
(section.start < paddedViewportStart && section.end > paddedViewportEnd);
|
||||||
|
|
||||||
|
if (isIncluded) {
|
||||||
|
included.push(section);
|
||||||
|
if (section.status === 'placeholder') {
|
||||||
|
void loadSection(section);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (section.status === 'loading' && section.abort) {
|
||||||
|
abortSection(section);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapperHeight = newWrapperHeight;
|
||||||
|
console.log(`updating visible sections (${visibleSections.length})`);
|
||||||
|
visibleSections = included.map((item) => ({ ...item }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const abortSection = (section: Section) => {
|
||||||
|
if (section.abort) {
|
||||||
|
console.log(`[${section.timeBucket}] Load abort`);
|
||||||
|
section.abort.abort();
|
||||||
|
delete section.abort;
|
||||||
|
}
|
||||||
|
|
||||||
|
section.status = 'placeholder';
|
||||||
|
isDirty = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSection = async (section: Section) => {
|
||||||
|
try {
|
||||||
|
console.log(`[${section.timeBucket}] Load start`);
|
||||||
|
|
||||||
|
section.status = 'loading';
|
||||||
|
section.abort = new AbortController();
|
||||||
|
|
||||||
|
const assets = await getTimeBucket(
|
||||||
|
{ size: bucketSize, timeBucket: section.timeBucket },
|
||||||
|
{ signal: section.abort.signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[${section.timeBucket}] Load finish`);
|
||||||
|
|
||||||
|
if (!assets) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layout = justifiedLayout(
|
||||||
|
assets.map((asset) => getAssetRatio(asset)),
|
||||||
|
{
|
||||||
|
boxSpacing: 2,
|
||||||
|
containerWidth: Math.ceil(scrollEl.clientWidth),
|
||||||
|
containerPadding: 0,
|
||||||
|
targetRowHeightTolerance: 0.15,
|
||||||
|
targetRowHeight: 235,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const actualHeight = Math.ceil(layout.containerHeight) + 32;
|
||||||
|
section.assets = assets;
|
||||||
|
console.log(`[${section.timeBucket}] Update height from ${section.height} to ${actualHeight}`);
|
||||||
|
section.height = actualHeight;
|
||||||
|
section.status = 'loaded';
|
||||||
|
section.layout = layout;
|
||||||
|
|
||||||
|
isDirty = true;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
section.status = 'placeholder';
|
||||||
|
if ((error as AbortError)?.name !== 'AbortError') {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
delete section.abort;
|
||||||
|
if (section.status === 'loading') {
|
||||||
|
section.status = 'placeholder';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
isDirty = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
isDirty = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
onRender();
|
||||||
|
requestAnimationFrame(render);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void onLoad();
|
||||||
|
const requestId = requestAnimationFrame(render);
|
||||||
|
|
||||||
|
// setTimeout(() => {
|
||||||
|
// scrollEl.scrollTop = 50000;
|
||||||
|
// }, 2000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(requestId);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onresize={handleResize} />
|
||||||
|
|
||||||
|
<div class="h-full w-full">
|
||||||
|
<div bind:this={scrollEl} class="absolute w-full overflow-y-auto top-0 h-full" onscroll={handleScroll}>
|
||||||
|
<!-- <div bind:this={scrollEl} class="h-full w-full"> -->
|
||||||
|
<div bind:this={sectionsEl} class="absolute w-full">
|
||||||
|
<!-- section -->
|
||||||
|
{#each visibleSections as section (section.timeBucket)}
|
||||||
|
<div
|
||||||
|
data-section-key={section.timeBucket}
|
||||||
|
style="height: {section.height}px; transform: translate3d(0px, {section.start}px, 0px);"
|
||||||
|
class="absolute w-full {section.color}"
|
||||||
|
>
|
||||||
|
<!-- <pre>{section.status}</pre> -->
|
||||||
|
<!-- asset -->
|
||||||
|
{#if section.status === 'loaded' && section.layout}
|
||||||
|
{#each section.assets as asset, i (asset.id)}
|
||||||
|
{@const box = section.layout.boxes[i]}
|
||||||
|
{#if isInRange(section.start + box.top, scrollEl.scrollTop - VIEWPORT_PADDING, scrollEl.scrollTop + scrollEl.clientHeight + VIEWPORT_PADDING)}
|
||||||
|
<div
|
||||||
|
class="absolute border"
|
||||||
|
style="width: {Math.floor(box.width)}px; height: {Math.floor(
|
||||||
|
box.height,
|
||||||
|
)}px; transform: translate3d({Math.floor(box.left)}px, {Math.floor(box.top)}px, 0px)"
|
||||||
|
>
|
||||||
|
<!-- <pre class="text-wrap break-all">{asset.id}</pre> -->
|
||||||
|
<PhotoThumbnail {asset} width={box.width} height={box.height} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<!-- </div> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
8
web/src/routes/photos2/[[assetId=id]]/+page.svelte
Normal file
8
web/src/routes/photos2/[[assetId=id]]/+page.svelte
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||||
|
import PhotoTimeline from '$lib/components/PhotoTimeline.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<UserPageLayout showUploadButton noWrapper>
|
||||||
|
<PhotoTimeline />
|
||||||
|
</UserPageLayout>
|
14
web/src/routes/photos2/[[assetId=id]]/+page.ts
Normal file
14
web/src/routes/photos2/[[assetId=id]]/+page.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { authenticate } from '$lib/utils/auth';
|
||||||
|
import { getFormatter } from '$lib/utils/i18n';
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const load = (async () => {
|
||||||
|
await authenticate();
|
||||||
|
const $t = await getFormatter();
|
||||||
|
|
||||||
|
return {
|
||||||
|
meta: {
|
||||||
|
title: $t('photos'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}) satisfies PageLoad;
|
Loading…
x
Reference in New Issue
Block a user