Feature - Implemented virtual scroll on web (#573)

This PR implemented a virtual scroll on the web, as seen in this article.

[Building the Google Photos Web UI](https://medium.com/google-design/google-photos-45b714dfbed1)
This commit is contained in:
Alex
2022-09-04 08:34:39 -05:00
committed by GitHub
parent bd92dde117
commit 552340add7
58 changed files with 2197 additions and 698 deletions

View File

@@ -26,11 +26,22 @@
notificationController,
NotificationType
} from '../shared-components/notification/notification';
import { browser } from '$app/env';
export let album: AlbumResponseDto;
let isShowAssetViewer = false;
let isShowAssetSelection = false;
$: {
if (browser) {
if (isShowAssetSelection) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'auto';
}
}
}
let isShowShareUserSelection = false;
let isEditingTitle = false;
let isCreatingSharedAlbum = false;
@@ -197,10 +208,12 @@
}
const createAlbumHandler = async (event: CustomEvent) => {
const { assets }: { assets: string[] } = event.detail;
const { assets }: { assets: AssetResponseDto[] } = event.detail;
try {
const { data } = await api.albumApi.addAssetsToAlbum(album.id, { assetIds: assets });
const { data } = await api.albumApi.addAssetsToAlbum(album.id, {
assetIds: assets.map((a) => a.id)
});
album = data;
isShowAssetSelection = false;
@@ -456,8 +469,8 @@
{#if isShowAssetViewer}
<AssetViewer
asset={selectedAsset}
on:navigate-backward={navigateAssetBackward}
on:navigate-forward={navigateAssetForward}
on:navigate-previous={navigateAssetBackward}
on:navigate-next={navigateAssetForward}
on:close={closeViewer}
/>
{/if}

View File

@@ -2,30 +2,26 @@
import { createEventDispatcher, onMount } from 'svelte';
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import { assetsGroupByDate, flattenAssetGroupByDate } from '$lib/stores/assets';
import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
import CircleOutline from 'svelte-material-icons/CircleOutline.svelte';
import moment from 'moment';
import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
import { AssetResponseDto } from '@api';
import { openFileUploadDialog, UploadType } from '$lib/utils/file-uploader';
import { albumUploadAssetStore } from '$lib/stores/album-upload-asset';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
import AssetGrid from '../photos-page/asset-grid.svelte';
import {
assetInteractionStore,
assetsInAlbumStoreState,
selectedAssets
} from '$lib/stores/asset-interaction.store';
const dispatch = createEventDispatcher();
export let assetsInAlbum: AssetResponseDto[];
let selectedAsset: Set<string> = new Set();
let selectedGroup: Set<number> = new Set();
let existingGroup: Set<number> = new Set();
let groupWithAssetsInAlbum: Record<number, Set<string>> = {};
let uploadAssets: string[] = [];
let uploadAssetsCount = 9999;
onMount(() => {
scanForExistingSelectedGroup();
$assetsInAlbumStoreState = assetsInAlbum;
albumUploadAssetStore.asset.subscribe((uploadedAsset) => {
uploadAssets = uploadedAsset;
@@ -60,127 +56,30 @@
}
}
const selectAssetHandler = (assetId: string, groupIndex: number) => {
const tempSelectedAsset = new Set(selectedAsset);
if (selectedAsset.has(assetId)) {
tempSelectedAsset.delete(assetId);
const tempSelectedGroup = new Set(selectedGroup);
tempSelectedGroup.delete(groupIndex);
selectedGroup = tempSelectedGroup;
} else {
tempSelectedAsset.add(assetId);
}
selectedAsset = tempSelectedAsset;
// Check if all assets are selected in a group to toggle the group selection's icon
if (!selectedGroup.has(groupIndex)) {
const assetsInGroup = $assetsGroupByDate[groupIndex];
let selectedAssetsInGroupCount = 0;
assetsInGroup.forEach((asset) => {
if (selectedAsset.has(asset.id)) {
selectedAssetsInGroupCount++;
}
});
// Taking into account of assets in group that are already in album
if (groupWithAssetsInAlbum[groupIndex]) {
selectedAssetsInGroupCount += groupWithAssetsInAlbum[groupIndex].size;
}
// if all assets are selected in a group, add the group to selected group
if (selectedAssetsInGroupCount == assetsInGroup.length) {
selectedGroup = selectedGroup.add(groupIndex);
}
}
};
const selectAssetGroupHandler = (groupIndex: number) => {
if (existingGroup.has(groupIndex)) return;
let tempSelectedGroup = new Set(selectedGroup);
let tempSelectedAsset = new Set(selectedAsset);
if (selectedGroup.has(groupIndex)) {
tempSelectedGroup.delete(groupIndex);
tempSelectedAsset.forEach((assetId) => {
if ($assetsGroupByDate[groupIndex].find((a) => a.id == assetId)) {
tempSelectedAsset.delete(assetId);
}
});
} else {
tempSelectedGroup.add(groupIndex);
tempSelectedAsset = new Set([
...selectedAsset,
...$assetsGroupByDate[groupIndex].map((a) => a.id)
]);
}
// Remove existed assets in the date group
if (groupWithAssetsInAlbum[groupIndex]) {
tempSelectedAsset.forEach((assetId) => {
if (groupWithAssetsInAlbum[groupIndex].has(assetId)) {
tempSelectedAsset.delete(assetId);
}
});
}
selectedAsset = tempSelectedAsset;
selectedGroup = tempSelectedGroup;
};
const addSelectedAssets = async () => {
dispatch('create-album', {
assets: Array.from(selectedAsset)
assets: Array.from($selectedAssets)
});
};
/**
* This function is used to scan for existing selected group in the album
* and format it into the form of Record<any, Set<string>> to conditionally render and perform interaction
* relationship between the noneselected assets/groups
* with the existing assets/groups
*/
const scanForExistingSelectedGroup = () => {
if (assetsInAlbum) {
// Convert to each assetGroup to set of assetIds
const distinctAssetGroup = $assetsGroupByDate.map((assetGroup) => {
return new Set(assetGroup.map((asset) => asset.id));
});
// Find the group that contains all existed assets with the same set of assetIds
for (const assetInAlbum of assetsInAlbum) {
distinctAssetGroup.forEach((group, index) => {
if (group.has(assetInAlbum.id)) {
groupWithAssetsInAlbum[index] = new Set(groupWithAssetsInAlbum[index] || []).add(
assetInAlbum.id
);
}
});
}
Object.keys(groupWithAssetsInAlbum).forEach((key) => {
if (distinctAssetGroup[parseInt(key)].size == groupWithAssetsInAlbum[parseInt(key)].size) {
existingGroup = existingGroup.add(parseInt(key));
}
});
}
assetInteractionStore.clearMultiselect();
};
</script>
<section
transition:fly={{ y: 500, duration: 100, easing: quintOut }}
class="absolute top-0 left-0 w-full h-full py-[160px] bg-immich-bg z-[9999]"
class="absolute top-0 left-0 w-full h-full bg-immich-bg z-[9999]"
>
<ControlAppBar on:close-button-click={() => dispatch('go-back')}>
<ControlAppBar
on:close-button-click={() => {
assetInteractionStore.clearMultiselect();
dispatch('go-back');
}}
>
<svelte:fragment slot="leading">
{#if selectedAsset.size == 0}
{#if $selectedAssets.size == 0}
<p class="text-lg">Add to album</p>
{:else}
<p class="text-lg">{selectedAsset.size} selected</p>
<p class="text-lg">{$selectedAssets.size} selected</p>
{/if}
</svelte:fragment>
@@ -192,51 +91,14 @@
Select from computer
</button>
<button
disabled={selectedAsset.size === 0}
disabled={$selectedAssets.size === 0}
on:click={addSelectedAssets}
class="immich-text-button border bg-immich-primary text-gray-50 hover:bg-immich-primary/75 px-6 text-sm disabled:opacity-25 disabled:bg-gray-500 disabled:cursor-not-allowed"
><span class="px-2">Done</span></button
>
</svelte:fragment>
</ControlAppBar>
<section class="flex flex-wrap gap-14 px-20 overflow-y-auto">
{#each $assetsGroupByDate as assetsInDateGroup, groupIndex}
<!-- Asset Group By Date -->
<div class="flex flex-col">
<!-- Date group title -->
<p class="font-medium text-sm text-immich-fg mb-2 flex place-items-center h-6">
<span
in:fly={{ x: -24, duration: 200, opacity: 0.5 }}
out:fly={{ x: -24, duration: 200 }}
class="inline-block px-2 hover:cursor-pointer"
on:click={() => selectAssetGroupHandler(groupIndex)}
>
{#if selectedGroup.has(groupIndex)}
<CheckCircle size="24" color="#4250af" />
{:else if existingGroup.has(groupIndex)}
<CheckCircle size="24" color="#757575" />
{:else}
<CircleOutline size="24" color="#757575" />
{/if}
</span>
{moment(assetsInDateGroup[0].createdAt).format('ddd, MMM DD YYYY')}
</p>
<!-- Image grid -->
<div class="flex flex-wrap gap-[2px]">
{#each assetsInDateGroup as asset}
<ImmichThumbnail
{asset}
on:click={() => selectAssetHandler(asset.id, groupIndex)}
{groupIndex}
selected={selectedAsset.has(asset.id)}
isExisted={assetsInAlbum.findIndex((a) => a.id == asset.id) != -1}
/>
{/each}
</div>
</div>
{/each}
<section class="pt-[100px] pl-[70px] grid h-screen bg-immich-bg">
<AssetGrid />
</section>
</section>

View File

@@ -18,7 +18,6 @@
</div>
<div class="text-white flex gap-2">
<CircleIconButton logo={CloudDownloadOutline} on:click={() => dispatch('download')} />
<!-- <CircleIconButton logo={DotsVertical} on:click={() => console.log('Options')} /> -->
<CircleIconButton logo={InformationOutline} on:click={() => dispatch('showDetail')} />
</div>
</div>

View File

@@ -52,12 +52,12 @@
const navigateAssetForward = (e?: Event) => {
e?.stopPropagation();
dispatch('navigate-forward');
dispatch('navigate-next');
};
const navigateAssetBackward = (e?: Event) => {
e?.stopPropagation();
dispatch('navigate-backward');
dispatch('navigate-previous');
};
const showDetailInfoHandler = () => {
@@ -66,7 +66,6 @@
const downloadFile = async () => {
try {
console.log(asset.exifInfo);
const imageName = asset.exifInfo?.imageName ? asset.exifInfo?.imageName : asset.id;
const imageExtension = asset.originalPath.split('.')[1];
const imageFileName = imageName + '.' + imageExtension;
@@ -130,7 +129,7 @@
<section
id="immich-asset-viewer"
class="fixed h-screen w-screen top-0 overflow-y-hidden bg-black z-[999] grid grid-rows-[64px_1fr] grid-cols-4 "
class="fixed h-screen w-screen top-0 overflow-y-hidden bg-black z-[999] grid grid-rows-[64px_1fr] grid-cols-4"
>
<div class="col-start-1 col-span-4 row-start-1 row-span-1 z-[1000] transition-transform">
<AsserViewerNavBar
@@ -207,6 +206,10 @@
</section>
<style>
#immich-asset-viewer {
contain: layout;
}
.navigation-button-hover {
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
color: rgb(55 65 81 / var(--tw-text-opacity));

View File

@@ -1,28 +1,39 @@
<script lang="ts">
import { onMount } from 'svelte';
import { createEventDispatcher } from 'svelte';
export let once = false;
export let top = 0;
export let bottom = 0;
export let left = 0;
export let right = 0;
export let root: HTMLElement | null = null;
let intersecting = false;
let container: any;
const dispatch = createEventDispatcher();
onMount(() => {
if (typeof IntersectionObserver !== 'undefined') {
const rootMargin = `${bottom}px ${left}px ${top}px ${right}px`;
const rootMargin = `${top}px ${right}px ${bottom}px ${left}px`;
const observer = new IntersectionObserver(
(entries) => {
intersecting = entries[0].isIntersecting;
if (!intersecting) {
dispatch('hidden', container);
}
if (intersecting && once) {
observer.unobserve(container);
}
if (intersecting) {
dispatch('intersected', container);
}
},
{
rootMargin
rootMargin,
root
}
);

View File

@@ -1,9 +1,8 @@
<script lang="ts">
import { api } from '@api';
import { api, UserResponseDto } from '@api';
import { createEventDispatcher } from 'svelte';
import type { ImmichUser } from '../../models/immich-user';
export let user: ImmichUser;
export let user: UserResponseDto;
let error: string;
let success: string;

View File

@@ -0,0 +1,157 @@
<script lang="ts">
import { assetStore } from '$lib/stores/assets.store';
import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
import CircleOutline from 'svelte-material-icons/CircleOutline.svelte';
import { fly } from 'svelte/transition';
import { AssetResponseDto } from '@api';
import lodash from 'lodash-es';
import moment from 'moment';
import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
import { createEventDispatcher } from 'svelte';
import {
assetInteractionStore,
assetsInAlbumStoreState,
isMultiSelectStoreState,
selectedAssets,
selectedGroup
} from '$lib/stores/asset-interaction.store';
export let assets: AssetResponseDto[];
export let bucketDate: string;
export let bucketHeight: number;
const dispatch = createEventDispatcher();
let isMouseOverGroup = false;
let actualBucketHeight: number;
let hoveredDateGroup: string = '';
$: assetsGroupByDate = lodash
.chain(assets)
.groupBy((a) => moment(a.createdAt).format('ddd, MMM DD YYYY'))
.sortBy((group) => assets.indexOf(group[0]))
.value();
$: {
if (actualBucketHeight && actualBucketHeight != 0 && actualBucketHeight != bucketHeight) {
assetStore.updateBucketHeight(bucketDate, actualBucketHeight);
}
}
const assetClickHandler = (
asset: AssetResponseDto,
assetsInDateGroup: AssetResponseDto[],
dateGroupTitle: string
) => {
if ($isMultiSelectStoreState) {
assetSelectHandler(asset, assetsInDateGroup, dateGroupTitle);
} else {
assetInteractionStore.setViewingAsset(asset);
}
};
const selectAssetGroupHandler = (
selectAssetGroupHandler: AssetResponseDto[],
dateGroupTitle: string
) => {
if ($selectedGroup.has(dateGroupTitle)) {
assetInteractionStore.removeGroupFromMultiselectGroup(dateGroupTitle);
selectAssetGroupHandler.forEach((asset) => {
assetInteractionStore.removeAssetFromMultiselectGroup(asset);
});
} else {
assetInteractionStore.addGroupToMultiselectGroup(dateGroupTitle);
selectAssetGroupHandler.forEach((asset) => {
assetInteractionStore.addAssetToMultiselectGroup(asset);
});
}
};
const assetSelectHandler = (
asset: AssetResponseDto,
assetsInDateGroup: AssetResponseDto[],
dateGroupTitle: string
) => {
if ($selectedAssets.has(asset)) {
assetInteractionStore.removeAssetFromMultiselectGroup(asset);
} else {
assetInteractionStore.addAssetToMultiselectGroup(asset);
}
// Check if all assets are selected in a group to toggle the group selection's icon
let selectedAssetsInGroupCount = 0;
assetsInDateGroup.forEach((asset) => {
if ($selectedAssets.has(asset)) {
selectedAssetsInGroupCount++;
}
});
// if all assets are selected in a group, add the group to selected group
if (selectedAssetsInGroupCount == assetsInDateGroup.length) {
assetInteractionStore.addGroupToMultiselectGroup(dateGroupTitle);
} else {
assetInteractionStore.removeGroupFromMultiselectGroup(dateGroupTitle);
}
};
const assetMouseEventHandler = (dateGroupTitle: string) => {
// Show multi select icon on hover on date group
hoveredDateGroup = dateGroupTitle;
};
</script>
<section
id="asset-group-by-date"
class="flex flex-wrap gap-5 mt-5"
bind:clientHeight={actualBucketHeight}
>
{#each assetsGroupByDate as assetsInDateGroup, groupIndex (assetsInDateGroup[0].id)}
{@const dateGroupTitle = moment(assetsInDateGroup[0].createdAt).format('ddd, MMM DD YYYY')}
<!-- Asset Group By Date -->
<div
class="flex flex-col"
on:mouseenter={() => (isMouseOverGroup = true)}
on:mouseleave={() => (isMouseOverGroup = false)}
>
<!-- Date group title -->
<p class="font-medium text-sm text-immich-fg mb-2 flex place-items-center h-6">
{#if (hoveredDateGroup == dateGroupTitle && isMouseOverGroup) || $selectedGroup.has(dateGroupTitle)}
<div
transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
class="inline-block px-2 hover:cursor-pointer"
on:click={() => selectAssetGroupHandler(assetsInDateGroup, dateGroupTitle)}
>
{#if $selectedGroup.has(dateGroupTitle)}
<CheckCircle size="24" color="#4250af" />
{:else}
<CircleOutline size="24" color="#757575" />
{/if}
</div>
{/if}
<span>
{dateGroupTitle}
</span>
</p>
<!-- Image grid -->
<div class="flex flex-wrap gap-[2px]">
{#each assetsInDateGroup as asset (asset.id)}
<ImmichThumbnail
{asset}
{groupIndex}
on:click={() => assetClickHandler(asset, assetsInDateGroup, dateGroupTitle)}
on:select={() => assetSelectHandler(asset, assetsInDateGroup, dateGroupTitle)}
on:mouse-event={() => assetMouseEventHandler(dateGroupTitle)}
selected={$selectedAssets.has(asset)}
disabled={$assetsInAlbumStoreState.findIndex((a) => a.id == asset.id) != -1}
/>
{/each}
</div>
</div>
{/each}
</section>
<style>
#asset-group-by-date {
contain: layout;
}
</style>

View File

@@ -0,0 +1,119 @@
<script lang="ts">
import { onMount } from 'svelte';
import IntersectionObserver from '../asset-viewer/intersection-observer.svelte';
import { assetGridState, assetStore, loadingBucketState } from '$lib/stores/assets.store';
import { api, TimeGroupEnum } from '@api';
import AssetDateGroup from './asset-date-group.svelte';
import Portal from '../shared-components/portal/portal.svelte';
import AssetViewer from '../asset-viewer/asset-viewer.svelte';
import {
assetInteractionStore,
isViewingAssetStoreState,
viewingAssetStoreState
} from '$lib/stores/asset-interaction.store';
let viewportHeight = 0;
let viewportWidth = 0;
let assetGridElement: HTMLElement;
onMount(async () => {
const { data: assetCountByTimebucket } = await api.assetApi.getAssetCountByTimeBucket({
timeGroup: TimeGroupEnum.Month
});
assetStore.setInitialState(viewportHeight, viewportWidth, assetCountByTimebucket);
// Get asset bucket if bucket height is smaller than viewport height
let bucketsToFetchInitially: string[] = [];
let initialBucketsHeight = 0;
$assetGridState.buckets.every((bucket) => {
if (initialBucketsHeight < viewportHeight) {
initialBucketsHeight += bucket.bucketHeight;
bucketsToFetchInitially.push(bucket.bucketDate);
return true;
} else {
return false;
}
});
bucketsToFetchInitially.forEach((bucketDate) => {
assetStore.getAssetsByBucket(bucketDate);
});
});
function intersectedHandler(event: CustomEvent) {
const el = event.detail as HTMLElement;
const target = el.firstChild as HTMLElement;
if (target) {
const bucketDate = target.id.split('_')[1];
assetStore.getAssetsByBucket(bucketDate);
}
}
const navigateToPreviousAsset = () => {
assetInteractionStore.navigateAsset('previous');
};
const navigateToNextAsset = () => {
assetInteractionStore.navigateAsset('next');
};
</script>
<section
id="asset-grid"
class="overflow-y-auto pl-4"
bind:clientHeight={viewportHeight}
bind:clientWidth={viewportWidth}
bind:this={assetGridElement}
>
{#if assetGridElement}
<section id="virtual-timeline" style:height={$assetGridState.timelineHeight + 'px'}>
{#each $assetGridState.buckets as bucket, bucketIndex (bucketIndex)}
<IntersectionObserver
on:intersected={intersectedHandler}
on:hidden={async () => {
// If bucket is hidden and in loading state, cancel the request
if ($loadingBucketState[bucket.bucketDate]) {
await assetStore.cancelBucketRequest(bucket.cancelToken, bucket.bucketDate);
}
}}
let:intersecting
top={750}
bottom={750}
root={assetGridElement}
>
<div id={'bucket_' + bucket.bucketDate} style:height={bucket.bucketHeight + 'px'}>
{#if intersecting}
<AssetDateGroup
assets={bucket.assets}
bucketDate={bucket.bucketDate}
bucketHeight={bucket.bucketHeight}
/>
{/if}
</div>
</IntersectionObserver>
{/each}
</section>
{/if}
</section>
<Portal target="body">
{#if $isViewingAssetStoreState}
<AssetViewer
asset={$viewingAssetStoreState}
on:navigate-previous={navigateToPreviousAsset}
on:navigate-next={navigateToNextAsset}
on:close={() => {
assetInteractionStore.setIsViewingAsset(false);
}}
/>
{/if}
</Portal>
<style>
#asset-grid {
contain: layout;
}
</style>

View File

@@ -15,32 +15,19 @@
export let thumbnailSize: number | undefined = undefined;
export let format: ThumbnailFormat = ThumbnailFormat.Webp;
export let selected: boolean = false;
export let isExisted: boolean = false;
export let disabled: boolean = false;
let imageData: string;
// let videoData: string;
let mouseOver: boolean = false;
$: dispatch('mouseEvent', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
$: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
let mouseOverIcon: boolean = false;
let videoPlayerNode: HTMLVideoElement;
let isThumbnailVideoPlaying = false;
let calculateVideoDurationIntervalHandler: NodeJS.Timer;
let videoProgress = '00:00';
// let videoAbortController: AbortController;
let videoUrl: string;
const loadImageData = async () => {
const { data } = await api.assetApi.getAssetThumbnail(asset.id, format, {
responseType: 'blob'
});
if (data instanceof Blob) {
imageData = URL.createObjectURL(data);
return imageData;
}
};
const loadVideoData = async () => {
isThumbnailVideoPlaying = false;
@@ -117,7 +104,7 @@
$: getThumbnailBorderStyle = () => {
if (selected) {
return 'border-[20px] border-immich-primary/20';
} else if (isExisted) {
} else if (disabled) {
return 'border-[20px] border-gray-300';
} else {
return '';
@@ -125,36 +112,38 @@
};
$: getOverlaySelectorIconStyle = () => {
if (selected || isExisted) {
if (selected || disabled) {
return '';
} else {
return 'bg-gradient-to-b from-gray-800/50';
}
};
const thumbnailClickedHandler = () => {
if (!isExisted) {
if (!disabled) {
dispatch('click', { asset });
}
};
const onIconClickedHandler = (e: MouseEvent) => {
e.stopPropagation();
dispatch('select', { asset });
if (!disabled) {
dispatch('select', { asset });
}
};
</script>
<IntersectionObserver once={true} let:intersecting>
<IntersectionObserver once={false} let:intersecting>
<div
style:width={`${thumbnailSize}px`}
style:height={`${thumbnailSize}px`}
class={`bg-gray-100 relative ${getSize()} ${
isExisted ? 'cursor-not-allowed' : 'hover:cursor-pointer'
disabled ? 'cursor-not-allowed' : 'hover:cursor-pointer'
}`}
on:mouseenter={handleMouseOverThumbnail}
on:mouseleave={handleMouseLeaveThumbnail}
on:click={thumbnailClickedHandler}
>
{#if mouseOver || selected || isExisted}
{#if mouseOver || selected || disabled}
<div
in:fade={{ duration: 200 }}
class={`w-full ${getOverlaySelectorIconStyle()} via-white/0 to-white/0 absolute p-2 z-10`}
@@ -167,7 +156,7 @@
>
{#if selected}
<CheckCircle size="24" color="#4250af" />
{:else if isExisted}
{:else if disabled}
<CheckCircle size="24" color="#252525" />
{:else}
<CheckCircle size="24" color={mouseOverIcon ? 'white' : '#d8dadb'} />
@@ -212,12 +201,13 @@
<!-- Thumbnail -->
{#if intersecting}
<img
id={asset.id}
style:width={`${thumbnailSize}px`}
style:height={`${thumbnailSize}px`}
in:fade={{ duration: 250 }}
in:fade={{ duration: 150 }}
src={`/api/asset/thumbnail/${asset.id}?format=${format}`}
alt={asset.id}
class={`object-cover ${getSize()} transition-all duration-100 z-0 ${getThumbnailBorderStyle()}`}
class={`object-cover ${getSize()} transition-all z-0 ${getThumbnailBorderStyle()}`}
loading="lazy"
/>
{/if}

View File

@@ -0,0 +1,60 @@
<script context="module" lang="ts">
import { tick } from 'svelte';
/**
* Usage: <div use:portal={'css selector'}> or <div use:portal={document.body}>
*
* @param {HTMLElement} el
* @param {HTMLElement|string} target DOM Element or CSS Selector
*/
export function portal(el: any, target: any = 'body') {
let targetEl;
async function update(newTarget: any) {
target = newTarget;
if (typeof target === 'string') {
targetEl = document.querySelector(target);
if (targetEl === null) {
await tick();
targetEl = document.querySelector(target);
}
if (targetEl === null) {
throw new Error(`No element found matching css selector: "${target}"`);
}
} else if (target instanceof HTMLElement) {
targetEl = target;
} else {
throw new TypeError(
`Unknown portal target type: ${
target === null ? 'null' : typeof target
}. Allowed types: string (CSS selector) or HTMLElement.`
);
}
targetEl.appendChild(el);
el.hidden = false;
}
function destroy() {
if (el.parentNode) {
el.parentNode.removeChild(el);
}
}
update(target);
return {
update,
destroy
};
}
</script>
<script>
/**
* DOM Element or CSS Selector
* @type { HTMLElement|string}
*/
export let target = 'body';
</script>
<div use:portal={target} hidden>
<slot />
</div>

View File

@@ -0,0 +1,122 @@
<script lang="ts">
import { onMount } from 'svelte';
import { SegmentScrollbarLayout } from './segment-scrollbar-layout';
export let scrollTop = 0;
export let viewportWidth = 0;
export let scrollbarHeight = 0;
let timelineHeight = 0;
let segmentScrollbarLayout: SegmentScrollbarLayout[] = [];
let isHover = false;
let hoveredDate: Date;
let currentMouseYLocation: number = 0;
let scrollbarPosition = 0;
$: {
scrollbarPosition = (scrollTop / timelineHeight) * scrollbarHeight;
}
$: {
// let result: SegmentScrollbarLayout[] = [];
// for (const [i, segment] of assetStoreState.entries()) {
// let segmentLayout = new SegmentScrollbarLayout();
// segmentLayout.count = segmentData.groups[i].count;
// segmentLayout.height =
// segment.assets.length == 0
// ? getSegmentHeight(segmentData.groups[i].count)
// : Math.round((segment.segmentHeight / timelineHeight) * scrollbarHeight);
// segmentLayout.timeGroup = segment.segmentDate;
// result.push(segmentLayout);
// }
// segmentScrollbarLayout = result;
}
onMount(() => {
// segmentScrollbarLayout = getLayoutDistance();
return () => {};
});
const getSegmentHeight = (groupCount: number) => {
// if (segmentData.groups.length > 0) {
// const percentage = (groupCount * 100) / segmentData.totalAssets;
// return Math.round((percentage * scrollbarHeight) / 100);
// } else {
// return 0;
// }
};
const getLayoutDistance = () => {
// let result: SegmentScrollbarLayout[] = [];
// for (const segment of segmentData.groups) {
// let segmentLayout = new SegmentScrollbarLayout();
// segmentLayout.count = segment.count;
// segmentLayout.height = getSegmentHeight(segment.count);
// segmentLayout.timeGroup = segment.timeGroup;
// result.push(segmentLayout);
// }
// return result;
};
const handleMouseMove = (e: MouseEvent, currentDate: Date) => {
currentMouseYLocation = e.clientY - 71 - 30;
hoveredDate = new Date(currentDate.toISOString().slice(0, -1));
};
</script>
<div
id="immich-scubbable-scrollbar"
class="fixed right-0 w-[60px] h-full bg-immich-bg z-[9999] hover:cursor-row-resize"
on:mouseenter={() => (isHover = true)}
on:mouseleave={() => (isHover = false)}
>
{#if isHover}
<div
class="border-b-2 border-immich-primary w-[100px] right-0 pr-6 py-1 text-sm pl-1 font-medium absolute bg-white z-50 pointer-events-none rounded-tl-md shadow-lg"
style:top={currentMouseYLocation + 'px'}
>
{hoveredDate?.toLocaleString('default', { month: 'short' })}
{hoveredDate?.getFullYear()}
</div>
{/if}
<!-- Scroll Position Indicator Line -->
<div
class="absolute right-0 w-10 h-[2px] bg-immich-primary"
style:top={scrollbarPosition + 'px'}
/>
<!-- Time Segment -->
{#each segmentScrollbarLayout as segment, index (segment.timeGroup)}
{@const groupDate = new Date(segment.timeGroup)}
<div
class="relative "
style:height={segment.height + 'px'}
aria-label={segment.timeGroup + ' ' + segment.count}
on:mousemove={(e) => handleMouseMove(e, groupDate)}
>
{#if new Date(segmentScrollbarLayout[index - 1]?.timeGroup).getFullYear() !== groupDate.getFullYear()}
<div
aria-label={segment.timeGroup + ' ' + segment.count}
class="absolute right-0 pr-3 z-10 text-xs font-medium"
>
{groupDate.getFullYear()}
</div>
{:else if segment.count > 5}
<div
aria-label={segment.timeGroup + ' ' + segment.count}
class="absolute right-0 rounded-full h-[4px] w-[4px] mr-3 bg-gray-300 block"
/>
{/if}
</div>
{/each}
</div>
<style>
#immich-scubbable-scrollbar {
contain: layout;
}
</style>

View File

@@ -0,0 +1,5 @@
export class SegmentScrollbarLayout {
height!: number;
timeGroup!: string;
count!: number;
}

View File

@@ -5,7 +5,7 @@
import CloudUploadOutline from 'svelte-material-icons/CloudUploadOutline.svelte';
import WindowMinimize from 'svelte-material-icons/WindowMinimize.svelte';
import type { UploadAsset } from '$lib/models/upload-asset';
import { getAssetsInfo } from '$lib/stores/assets';
// import { getAssetsInfo } fro$lib/stores/assets.storeets';
let showDetail = true;
let uploadLength = 0;
@@ -83,7 +83,9 @@
<div
in:fade={{ duration: 250 }}
out:fade={{ duration: 250, delay: 1000 }}
on:outroend={() => getAssetsInfo()}
on:outroend={() => {
// getAssetsInfo()
}}
class="absolute right-6 bottom-6 z-[10000]"
>
{#if showDetail}