mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-04 03:27:09 -05:00 
			
		
		
		
	feat(web, server): Implement justified layout for AssetGrid (#2666)
* Implement justified layout for timeline * Add withoutThumbs field to GetTimelineLayotDto * Back to rough estimation of initial buckets height * Remove getTimelineLayout endpoint * Estimate rough viewport height better * Fix shift/jump issues while scrolling up --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
		
							parent
							
								
									8ebac41318
								
							
						
					
					
						commit
						5764bf16f3
					
				@ -104,6 +104,7 @@ export class AssetRepository implements IAssetRepository {
 | 
				
			|||||||
    // Get asset entity from a list of time buckets
 | 
					    // Get asset entity from a list of time buckets
 | 
				
			||||||
    let builder = this.assetRepository
 | 
					    let builder = this.assetRepository
 | 
				
			||||||
      .createQueryBuilder('asset')
 | 
					      .createQueryBuilder('asset')
 | 
				
			||||||
 | 
					      .leftJoinAndSelect('asset.exifInfo', 'exifInfo')
 | 
				
			||||||
      .where('asset.ownerId = :userId', { userId: userId })
 | 
					      .where('asset.ownerId = :userId', { userId: userId })
 | 
				
			||||||
      .andWhere(`date_trunc('month', "fileCreatedAt") IN (:...buckets)`, {
 | 
					      .andWhere(`date_trunc('month', "fileCreatedAt") IN (:...buckets)`, {
 | 
				
			||||||
        buckets: [...dto.timeBucket],
 | 
					        buckets: [...dto.timeBucket],
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										11
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										11
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							@ -12,6 +12,7 @@
 | 
				
			|||||||
				"axios": "^0.27.2",
 | 
									"axios": "^0.27.2",
 | 
				
			||||||
				"copy-image-clipboard": "^2.1.2",
 | 
									"copy-image-clipboard": "^2.1.2",
 | 
				
			||||||
				"handlebars": "^4.7.7",
 | 
									"handlebars": "^4.7.7",
 | 
				
			||||||
 | 
									"justified-layout": "^4.1.0",
 | 
				
			||||||
				"leaflet": "^1.9.3",
 | 
									"leaflet": "^1.9.3",
 | 
				
			||||||
				"leaflet.markercluster": "^1.5.3",
 | 
									"leaflet.markercluster": "^1.5.3",
 | 
				
			||||||
				"lodash-es": "^4.17.21",
 | 
									"lodash-es": "^4.17.21",
 | 
				
			||||||
@ -9076,6 +9077,11 @@
 | 
				
			|||||||
				"node": ">=6"
 | 
									"node": ">=6"
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
		},
 | 
							},
 | 
				
			||||||
 | 
							"node_modules/justified-layout": {
 | 
				
			||||||
 | 
								"version": "4.1.0",
 | 
				
			||||||
 | 
								"resolved": "https://registry.npmjs.org/justified-layout/-/justified-layout-4.1.0.tgz",
 | 
				
			||||||
 | 
								"integrity": "sha512-M5FimNMXgiOYerVRGsXZ2YK9YNCaTtwtYp7Hb2308U1Q9TXXHx5G0p08mcVR5O53qf8bWY4NJcPBxE6zuayXSg=="
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
		"node_modules/kind-of": {
 | 
							"node_modules/kind-of": {
 | 
				
			||||||
			"version": "6.0.3",
 | 
								"version": "6.0.3",
 | 
				
			||||||
			"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
 | 
								"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
 | 
				
			||||||
@ -18186,6 +18192,11 @@
 | 
				
			|||||||
			"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
 | 
								"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
 | 
				
			||||||
			"dev": true
 | 
								"dev": true
 | 
				
			||||||
		},
 | 
							},
 | 
				
			||||||
 | 
							"justified-layout": {
 | 
				
			||||||
 | 
								"version": "4.1.0",
 | 
				
			||||||
 | 
								"resolved": "https://registry.npmjs.org/justified-layout/-/justified-layout-4.1.0.tgz",
 | 
				
			||||||
 | 
								"integrity": "sha512-M5FimNMXgiOYerVRGsXZ2YK9YNCaTtwtYp7Hb2308U1Q9TXXHx5G0p08mcVR5O53qf8bWY4NJcPBxE6zuayXSg=="
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
		"kind-of": {
 | 
							"kind-of": {
 | 
				
			||||||
			"version": "6.0.3",
 | 
								"version": "6.0.3",
 | 
				
			||||||
			"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
 | 
								"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
 | 
				
			||||||
 | 
				
			|||||||
@ -62,6 +62,7 @@
 | 
				
			|||||||
		"axios": "^0.27.2",
 | 
							"axios": "^0.27.2",
 | 
				
			||||||
		"copy-image-clipboard": "^2.1.2",
 | 
							"copy-image-clipboard": "^2.1.2",
 | 
				
			||||||
		"handlebars": "^4.7.7",
 | 
							"handlebars": "^4.7.7",
 | 
				
			||||||
 | 
							"justified-layout": "^4.1.0",
 | 
				
			||||||
		"leaflet": "^1.9.3",
 | 
							"leaflet": "^1.9.3",
 | 
				
			||||||
		"leaflet.markercluster": "^1.5.3",
 | 
							"leaflet.markercluster": "^1.5.3",
 | 
				
			||||||
		"lodash-es": "^4.17.21",
 | 
							"lodash-es": "^4.17.21",
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,5 @@
 | 
				
			|||||||
<script lang="ts">
 | 
					<script lang="ts">
 | 
				
			||||||
 | 
						import { BucketPosition } from '$lib/models/asset-grid-state';
 | 
				
			||||||
	import { onMount } from 'svelte';
 | 
						import { onMount } from 'svelte';
 | 
				
			||||||
	import { createEventDispatcher } from 'svelte';
 | 
						import { createEventDispatcher } from 'svelte';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -28,7 +29,17 @@
 | 
				
			|||||||
					}
 | 
										}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
					if (intersecting) {
 | 
										if (intersecting) {
 | 
				
			||||||
						dispatch('intersected', container);
 | 
											let position: BucketPosition = BucketPosition.Visible;
 | 
				
			||||||
 | 
											if (entries[0].boundingClientRect.top + 50 > entries[0].intersectionRect.bottom) {
 | 
				
			||||||
 | 
												position = BucketPosition.Below;
 | 
				
			||||||
 | 
											} else if (entries[0].boundingClientRect.bottom < 0) {
 | 
				
			||||||
 | 
												position = BucketPosition.Above;
 | 
				
			||||||
 | 
											}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
											dispatch('intersected', {
 | 
				
			||||||
 | 
												container,
 | 
				
			||||||
 | 
												position
 | 
				
			||||||
 | 
											});
 | 
				
			||||||
					}
 | 
										}
 | 
				
			||||||
				},
 | 
									},
 | 
				
			||||||
				{
 | 
									{
 | 
				
			||||||
 | 
				
			|||||||
@ -9,17 +9,20 @@
 | 
				
			|||||||
	import { assetStore } from '$lib/stores/assets.store';
 | 
						import { assetStore } from '$lib/stores/assets.store';
 | 
				
			||||||
	import { locale } from '$lib/stores/preferences.store';
 | 
						import { locale } from '$lib/stores/preferences.store';
 | 
				
			||||||
	import type { AssetResponseDto } from '@api';
 | 
						import type { AssetResponseDto } from '@api';
 | 
				
			||||||
 | 
						import justifiedLayout from 'justified-layout';
 | 
				
			||||||
	import lodash from 'lodash-es';
 | 
						import lodash from 'lodash-es';
 | 
				
			||||||
	import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
 | 
						import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
 | 
				
			||||||
	import CircleOutline from 'svelte-material-icons/CircleOutline.svelte';
 | 
						import CircleOutline from 'svelte-material-icons/CircleOutline.svelte';
 | 
				
			||||||
	import { flip } from 'svelte/animate';
 | 
					 | 
				
			||||||
	import { fly } from 'svelte/transition';
 | 
						import { fly } from 'svelte/transition';
 | 
				
			||||||
 | 
						import { getAssetRatio } from '$lib/utils/asset-utils';
 | 
				
			||||||
	import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
 | 
						import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
 | 
				
			||||||
 | 
						import { createEventDispatcher } from 'svelte';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	export let assets: AssetResponseDto[];
 | 
						export let assets: AssetResponseDto[];
 | 
				
			||||||
	export let bucketDate: string;
 | 
						export let bucketDate: string;
 | 
				
			||||||
	export let bucketHeight: number;
 | 
						export let bucketHeight: number;
 | 
				
			||||||
	export let isAlbumSelectionMode = false;
 | 
						export let isAlbumSelectionMode = false;
 | 
				
			||||||
 | 
						export let viewportWidth: number;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	const groupDateFormat: Intl.DateTimeFormatOptions = {
 | 
						const groupDateFormat: Intl.DateTimeFormatOptions = {
 | 
				
			||||||
		weekday: 'short',
 | 
							weekday: 'short',
 | 
				
			||||||
@ -28,20 +31,65 @@
 | 
				
			|||||||
		year: 'numeric'
 | 
							year: 'numeric'
 | 
				
			||||||
	};
 | 
						};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						const dispatch = createEventDispatcher();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	let isMouseOverGroup = false;
 | 
						let isMouseOverGroup = false;
 | 
				
			||||||
	let actualBucketHeight: number;
 | 
						let actualBucketHeight: number;
 | 
				
			||||||
	let hoveredDateGroup = '';
 | 
						let hoveredDateGroup = '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						interface LayoutBox {
 | 
				
			||||||
 | 
							top: number;
 | 
				
			||||||
 | 
							left: number;
 | 
				
			||||||
 | 
							width: number;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	$: assetsGroupByDate = lodash
 | 
						$: assetsGroupByDate = lodash
 | 
				
			||||||
		.chain(assets)
 | 
							.chain(assets)
 | 
				
			||||||
		.groupBy((a) => new Date(a.fileCreatedAt).toLocaleDateString($locale, groupDateFormat))
 | 
							.groupBy((a) => new Date(a.fileCreatedAt).toLocaleDateString($locale, groupDateFormat))
 | 
				
			||||||
		.sortBy((group) => assets.indexOf(group[0]))
 | 
							.sortBy((group) => assets.indexOf(group[0]))
 | 
				
			||||||
		.value();
 | 
							.value();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						$: geometry = (() => {
 | 
				
			||||||
 | 
							const geometry = [];
 | 
				
			||||||
 | 
							for (let group of assetsGroupByDate) {
 | 
				
			||||||
 | 
								geometry.push(
 | 
				
			||||||
 | 
									justifiedLayout(group.map(getAssetRatio), {
 | 
				
			||||||
 | 
										boxSpacing: 2,
 | 
				
			||||||
 | 
										containerWidth: Math.floor(viewportWidth),
 | 
				
			||||||
 | 
										containerPadding: 0,
 | 
				
			||||||
 | 
										targetRowHeightTolerance: 0.15,
 | 
				
			||||||
 | 
										targetRowHeight: 235
 | 
				
			||||||
 | 
									})
 | 
				
			||||||
 | 
								);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							return geometry;
 | 
				
			||||||
 | 
						})();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	$: {
 | 
						$: {
 | 
				
			||||||
		if (actualBucketHeight && actualBucketHeight != 0 && actualBucketHeight != bucketHeight) {
 | 
							if (actualBucketHeight && actualBucketHeight != 0 && actualBucketHeight != bucketHeight) {
 | 
				
			||||||
			assetStore.updateBucketHeight(bucketDate, actualBucketHeight);
 | 
								const heightDelta = assetStore.updateBucketHeight(bucketDate, actualBucketHeight);
 | 
				
			||||||
 | 
								if (heightDelta !== 0) {
 | 
				
			||||||
 | 
									scrollTimeline(heightDelta);
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						function scrollTimeline(heightDelta: number) {
 | 
				
			||||||
 | 
							dispatch('shift', {
 | 
				
			||||||
 | 
								heightDelta
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						const calculateWidth = (boxes: LayoutBox[]): number => {
 | 
				
			||||||
 | 
							let width = 0;
 | 
				
			||||||
 | 
							for (const box of boxes) {
 | 
				
			||||||
 | 
								if (box.top < 100) {
 | 
				
			||||||
 | 
									width = box.left + box.width;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							return width;
 | 
				
			||||||
 | 
						};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	const assetClickHandler = (
 | 
						const assetClickHandler = (
 | 
				
			||||||
		asset: AssetResponseDto,
 | 
							asset: AssetResponseDto,
 | 
				
			||||||
@ -112,8 +160,9 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<section
 | 
					<section
 | 
				
			||||||
	id="asset-group-by-date"
 | 
						id="asset-group-by-date"
 | 
				
			||||||
	class="flex flex-wrap gap-12 mt-5"
 | 
						class="flex flex-wrap gap-x-12"
 | 
				
			||||||
	bind:clientHeight={actualBucketHeight}
 | 
						bind:clientHeight={actualBucketHeight}
 | 
				
			||||||
 | 
						bind:clientWidth={viewportWidth}
 | 
				
			||||||
>
 | 
					>
 | 
				
			||||||
	{#each assetsGroupByDate as assetsInDateGroup, groupIndex (assetsInDateGroup[0].id)}
 | 
						{#each assetsGroupByDate as assetsInDateGroup, groupIndex (assetsInDateGroup[0].id)}
 | 
				
			||||||
		{@const dateGroupTitle = new Date(assetsInDateGroup[0].fileCreatedAt).toLocaleDateString(
 | 
							{@const dateGroupTitle = new Date(assetsInDateGroup[0].fileCreatedAt).toLocaleDateString(
 | 
				
			||||||
@ -123,8 +172,7 @@
 | 
				
			|||||||
		<!-- Asset Group By Date -->
 | 
							<!-- Asset Group By Date -->
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		<div
 | 
							<div
 | 
				
			||||||
			animate:flip={{ duration: 300 }}
 | 
								class="flex flex-col mt-5"
 | 
				
			||||||
			class="flex flex-col"
 | 
					 | 
				
			||||||
			on:mouseenter={() => {
 | 
								on:mouseenter={() => {
 | 
				
			||||||
				isMouseOverGroup = true;
 | 
									isMouseOverGroup = true;
 | 
				
			||||||
				assetMouseEventHandler(dateGroupTitle);
 | 
									assetMouseEventHandler(dateGroupTitle);
 | 
				
			||||||
@ -156,9 +204,18 @@
 | 
				
			|||||||
			</p>
 | 
								</p>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			<!-- Image grid -->
 | 
								<!-- Image grid -->
 | 
				
			||||||
			<div class="flex flex-wrap gap-[2px]">
 | 
								<div
 | 
				
			||||||
				{#each assetsInDateGroup as asset (asset.id)}
 | 
									class="relative"
 | 
				
			||||||
					<div animate:flip={{ duration: 300 }}>
 | 
									style="height: {geometry[groupIndex].containerHeight}px;width: {calculateWidth(
 | 
				
			||||||
 | 
										geometry[groupIndex].boxes
 | 
				
			||||||
 | 
									)}px"
 | 
				
			||||||
 | 
								>
 | 
				
			||||||
 | 
									{#each assetsInDateGroup as asset, index (asset.id)}
 | 
				
			||||||
 | 
										{@const box = geometry[groupIndex].boxes[index]}
 | 
				
			||||||
 | 
										<div
 | 
				
			||||||
 | 
											class="absolute"
 | 
				
			||||||
 | 
											style="width: {box.width}px; height: {box.height}px; top: {box.top}px; left: {box.left}px"
 | 
				
			||||||
 | 
										>
 | 
				
			||||||
						<Thumbnail
 | 
											<Thumbnail
 | 
				
			||||||
							{asset}
 | 
												{asset}
 | 
				
			||||||
							{groupIndex}
 | 
												{groupIndex}
 | 
				
			||||||
@ -168,6 +225,8 @@
 | 
				
			|||||||
							selected={$selectedAssets.has(asset) ||
 | 
												selected={$selectedAssets.has(asset) ||
 | 
				
			||||||
								$assetsInAlbumStoreState.findIndex((a) => a.id == asset.id) != -1}
 | 
													$assetsInAlbumStoreState.findIndex((a) => a.id == asset.id) != -1}
 | 
				
			||||||
							disabled={$assetsInAlbumStoreState.findIndex((a) => a.id == asset.id) != -1}
 | 
												disabled={$assetsInAlbumStoreState.findIndex((a) => a.id == asset.id) != -1}
 | 
				
			||||||
 | 
												thumbnailWidth={box.width}
 | 
				
			||||||
 | 
												thumbnailHeight={box.height}
 | 
				
			||||||
						/>
 | 
											/>
 | 
				
			||||||
					</div>
 | 
										</div>
 | 
				
			||||||
				{/each}
 | 
									{/each}
 | 
				
			||||||
 | 
				
			|||||||
@ -16,6 +16,7 @@
 | 
				
			|||||||
		OnScrollbarDragDetail
 | 
							OnScrollbarDragDetail
 | 
				
			||||||
	} from '../shared-components/scrollbar/scrollbar.svelte';
 | 
						} from '../shared-components/scrollbar/scrollbar.svelte';
 | 
				
			||||||
	import AssetDateGroup from './asset-date-group.svelte';
 | 
						import AssetDateGroup from './asset-date-group.svelte';
 | 
				
			||||||
 | 
						import { BucketPosition } from '$lib/models/asset-grid-state';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	export let user: UserResponseDto | undefined = undefined;
 | 
						export let user: UserResponseDto | undefined = undefined;
 | 
				
			||||||
	export let isAlbumSelectionMode = false;
 | 
						export let isAlbumSelectionMode = false;
 | 
				
			||||||
@ -33,6 +34,7 @@
 | 
				
			|||||||
				withoutThumbs: true
 | 
									withoutThumbs: true
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
		});
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		bucketInfo = assetCountByTimebucket;
 | 
							bucketInfo = assetCountByTimebucket;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		assetStore.setInitialState(viewportHeight, viewportWidth, assetCountByTimebucket, user?.id);
 | 
							assetStore.setInitialState(viewportHeight, viewportWidth, assetCountByTimebucket, user?.id);
 | 
				
			||||||
@ -51,7 +53,7 @@
 | 
				
			|||||||
		});
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		bucketsToFetchInitially.forEach((bucketDate) => {
 | 
							bucketsToFetchInitially.forEach((bucketDate) => {
 | 
				
			||||||
			assetStore.getAssetsByBucket(bucketDate);
 | 
								assetStore.getAssetsByBucket(bucketDate, BucketPosition.Visible);
 | 
				
			||||||
		});
 | 
							});
 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -60,15 +62,18 @@
 | 
				
			|||||||
	});
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	function intersectedHandler(event: CustomEvent) {
 | 
						function intersectedHandler(event: CustomEvent) {
 | 
				
			||||||
		const el = event.detail as HTMLElement;
 | 
							const el = event.detail.container as HTMLElement;
 | 
				
			||||||
		const target = el.firstChild as HTMLElement;
 | 
							const target = el.firstChild as HTMLElement;
 | 
				
			||||||
 | 
					 | 
				
			||||||
		if (target) {
 | 
							if (target) {
 | 
				
			||||||
			const bucketDate = target.id.split('_')[1];
 | 
								const bucketDate = target.id.split('_')[1];
 | 
				
			||||||
			assetStore.getAssetsByBucket(bucketDate);
 | 
								assetStore.getAssetsByBucket(bucketDate, event.detail.position);
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						function handleScrollTimeline(event: CustomEvent) {
 | 
				
			||||||
 | 
							assetGridElement.scrollBy(0, event.detail.heightDelta);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	const navigateToPreviousAsset = () => {
 | 
						const navigateToPreviousAsset = () => {
 | 
				
			||||||
		assetInteractionStore.navigateAsset('previous');
 | 
							assetInteractionStore.navigateAsset('previous');
 | 
				
			||||||
	};
 | 
						};
 | 
				
			||||||
@ -115,9 +120,10 @@
 | 
				
			|||||||
	/>
 | 
						/>
 | 
				
			||||||
{/if}
 | 
					{/if}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<!-- Right margin MUST be equal to the width of immich-scrubbable-scrollbar -->
 | 
				
			||||||
<section
 | 
					<section
 | 
				
			||||||
	id="asset-grid"
 | 
						id="asset-grid"
 | 
				
			||||||
	class="overflow-y-auto pl-4 scrollbar-hidden"
 | 
						class="overflow-y-auto ml-4 mb-4 mr-[60px] scrollbar-hidden"
 | 
				
			||||||
	bind:clientHeight={viewportHeight}
 | 
						bind:clientHeight={viewportHeight}
 | 
				
			||||||
	bind:clientWidth={viewportWidth}
 | 
						bind:clientWidth={viewportWidth}
 | 
				
			||||||
	bind:this={assetGridElement}
 | 
						bind:this={assetGridElement}
 | 
				
			||||||
@ -143,9 +149,11 @@
 | 
				
			|||||||
						{#if intersecting}
 | 
											{#if intersecting}
 | 
				
			||||||
							<AssetDateGroup
 | 
												<AssetDateGroup
 | 
				
			||||||
								{isAlbumSelectionMode}
 | 
													{isAlbumSelectionMode}
 | 
				
			||||||
 | 
													on:shift={handleScrollTimeline}
 | 
				
			||||||
								assets={bucket.assets}
 | 
													assets={bucket.assets}
 | 
				
			||||||
								bucketDate={bucket.bucketDate}
 | 
													bucketDate={bucket.bucketDate}
 | 
				
			||||||
								bucketHeight={bucket.bucketHeight}
 | 
													bucketHeight={bucket.bucketHeight}
 | 
				
			||||||
 | 
													{viewportWidth}
 | 
				
			||||||
							/>
 | 
												/>
 | 
				
			||||||
						{/if}
 | 
											{/if}
 | 
				
			||||||
					</div>
 | 
										</div>
 | 
				
			||||||
 | 
				
			|||||||
@ -1,5 +1,12 @@
 | 
				
			|||||||
import type { AssetResponseDto } from '@api';
 | 
					import type { AssetResponseDto } from '@api';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export enum BucketPosition {
 | 
				
			||||||
 | 
						Above = 'above',
 | 
				
			||||||
 | 
						Below = 'below',
 | 
				
			||||||
 | 
						Visible = 'visible',
 | 
				
			||||||
 | 
						Unknown = 'unknown'
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export class AssetBucket {
 | 
					export class AssetBucket {
 | 
				
			||||||
	/**
 | 
						/**
 | 
				
			||||||
	 * The DOM height of the bucket in pixel
 | 
						 * The DOM height of the bucket in pixel
 | 
				
			||||||
@ -9,6 +16,7 @@ export class AssetBucket {
 | 
				
			|||||||
	bucketDate!: string;
 | 
						bucketDate!: string;
 | 
				
			||||||
	assets!: AssetResponseDto[];
 | 
						assets!: AssetResponseDto[];
 | 
				
			||||||
	cancelToken!: AbortController;
 | 
						cancelToken!: AbortController;
 | 
				
			||||||
 | 
						position!: BucketPosition;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export class AssetGridState {
 | 
					export class AssetGridState {
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
import { AssetGridState } from '$lib/models/asset-grid-state';
 | 
					import { AssetGridState, BucketPosition } from '$lib/models/asset-grid-state';
 | 
				
			||||||
import { api, AssetResponseDto } from '@api';
 | 
					import { api, AssetResponseDto } from '@api';
 | 
				
			||||||
import { derived, writable } from 'svelte/store';
 | 
					import { derived, writable } from 'svelte/store';
 | 
				
			||||||
import { assetGridState, assetStore } from './assets.store';
 | 
					import { assetGridState, assetStore } from './assets.store';
 | 
				
			||||||
@ -92,7 +92,7 @@ function createAssetInteractionStore() {
 | 
				
			|||||||
			}
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			if (nextBucket !== '') {
 | 
								if (nextBucket !== '') {
 | 
				
			||||||
				await assetStore.getAssetsByBucket(nextBucket);
 | 
									await assetStore.getAssetsByBucket(nextBucket, BucketPosition.Below);
 | 
				
			||||||
				navigateAsset(direction);
 | 
									navigateAsset(direction);
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
			return;
 | 
								return;
 | 
				
			||||||
 | 
				
			|||||||
@ -1,6 +1,5 @@
 | 
				
			|||||||
import { AssetGridState } from '$lib/models/asset-grid-state';
 | 
					import { AssetGridState, BucketPosition } from '$lib/models/asset-grid-state';
 | 
				
			||||||
import { calculateViewportHeightByNumberOfAsset } from '$lib/utils/viewport-utils';
 | 
					import { AssetCountByTimeBucketResponseDto, api } from '@api';
 | 
				
			||||||
import { api, AssetCountByTimeBucketResponseDto } from '@api';
 | 
					 | 
				
			||||||
import { sumBy, flatMap } from 'lodash-es';
 | 
					import { sumBy, flatMap } from 'lodash-es';
 | 
				
			||||||
import { writable } from 'svelte/store';
 | 
					import { writable } from 'svelte/store';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -20,6 +19,18 @@ function createAssetStore() {
 | 
				
			|||||||
	loadingBucketState.subscribe((state) => {
 | 
						loadingBucketState.subscribe((state) => {
 | 
				
			||||||
		_loadingBucketState = state;
 | 
							_loadingBucketState = state;
 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						const estimateViewportHeight = (assetCount: number, viewportWidth: number): number => {
 | 
				
			||||||
 | 
							// Ideally we would use the average aspect ratio for the photoset, however assume
 | 
				
			||||||
 | 
							// a normal landscape aspect ratio of 3:2, then discount for the likelihood we
 | 
				
			||||||
 | 
							// will be scaling down and coalescing.
 | 
				
			||||||
 | 
							const thumbnailHeight = 235;
 | 
				
			||||||
 | 
							const unwrappedWidth = (3 / 2) * assetCount * thumbnailHeight * (7 / 10);
 | 
				
			||||||
 | 
							const rows = Math.ceil(unwrappedWidth / viewportWidth);
 | 
				
			||||||
 | 
							const height = rows * thumbnailHeight;
 | 
				
			||||||
 | 
							return height;
 | 
				
			||||||
 | 
						};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	/**
 | 
						/**
 | 
				
			||||||
	 * Set initial state
 | 
						 * Set initial state
 | 
				
			||||||
	 * @param viewportHeight
 | 
						 * @param viewportHeight
 | 
				
			||||||
@ -36,11 +47,12 @@ function createAssetStore() {
 | 
				
			|||||||
			viewportHeight,
 | 
								viewportHeight,
 | 
				
			||||||
			viewportWidth,
 | 
								viewportWidth,
 | 
				
			||||||
			timelineHeight: 0,
 | 
								timelineHeight: 0,
 | 
				
			||||||
			buckets: data.buckets.map((d) => ({
 | 
								buckets: data.buckets.map((bucket) => ({
 | 
				
			||||||
				bucketDate: d.timeBucket,
 | 
									bucketDate: bucket.timeBucket,
 | 
				
			||||||
				bucketHeight: calculateViewportHeightByNumberOfAsset(d.count, viewportWidth),
 | 
									bucketHeight: estimateViewportHeight(bucket.count, viewportWidth),
 | 
				
			||||||
				assets: [],
 | 
									assets: [],
 | 
				
			||||||
				cancelToken: new AbortController()
 | 
									cancelToken: new AbortController(),
 | 
				
			||||||
 | 
									position: BucketPosition.Unknown
 | 
				
			||||||
			})),
 | 
								})),
 | 
				
			||||||
			assets: [],
 | 
								assets: [],
 | 
				
			||||||
			userId
 | 
								userId
 | 
				
			||||||
@ -53,10 +65,15 @@ function createAssetStore() {
 | 
				
			|||||||
		});
 | 
							});
 | 
				
			||||||
	};
 | 
						};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	const getAssetsByBucket = async (bucket: string) => {
 | 
						const getAssetsByBucket = async (bucket: string, position: BucketPosition) => {
 | 
				
			||||||
		try {
 | 
							try {
 | 
				
			||||||
			const currentBucketData = _assetGridState.buckets.find((b) => b.bucketDate === bucket);
 | 
								const currentBucketData = _assetGridState.buckets.find((b) => b.bucketDate === bucket);
 | 
				
			||||||
			if (currentBucketData?.assets && currentBucketData.assets.length > 0) {
 | 
								if (currentBucketData?.assets && currentBucketData.assets.length > 0) {
 | 
				
			||||||
 | 
									assetGridState.update((state) => {
 | 
				
			||||||
 | 
										const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucket);
 | 
				
			||||||
 | 
										state.buckets[bucketIndex].position = position;
 | 
				
			||||||
 | 
										return state;
 | 
				
			||||||
 | 
									});
 | 
				
			||||||
				return;
 | 
									return;
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -83,8 +100,8 @@ function createAssetStore() {
 | 
				
			|||||||
			assetGridState.update((state) => {
 | 
								assetGridState.update((state) => {
 | 
				
			||||||
				const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucket);
 | 
									const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucket);
 | 
				
			||||||
				state.buckets[bucketIndex].assets = assets;
 | 
									state.buckets[bucketIndex].assets = assets;
 | 
				
			||||||
 | 
									state.buckets[bucketIndex].position = position;
 | 
				
			||||||
				state.assets = flatMap(state.buckets, (b) => b.assets);
 | 
									state.assets = flatMap(state.buckets, (b) => b.assets);
 | 
				
			||||||
 | 
					 | 
				
			||||||
				return state;
 | 
									return state;
 | 
				
			||||||
			});
 | 
								});
 | 
				
			||||||
			// eslint-disable-next-line @typescript-eslint/no-explicit-any
 | 
								// eslint-disable-next-line @typescript-eslint/no-explicit-any
 | 
				
			||||||
@ -120,21 +137,31 @@ function createAssetStore() {
 | 
				
			|||||||
		});
 | 
							});
 | 
				
			||||||
	};
 | 
						};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	const updateBucketHeight = (bucket: string, actualBucketHeight: number) => {
 | 
						const updateBucketHeight = (bucket: string, actualBucketHeight: number): number => {
 | 
				
			||||||
 | 
							let scrollTimeline = false;
 | 
				
			||||||
 | 
							let heightDelta = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		assetGridState.update((state) => {
 | 
							assetGridState.update((state) => {
 | 
				
			||||||
			const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucket);
 | 
								const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucket);
 | 
				
			||||||
			// Update timeline height based on the new bucket height
 | 
								// Update timeline height based on the new bucket height
 | 
				
			||||||
			const estimateBucketHeight = state.buckets[bucketIndex].bucketHeight;
 | 
								const estimateBucketHeight = state.buckets[bucketIndex].bucketHeight;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			if (actualBucketHeight >= estimateBucketHeight) {
 | 
								heightDelta = actualBucketHeight - estimateBucketHeight;
 | 
				
			||||||
				state.timelineHeight += actualBucketHeight - estimateBucketHeight;
 | 
								state.timelineHeight += heightDelta;
 | 
				
			||||||
			} else {
 | 
					
 | 
				
			||||||
				state.timelineHeight -= estimateBucketHeight - actualBucketHeight;
 | 
								scrollTimeline = state.buckets[bucketIndex].position == BucketPosition.Above;
 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
			state.buckets[bucketIndex].bucketHeight = actualBucketHeight;
 | 
								state.buckets[bucketIndex].bucketHeight = actualBucketHeight;
 | 
				
			||||||
 | 
								state.buckets[bucketIndex].position = BucketPosition.Unknown;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			return state;
 | 
								return state;
 | 
				
			||||||
		});
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							if (scrollTimeline) {
 | 
				
			||||||
 | 
								return heightDelta;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							return 0;
 | 
				
			||||||
	};
 | 
						};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	const cancelBucketRequest = async (token: AbortController, bucketDate: string) => {
 | 
						const cancelBucketRequest = async (token: AbortController, bucketDate: string) => {
 | 
				
			||||||
 | 
				
			|||||||
@ -150,3 +150,18 @@ export function getFileMimeType(file: File): string {
 | 
				
			|||||||
			return '';
 | 
								return '';
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Returns aspect ratio for the asset
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					export function getAssetRatio(asset: AssetResponseDto) {
 | 
				
			||||||
 | 
						let height = asset.exifInfo?.exifImageHeight || 235;
 | 
				
			||||||
 | 
						let width = asset.exifInfo?.exifImageWidth || 235;
 | 
				
			||||||
 | 
						const orientation = Number(asset.exifInfo?.orientation);
 | 
				
			||||||
 | 
						if (orientation) {
 | 
				
			||||||
 | 
							if (orientation == 6 || orientation == -90) {
 | 
				
			||||||
 | 
								[width, height] = [height, width];
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return { width, height };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -1,14 +0,0 @@
 | 
				
			|||||||
/**
 | 
					 | 
				
			||||||
 * Glossary
 | 
					 | 
				
			||||||
 * 1. Section: Group of assets in a month
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export function calculateViewportHeightByNumberOfAsset(assetCount: number, viewportWidth: number) {
 | 
					 | 
				
			||||||
	const thumbnailHeight = 237;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	// const unwrappedWidth = (3 / 2) * assetCount * thumbnailHeight * (7 / 10);
 | 
					 | 
				
			||||||
	const unwrappedWidth = assetCount * thumbnailHeight;
 | 
					 | 
				
			||||||
	const rows = Math.ceil(unwrappedWidth / viewportWidth);
 | 
					 | 
				
			||||||
	const height = rows * thumbnailHeight;
 | 
					 | 
				
			||||||
	return height;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user