Search results use photostream

This commit is contained in:
midzelis
2025-09-18 21:09:21 +00:00
parent 10f599bee1
commit 2accf351bd
23 changed files with 786 additions and 454 deletions
@@ -85,8 +85,8 @@
onerror={setErrored} onerror={setErrored}
style:width={widthStyle} style:width={widthStyle}
style:height={heightStyle} style:height={heightStyle}
style:filter={hidden ? 'grayscale(50%)' : 'none'}
style:opacity={hidden ? '0.5' : '1'} style:opacity={hidden ? '0.5' : '1'}
style:filter="blur(7px)"
src={url} src={url}
alt={loaded || errored ? altText : ''} alt={loaded || errored ? altText : ''}
{title} {title}
@@ -88,6 +88,7 @@
> >
<img <img
class="h-full w-full rounded-xl object-cover" class="h-full w-full rounded-xl object-cover"
style:filter="blur(5px)"
src={getAssetThumbnailUrl(memory.assets[0].id)} src={getAssetThumbnailUrl(memory.assets[0].id)}
alt={$t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } })} alt={$t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } })}
draggable="false" draggable="false"
@@ -0,0 +1,73 @@
<script lang="ts">
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import AssetLayout from '$lib/components/timeline/base-components/AssetLayout.svelte';
import BaseTimelineViewer from '$lib/components/timeline/base-components/base-timeline-viewer.svelte';
import SelectableTimelineMonth from '$lib/components/timeline/internal-components/selectable-timeline-month.svelte';
import Skeleton from '$lib/elements/Skeleton.svelte';
import { SearchStreamManager } from '$lib/managers/timeline-manager/SearchStreamManager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
let { isViewing: showAssetViewer } = assetViewingStore;
interface Props {
searchTerms: any;
}
let { searchTerms }: Props = $props();
let viewer: BaseTimelineViewer | undefined = $state();
let showSkeleton: boolean = $state(true);
const timelineManager = new SearchStreamManager(searchTerms, { isSmartSearchEnabled: true });
timelineManager.init();
const assetInteraction = new AssetInteraction();
</script>
<BaseTimelineViewer
showScrollbar={true}
enableRouting={false}
{timelineManager}
isShowDeleteConfirmation={true}
{showSkeleton}
>
{#snippet skeleton({ segment })}
<Skeleton height={segment.height - segment.timelineManager.headerHeight} title={''} />
{/snippet}
{#snippet segment({ segment, onScrollCompensationMonthInDOM })}
<SelectableTimelineMonth
{segment}
{onScrollCompensationMonthInDOM}
{timelineManager}
{assetInteraction}
isSelectionMode={false}
singleSelect={false}
>
{#snippet content({ onAssetOpen, onAssetSelect, onHover })}
<AssetLayout
photostreamManager={timelineManager}
viewerAssets={segment.viewerAssets}
height={segment.height}
width={timelineManager.viewportWidth}
>
{#snippet thumbnail({ asset, position })}
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
{@const isAssetSelected = assetInteraction.hasSelectedAsset(asset.id)}
<Thumbnail
showStackedIcon={true}
showArchiveIcon={true}
{asset}
onClick={() => onAssetOpen(asset)}
onSelect={() => onAssetSelect(asset)}
onMouseEvent={() => onHover(asset)}
selected={isAssetSelected}
selectionCandidate={isAssetSelectionCandidate}
thumbnailWidth={position.width}
thumbnailHeight={position.height}
/>
{/snippet}
</AssetLayout>
{/snippet}
</SelectableTimelineMonth>
{/snippet}
</BaseTimelineViewer>
+61 -10
View File
@@ -1,10 +1,16 @@
<script lang="ts"> <script lang="ts">
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import TimelineKeyboardActions from '$lib/components/timeline/actions/timeline-keyboard-actions.svelte'; import TimelineKeyboardActions from '$lib/components/timeline/actions/timeline-keyboard-actions.svelte';
import BaseTimeline from '$lib/components/timeline/base-components/base-timeline.svelte'; import BaseTimeline from '$lib/components/timeline/base-components/base-timeline.svelte';
import TimelineMonth from '$lib/components/timeline/base-components/timeline-month.svelte';
import SelectableTimelineDaygroup from '$lib/components/timeline/internal-components/selectable-timeline-daygroup.svelte';
import SelectableTimelineMonth from '$lib/components/timeline/internal-components/selectable-timeline-month.svelte';
import TimelineAssetViewer from '$lib/components/timeline/internal-components/timeline-asset-viewer.svelte'; import TimelineAssetViewer from '$lib/components/timeline/internal-components/timeline-asset-viewer.svelte';
import type { AssetAction } from '$lib/constants'; import type { AssetAction } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte'; import Portal from '$lib/elements/Portal.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte'; import Skeleton from '$lib/elements/Skeleton.svelte';
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
@@ -71,21 +77,66 @@
<BaseTimeline <BaseTimeline
bind:this={viewer} bind:this={viewer}
{customThumbnailLayout}
{isSelectionMode}
{singleSelect}
{enableRouting} {enableRouting}
{timelineManager} {timelineManager}
{assetInteraction}
{withStacked}
{showArchiveIcon}
{isShowDeleteConfirmation} {isShowDeleteConfirmation}
{showSkeleton} {showSkeleton}
{onAssetOpen}
{onSelect}
{children} {children}
{empty} {empty}
/> >
{#snippet skeleton({ segment })}
<Skeleton
height={segment.height - segment.timelineManager.headerHeight}
title={(segment as MonthGroup).monthGroupTitle}
/>
{/snippet}
{#snippet segment({ segment, onScrollCompensationMonthInDOM })}
<SelectableTimelineMonth
{segment}
{onScrollCompensationMonthInDOM}
{timelineManager}
{assetInteraction}
{isSelectionMode}
{singleSelect}
>
{#snippet content({ onAssetOpen, onAssetSelect, onHover })}
<SelectableTimelineDaygroup {timelineManager} {assetInteraction} {onAssetSelect}>
{#snippet content({ onDayGroupSelect, onDayGroupAssetSelect })}
<TimelineMonth
{assetInteraction}
{customThumbnailLayout}
{singleSelect}
monthGroup={segment as MonthGroup}
{timelineManager}
{onDayGroupSelect}
>
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
{@const isAssetSelected =
assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)}
{@const isAssetDisabled = timelineManager.albumAssets.has(asset.id)}
<Thumbnail
showStackedIcon={withStacked}
{showArchiveIcon}
{asset}
{groupIndex}
onClick={() => onAssetOpen(asset)}
onSelect={() => onDayGroupAssetSelect(dayGroup, asset)}
onMouseEvent={() => onHover(asset)}
selected={isAssetSelected}
selectionCandidate={isAssetSelectionCandidate}
disabled={isAssetDisabled}
thumbnailWidth={position.width}
thumbnailHeight={position.height}
/>
{/snippet}
</TimelineMonth>
{/snippet}
</SelectableTimelineDaygroup>
{/snippet}
</SelectableTimelineMonth>
{/snippet}
</BaseTimeline>
<TimelineKeyboardActions <TimelineKeyboardActions
scrollToAsset={(asset) => viewer?.scrollToAsset(asset) ?? false} scrollToAsset={(asset) => viewer?.scrollToAsset(asset) ?? false}
@@ -0,0 +1,68 @@
<script lang="ts">
import { uploadAssetsStore } from '$lib/stores/upload';
import { flip } from 'svelte/animate';
import { scale } from 'svelte/transition';
import type { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
import type { CommonPosition } from '$lib/utils/layout-utils';
import type { Snippet } from 'svelte';
let { isUploading } = uploadAssetsStore;
interface Props {
viewerAssets: ViewerAsset[];
width: number;
height: number;
photostreamManager: PhotostreamManager;
thumbnail: Snippet<
[
{
asset: TimelineAsset;
position: CommonPosition;
},
]
>;
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>;
}
let { viewerAssets, width, height, photostreamManager, thumbnail, customThumbnailLayout }: Props = $props();
const transitionDuration = $derived.by(() => (photostreamManager.suspendTransitions && !$isUploading ? 0 : 150));
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
function filterIntersecting<R extends { intersecting: boolean }>(intersectables: R[]) {
return intersectables.filter((intersectable) => intersectable.intersecting);
}
</script>
<!-- Image grid -->
<div data-image-grid class="relative overflow-clip" style:height={height + 'px'} style:width={width + 'px'}>
{#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!}
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
<div
data-asset-id={asset.id}
class="absolute"
style:top={position.top + 'px'}
style:left={position.left + 'px'}
style:width={position.width + 'px'}
style:height={position.height + 'px'}
out:scale|global={{ start: 0.1, duration: scaleDuration }}
animate:flip={{ duration: transitionDuration }}
>
{@render thumbnail({ asset, position })}
{@render customThumbnailLayout?.(asset)}
</div>
{/each}
</div>
<style>
[data-image-grid] {
user-select: none;
}
</style>
@@ -2,37 +2,43 @@
import { afterNavigate, beforeNavigate } from '$app/navigation'; import { afterNavigate, beforeNavigate } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer'; import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import Skeleton from '$lib/components/timeline/base-components/skeleton.svelte';
import SelectableTimelineMonth from '$lib/components/timeline/internal-components/selectable-timeline-month.svelte';
import HotModuleReload from '$lib/elements/HotModuleReload.svelte'; import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte'; import type { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte'; import type { PhotostreamSegment } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { onMount, type Snippet } from 'svelte'; import { onMount, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite'; import type { UpdatePayload } from 'vite';
interface Props { interface Props {
customThumbnailLayout?: Snippet<[TimelineAsset]>; segment: Snippet<
[
{
segment: PhotostreamSegment;
scrollToFunction: (top: number) => void;
onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
},
]
>;
skeleton: Snippet<
[
{
segment: PhotostreamSegment;
},
]
>;
isSelectionMode?: boolean; showScrollbar?: boolean;
singleSelect?: boolean;
/** `true` if this asset grid responds to navigation events; if `true`, then look at the /** `true` if this asset grid responds to navigation events; if `true`, then look at the
`AssetViewingStore.gridScrollTarget` and load and scroll to the asset specified, and `AssetViewingStore.gridScrollTarget` and load and scroll to the asset specified, and
additionally, update the page location/url with the asset as the asset-grid is scrolled */ additionally, update the page location/url with the asset as the asset-grid is scrolled */
enableRouting: boolean; enableRouting: boolean;
timelineManager: TimelineManager; timelineManager: PhotostreamManager;
assetInteraction: AssetInteraction;
withStacked?: boolean;
showArchiveIcon?: boolean;
showSkeleton?: boolean; showSkeleton?: boolean;
isShowDeleteConfirmation?: boolean; isShowDeleteConfirmation?: boolean;
styleMarginRightOverride?: string; styleMarginRightOverride?: string;
onAssetOpen?: (dayGroup: DayGroup, asset: TimelineAsset, defaultAssetOpen: () => void) => void;
onSelect?: (asset: TimelineAsset) => void;
header?: Snippet<[scrollToFunction: (top: number) => void]>; header?: Snippet<[scrollToFunction: (top: number) => void]>;
children?: Snippet; children?: Snippet;
empty?: Snippet; empty?: Snippet;
@@ -40,22 +46,16 @@
} }
let { let {
customThumbnailLayout, segment,
isSelectionMode = false,
singleSelect = false,
enableRouting, enableRouting,
timelineManager = $bindable(), timelineManager = $bindable(),
assetInteraction,
withStacked = false,
showSkeleton = $bindable(true), showSkeleton = $bindable(true),
showArchiveIcon = false,
styleMarginRightOverride, styleMarginRightOverride,
isShowDeleteConfirmation = $bindable(false), isShowDeleteConfirmation = $bindable(false),
showScrollbar,
onAssetOpen,
onSelect,
children, children,
skeleton,
empty, empty,
header, header,
handleTimelineScroll = () => {}, handleTimelineScroll = () => {},
@@ -96,7 +96,7 @@
updateSlidingWindow(); updateSlidingWindow();
}; };
const scrollCompensation = (compensation: { heightDelta?: number; scrollTop?: number }) => { const handleTriggeredScrollCompensation = (compensation: { heightDelta?: number; scrollTop?: number }) => {
const { heightDelta, scrollTop } = compensation; const { heightDelta, scrollTop } = compensation;
if (heightDelta !== undefined) { if (heightDelta !== undefined) {
scrollBy(heightDelta); scrollBy(heightDelta);
@@ -106,16 +106,16 @@
timelineManager.clearScrollCompensation(); timelineManager.clearScrollCompensation();
}; };
const getAssetHeight = (assetId: string, monthGroup: MonthGroup) => { const getAssetHeight = (assetId: string, monthGroup: PhotostreamSegment) => {
// the following method may trigger any layouts, so need to // the following method may trigger any layouts, so need to
// handle any scroll compensation that may have been set // handle any scroll compensation that may have been set
const height = monthGroup!.findAssetAbsolutePosition(assetId); const height = monthGroup.findAssetAbsolutePosition(assetId);
// this is in a while loop, since scrollCompensations invoke scrolls // this is in a while loop, since scrollCompensations invoke scrolls
// which may load months, triggering more scrollCompensations. Call // which may load months, triggering more scrollCompensations. Call
// this in a loop, until no more layouts occur. // this in a loop, until no more layouts occur.
while (timelineManager.scrollCompensation.monthGroup) { while (timelineManager.scrollCompensation.monthGroup) {
scrollCompensation(timelineManager.scrollCompensation); handleTriggeredScrollCompensation(timelineManager.scrollCompensation);
} }
return height; return height;
}; };
@@ -129,8 +129,8 @@
return assetTop >= scrollTop && assetTop < scrollTop + clientHeight; return assetTop >= scrollTop && assetTop < scrollTop + clientHeight;
}; };
const scrollToAssetId = async (assetId: string) => { export const scrollToAssetId = (assetId: string) => {
const monthGroup = await timelineManager.findMonthGroupForAsset(assetId); const monthGroup = timelineManager.getSegmentForAssetId(assetId);
if (!monthGroup) { if (!monthGroup) {
return false; return false;
} }
@@ -145,16 +145,6 @@
return true; return true;
}; };
export const scrollToAsset = (asset: TimelineAsset) => {
const monthGroup = timelineManager.getMonthGroupByAssetId(asset.id);
if (!monthGroup) {
return false;
}
const height = getAssetHeight(asset.id, monthGroup);
scrollTo(height);
return true;
};
const completeNav = async () => { const completeNav = async () => {
const scrollTarget = $gridScrollTarget?.at; const scrollTarget = $gridScrollTarget?.at;
let scrolled = false; let scrolled = false;
@@ -219,8 +209,14 @@
<!-- Right margin MUST be equal to the width of scrubber --> <!-- Right margin MUST be equal to the width of scrubber -->
<section <section
id="asset-grid" id="asset-grid"
class={['scrollbar-hidden h-full overflow-y-auto outline-none', { 'm-0': isEmpty }, { 'ms-0': !isEmpty }]} class={[
'h-full overflow-y-auto outline-none',
{ 'scrollbar-hidden': !showScrollbar },
{ 'm-0': isEmpty },
{ 'ms-0': !isEmpty },
]}
style:margin-right={styleMarginRightOverride} style:margin-right={styleMarginRightOverride}
style:scrollbar-width={showScrollbar ? 'auto' : 'none'}
tabindex="-1" tabindex="-1"
bind:clientHeight={timelineManager.viewportHeight} bind:clientHeight={timelineManager.viewportHeight}
bind:clientWidth={null, (v: number) => ((timelineManager.viewportWidth = v), updateSlidingWindow())} bind:clientWidth={null, (v: number) => ((timelineManager.viewportWidth = v), updateSlidingWindow())}
@@ -247,50 +243,26 @@
{/if} {/if}
</section> </section>
{#each timelineManager.months as monthGroup (monthGroup.viewId)} {#each timelineManager.months as monthGroup (monthGroup.id)}
{@const display = monthGroup.intersecting} {@const shouldDisplay = monthGroup.intersecting}
{@const absoluteHeight = monthGroup.top} {@const absoluteHeight = monthGroup.top}
<div
{#if !monthGroup.isLoaded} class="month-group"
<div style:height={monthGroup.height + 'px'}
style:height={monthGroup.height + 'px'} 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%" >
> {#if !shouldDisplay}
<Skeleton {@render skeleton({ segment: monthGroup })}
height={monthGroup.height - monthGroup.timelineManager.headerHeight} {:else}
title={monthGroup.monthGroupTitle} {@render segment({
/> segment: monthGroup,
</div> scrollToFunction: scrollTo,
{:else if display} onScrollCompensationMonthInDOM: handleTriggeredScrollCompensation,
<div })}
class="month-group" {/if}
style:height={monthGroup.height + 'px'} </div>
style:position="absolute"
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:width="100%"
>
<SelectableTimelineMonth
{customThumbnailLayout}
{withStacked}
{showArchiveIcon}
{assetInteraction}
{timelineManager}
{isSelectionMode}
{singleSelect}
{monthGroup}
{onAssetOpen}
onSelect={(isSingleSelect: boolean, asset: TimelineAsset) => {
if (isSingleSelect) {
scrollTo(0);
}
onSelect?.(asset);
}}
onScrollCompensationMonthInDOM={scrollCompensation}
/>
</div>
{/if}
{/each} {/each}
<!-- spacer for lead-out --> <!-- spacer for lead-out -->
<div <div
@@ -308,7 +280,6 @@
contain: strict; contain: strict;
scrollbar-width: none; scrollbar-width: none;
} }
.month-group { .month-group {
contain: layout size paint; contain: layout size paint;
transform-style: flat; transform-style: flat;
@@ -1,52 +1,50 @@
<script lang="ts"> <script lang="ts">
import BaseTimelineViewer from '$lib/components/timeline/base-components/base-timeline-viewer.svelte'; import BaseTimelineViewer from '$lib/components/timeline/base-components/base-timeline-viewer.svelte';
import Scrubber from '$lib/components/timeline/Scrubber.svelte'; import Scrubber from '$lib/components/timeline/Scrubber.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte'; import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { PhotostreamSegment } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { findMonthAtScrollPosition, type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util'; import { findMonthAtScrollPosition, type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
interface Props { interface Props {
customThumbnailLayout?: Snippet<[TimelineAsset]>;
isSelectionMode?: boolean;
singleSelect?: boolean;
/** `true` if this timeline responds to navigation events; if `true`, then look at the /** `true` if this timeline responds to navigation events; if `true`, then look at the
`AssetViewingStore.gridScrollTarget` and load and scroll to the asset specified, and `AssetViewingStore.gridScrollTarget` and load and scroll to the asset specified, and
additionally, update the page location/url with the asset as the asset-grid is scrolled */ additionally, update the page location/url with the asset as the asset-grid is scrolled */
enableRouting: boolean; enableRouting: boolean;
timelineManager: TimelineManager; timelineManager: TimelineManager;
assetInteraction: AssetInteraction;
withStacked?: boolean;
showArchiveIcon?: boolean;
showSkeleton?: boolean; showSkeleton?: boolean;
isShowDeleteConfirmation?: boolean; isShowDeleteConfirmation?: boolean;
onAssetOpen?: (dayGroup: DayGroup, asset: TimelineAsset, defaultAssetOpen: () => void) => void;
onSelect?: (asset: TimelineAsset) => void;
segment: Snippet<
[
{
segment: PhotostreamSegment;
onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
},
]
>;
skeleton: Snippet<
[
{
segment: PhotostreamSegment;
},
]
>;
children?: Snippet; children?: Snippet;
empty?: Snippet; empty?: Snippet;
} }
let { let {
customThumbnailLayout,
isSelectionMode = false,
singleSelect = false,
enableRouting, enableRouting,
timelineManager = $bindable(), timelineManager = $bindable(),
assetInteraction,
withStacked = false,
showArchiveIcon = false,
showSkeleton = $bindable(true), showSkeleton = $bindable(true),
isShowDeleteConfirmation = $bindable(false), isShowDeleteConfirmation = $bindable(false),
onAssetOpen, segment,
onSelect = () => {}, skeleton,
children, children,
empty, empty,
}: Props = $props(); }: Props = $props();
@@ -160,27 +158,21 @@
handleScrollTop?.(scrollToTop); handleScrollTop?.(scrollToTop);
}; };
let baseTimelineViewer: BaseTimelineViewer | undefined = $state(); let baseTimelineViewer: BaseTimelineViewer | undefined = $state();
export const scrollToAsset = (asset: TimelineAsset) => baseTimelineViewer?.scrollToAsset(asset) ?? false; export const scrollToAsset = (asset: TimelineAsset) => baseTimelineViewer?.scrollToAssetId(asset.id) ?? false;
</script> </script>
<BaseTimelineViewer <BaseTimelineViewer
bind:this={baseTimelineViewer} bind:this={baseTimelineViewer}
{customThumbnailLayout}
{isSelectionMode}
{singleSelect}
{enableRouting} {enableRouting}
{timelineManager} {timelineManager}
{assetInteraction}
{withStacked}
{showArchiveIcon}
{showSkeleton} {showSkeleton}
{isShowDeleteConfirmation} {isShowDeleteConfirmation}
styleMarginRightOverride={scrubberWidth + 'px'} styleMarginRightOverride={scrubberWidth + 'px'}
{onAssetOpen} {handleTimelineScroll}
{onSelect} {segment}
{skeleton}
{children} {children}
{empty} {empty}
{handleTimelineScroll}
> >
{#snippet header(scrollToFunction)} {#snippet header(scrollToFunction)}
{#if timelineManager.months.length > 0} {#if timelineManager.months.length > 0}
@@ -0,0 +1,73 @@
<script lang="ts">
import Skeleton from '$lib/elements/Skeleton.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { type Snippet } from 'svelte';
interface Props {
segment: MonthGroup;
timelineManager: TimelineManager;
customThumbnailLayout?: Snippet<[TimelineAsset]>;
isSelectionMode?: boolean;
singleSelect?: boolean;
assetInteraction: AssetInteraction;
withStacked?: boolean;
showArchiveIcon?: boolean;
scrollToFunction: (top: number) => void;
onAssetOpen?: (dayGroup: DayGroup, asset: TimelineAsset, defaultAssetOpen: () => void) => void;
onSelect?: (asset: TimelineAsset) => void;
onTriggeredScrollCompensation: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
}
let {
segment: monthGroup,
timelineManager,
customThumbnailLayout,
isSelectionMode = false,
singleSelect = false,
assetInteraction,
withStacked = false,
showArchiveIcon = false,
scrollToFunction,
onAssetOpen,
onSelect,
onTriggeredScrollCompensation,
}: Props = $props();
const shouldDisplay = $derived(monthGroup.intersecting);
const absoluteHeight = $derived(monthGroup.top);
</script>
<p id="a">a</p>
{#if !shouldDisplay}
<div
style:height={monthGroup.height + 'px'}
style:position="absolute"
style:transform={`translate3d(0,${monthGroup.top}px,0)`}
style:width="100%"
>
<Skeleton height={monthGroup.height - monthGroup.timelineManager.headerHeight} title={monthGroup.monthGroupTitle} />
</div>
{:else}
<div
class="month-group"
style:height={monthGroup.height + 'px'}
style:position="absolute"
style:transform={`translate3d(0,${monthGroup.top}px,0)`}
style:width="100%"
>
{@render contents()}
</div>
{/if}
<style>
.month-group {
contain: layout size paint;
transform-style: flat;
backface-visibility: hidden;
transform-origin: center center;
}
</style>
@@ -1,64 +1,42 @@
<script lang="ts"> <script lang="ts">
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte'; import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetSnapshot, assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte'; import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import { uploadAssetsStore } from '$lib/stores/upload'; import { uploadAssetsStore } from '$lib/stores/upload';
import { Icon } from '@immich/ui'; import { Icon } from '@immich/ui';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js'; import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import { flip } from 'svelte/animate'; import { fly } from 'svelte/transition';
import { fly, scale } from 'svelte/transition';
import AssetLayout from '$lib/components/timeline/base-components/AssetLayout.svelte';
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte'; import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import type { CommonPosition } from '$lib/utils/layout-utils';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
let { isUploading } = uploadAssetsStore; let { isUploading } = uploadAssetsStore;
interface Props { interface Props {
thumbnail: Snippet<[{ asset: TimelineAsset; position: CommonPosition; dayGroup: DayGroup; groupIndex: number }]>;
customThumbnailLayout?: Snippet<[TimelineAsset]>; customThumbnailLayout?: Snippet<[TimelineAsset]>;
singleSelect: boolean; singleSelect: boolean;
withStacked: boolean; assetInteraction: AssetInteraction;
showArchiveIcon: boolean;
monthGroup: MonthGroup; monthGroup: MonthGroup;
timelineManager: TimelineManager; timelineManager: TimelineManager;
onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void; onDayGroupSelect: (daygroup: DayGroup, assets: TimelineAsset[]) => void;
onHover: (dayGroup: DayGroup, asset: TimelineAsset) => void;
onAssetOpen?: (dayGroup: DayGroup, asset: TimelineAsset) => void;
onAssetSelect: (dayGroup: DayGroup, asset: TimelineAsset) => void;
onDayGroupSelect: (dayGroup: DayGroup, assets: TimelineAsset[]) => void;
// these should be replaced with reactive properties in timeline-manager.svelte.ts
isDayGroupSelected: (dayGroup: DayGroup) => boolean;
isAssetSelected: (asset: TimelineAsset) => boolean;
isAssetSelectionCandidate: (asset: TimelineAsset) => boolean;
isAssetDisabled: (asset: TimelineAsset) => boolean;
} }
let { let {
thumbnail: thumbnailWithGroup,
customThumbnailLayout, customThumbnailLayout,
singleSelect, singleSelect,
withStacked, assetInteraction,
showArchiveIcon,
monthGroup, monthGroup,
timelineManager, timelineManager,
onScrollCompensationMonthInDOM,
onHover,
onAssetOpen,
onAssetSelect,
onDayGroupSelect, onDayGroupSelect,
isDayGroupSelected,
isAssetSelected,
isAssetSelectionCandidate,
isAssetDisabled,
}: Props = $props(); }: Props = $props();
let isMouseOverGroup = $state(false); let isMouseOverGroup = $state(false);
@@ -67,22 +45,15 @@
const transitionDuration = $derived.by(() => const transitionDuration = $derived.by(() =>
monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150, monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150,
); );
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
function filterIntersecting<R extends { intersecting: boolean }>(intersectables: R[]) { function filterIntersecting<R extends { intersecting: boolean }>(intersectables: R[]) {
return intersectables.filter((intersectable) => intersectable.intersecting); return intersectables.filter((intersectable) => intersectable.intersecting);
} }
$effect.root(() => {
if (timelineManager.scrollCompensation.monthGroup === monthGroup) {
onScrollCompensationMonthInDOM(timelineManager.scrollCompensation);
}
});
</script> </script>
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)} {#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
{@const absoluteWidth = dayGroup.left} {@const absoluteWidth = dayGroup.left}
{@const isDayGroupSelected = assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<section <section
class={[ class={[
@@ -106,14 +77,14 @@
class="flex pt-7 pb-5 max-md:pt-5 max-md:pb-3 h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm" class="flex pt-7 pb-5 max-md:pt-5 max-md:pb-3 h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm"
style:width={dayGroup.width + 'px'} style:width={dayGroup.width + 'px'}
> >
{#if !singleSelect && ((hoveredDayGroup === dayGroup.groupTitle && isMouseOverGroup) || isDayGroupSelected(dayGroup))} {#if !singleSelect && ((hoveredDayGroup === dayGroup.groupTitle && isMouseOverGroup) || isDayGroupSelected)}
<div <div
transition:fly={{ x: -24, duration: 200, opacity: 0.5 }} transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
class="inline-block pe-2 hover:cursor-pointer" class="inline-block pe-2 hover:cursor-pointer"
onclick={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))} onclick={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))}
onkeydown={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))} onkeydown={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))}
> >
{#if isDayGroupSelected(dayGroup)} {#if isDayGroupSelected}
<Icon icon={mdiCheckCircle} size="24" class="text-primary" /> <Icon icon={mdiCheckCircle} size="24" class="text-primary" />
{:else} {:else}
<Icon icon={mdiCircleOutline} size="24" color="#757575" /> <Icon icon={mdiCircleOutline} size="24" color="#757575" />
@@ -126,48 +97,17 @@
</span> </span>
</div> </div>
<!-- Image grid --> <AssetLayout
<div photostreamManager={timelineManager}
data-image-grid viewerAssets={dayGroup.viewerAssets}
class="relative overflow-clip" height={dayGroup.height}
style:height={dayGroup.height + 'px'} width={dayGroup.width}
style:width={dayGroup.width + 'px'} {customThumbnailLayout}
> >
{#each filterIntersecting(dayGroup.viewerAssets) as viewerAsset (viewerAsset.id)} {#snippet thumbnail({ asset, position })}
{@const position = viewerAsset.position!} {@render thumbnailWithGroup({ asset, position, dayGroup, groupIndex })}
{@const asset = viewerAsset.asset!} {/snippet}
</AssetLayout>
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
<div
data-asset-id={asset.id}
class="absolute"
style:top={position.top + 'px'}
style:left={position.left + 'px'}
style:width={position.width + 'px'}
style:height={position.height + 'px'}
out:scale|global={{ start: 0.1, duration: scaleDuration }}
animate:flip={{ duration: transitionDuration }}
>
<Thumbnail
showStackedIcon={withStacked}
{showArchiveIcon}
{asset}
{groupIndex}
onClick={() => onAssetOpen?.(dayGroup, assetSnapshot(asset))}
onSelect={() => onAssetSelect(dayGroup, assetSnapshot(asset))}
onMouseEvent={() => onHover(dayGroup, assetSnapshot(asset))}
selected={isAssetSelected(asset)}
selectionCandidate={isAssetSelectionCandidate(asset)}
disabled={isAssetDisabled(asset)}
thumbnailWidth={position.width}
thumbnailHeight={position.height}
/>
{#if customThumbnailLayout}
{@render customThumbnailLayout(asset)}
{/if}
</div>
{/each}
</div>
</section> </section>
{/each} {/each}
@@ -175,7 +115,4 @@
section { section {
contain: layout paint style; contain: layout paint style;
} }
[data-image-grid] {
user-select: none;
}
</style> </style>
@@ -0,0 +1,63 @@
<script lang="ts">
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte';
import type { Snippet } from 'svelte';
interface Props {
content: Snippet<
[
{
onDayGroupSelect: (dayGroup: DayGroup, asset: TimelineAsset[]) => void;
onDayGroupAssetSelect: (dayGroup: DayGroup, asset: TimelineAsset) => void;
},
]
>;
onAssetSelect: (asset: TimelineAsset) => void;
timelineManager: PhotostreamManager;
assetInteraction: AssetInteraction;
}
let { content, assetInteraction, timelineManager, onAssetSelect }: Props = $props();
// called when clicking asset with shift key pressed or with mouse
const onDayGroupAssetSelect = (dayGroup: DayGroup, asset: TimelineAsset) => {
onAssetSelect(asset);
const assetsInDayGroup = dayGroup.getAssets();
const groupTitle = dayGroup.groupTitle;
// Check if all assets are selected in a group to toggle the group selection's icon
const selectedAssetsInGroupCount = assetsInDayGroup.filter((asset) =>
assetInteraction.hasSelectedAsset(asset.id),
).length;
// if all assets are selected in a group, add the group to selected group
if (selectedAssetsInGroupCount == assetsInDayGroup.length) {
assetInteraction.addGroupToMultiselectGroup(groupTitle);
} else {
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
}
};
const onDayGroupSelect = (dayGroup: DayGroup, assets: TimelineAsset[]) => {
const group = dayGroup.groupTitle;
if (assetInteraction.selectedGroup.has(group)) {
assetInteraction.removeGroupFromMultiselectGroup(group);
for (const asset of assets) {
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
}
} else {
assetInteraction.addGroupToMultiselectGroup(group);
for (const asset of assets) {
onAssetSelect(asset);
}
}
};
</script>
{@render content({
onDayGroupSelect,
onDayGroupAssetSelect,
})}
@@ -1,40 +1,41 @@
<script lang="ts"> <script lang="ts">
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte'; import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import TimelineMonth from '$lib/components/timeline/base-components/timeline-month.svelte'; import type { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte';
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte'; import type { PhotostreamSegment } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte'; import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import { searchStore } from '$lib/stores/search.svelte'; import { searchStore } from '$lib/stores/search.svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
interface Props { interface Props {
customThumbnailLayout?: Snippet<[TimelineAsset]>; content: Snippet<
[
{
onAssetOpen: (asset: TimelineAsset) => void;
onAssetSelect: (asset: TimelineAsset) => void;
onHover: (asset: TimelineAsset) => void;
},
]
>;
segment: PhotostreamSegment;
isSelectionMode: boolean; isSelectionMode: boolean;
singleSelect: boolean; singleSelect: boolean;
withStacked: boolean; timelineManager: PhotostreamManager;
showArchiveIcon: boolean;
monthGroup: MonthGroup;
timelineManager: TimelineManager;
assetInteraction: AssetInteraction; assetInteraction: AssetInteraction;
onAssetOpen?: (asset: TimelineAsset, defaultAssetOpen: () => void) => void;
onAssetOpen?: (dayGroup: DayGroup, asset: TimelineAsset, defaultAssetOpen: () => void) => void;
onSelect?: (isSingleSelect: boolean, asset: TimelineAsset) => void; onSelect?: (isSingleSelect: boolean, asset: TimelineAsset) => void;
onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void; onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
} }
let { let {
customThumbnailLayout, segment,
content,
isSelectionMode, isSelectionMode,
singleSelect, singleSelect,
withStacked,
showArchiveIcon,
monthGroup = $bindable(),
assetInteraction, assetInteraction,
timelineManager, timelineManager,
onAssetOpen, onAssetOpen,
@@ -58,40 +59,26 @@
} }
}); });
const defaultAssetOpen = (dayGroup: DayGroup, asset: TimelineAsset) => { const defaultAssetOpen = (asset: TimelineAsset) => {
if (isSelectionMode || assetInteraction.selectionActive) { if (isSelectionMode || assetInteraction.selectionActive) {
handleAssetSelect(dayGroup, asset); handleAssetSelect(asset);
return; return;
} }
void navigate({ targetRoute: 'current', assetId: asset.id }); void navigate({ targetRoute: 'current', assetId: asset.id });
}; };
const handleOnAssetOpen = (dayGroup: DayGroup, asset: TimelineAsset) => { const handleOnAssetOpen = (asset: TimelineAsset) => {
if (onAssetOpen) { if (onAssetOpen) {
onAssetOpen(dayGroup, asset, () => defaultAssetOpen(dayGroup, asset)); onAssetOpen(asset, () => defaultAssetOpen(asset));
return; return;
} }
defaultAssetOpen(dayGroup, asset); defaultAssetOpen(asset);
}; };
// called when clicking asset with shift key pressed or with mouse // called when clicking asset with shift key pressed or with mouse
const handleAssetSelect = (dayGroup: DayGroup, asset: TimelineAsset) => { const handleAssetSelect = (asset: TimelineAsset) => {
void onSelectAssets(asset); void onSelectAssets(asset);
const assetsInDayGroup = dayGroup.getAssets();
const groupTitle = dayGroup.groupTitle;
// Check if all assets are selected in a group to toggle the group selection's icon
let selectedAssetsInGroupCount = assetsInDayGroup.filter((asset) =>
assetInteraction.hasSelectedAsset(asset.id),
).length;
// if all assets are selected in a group, add the group to selected group
if (selectedAssetsInGroupCount == assetsInDayGroup.length) {
assetInteraction.addGroupToMultiselectGroup(groupTitle);
} else {
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
}
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) { if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
isSelectingAllAssets.set(true); isSelectingAllAssets.set(true);
} else { } else {
@@ -99,12 +86,6 @@
} }
}; };
const handleSelectAsset = (asset: TimelineAsset) => {
if (!timelineManager.albumAssets.has(asset.id)) {
assetInteraction.selectAsset(asset);
}
};
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (searchStore.isSearchEnabled) { if (searchStore.isSearchEnabled) {
return; return;
@@ -127,34 +108,13 @@
} }
}; };
const handleOnHover = (dayGroup: DayGroup, asset: TimelineAsset) => { const handleOnHover = (asset: TimelineAsset) => {
if (assetInteraction.selectionActive) { if (assetInteraction.selectionActive) {
void selectAssetCandidates(asset); void selectAssetCandidates(asset);
} }
lastAssetMouseEvent = asset; lastAssetMouseEvent = asset;
}; };
const handleDayGroupSelect = (dayGroup: DayGroup, assets: TimelineAsset[]) => {
const group = dayGroup.groupTitle;
if (assetInteraction.selectedGroup.has(group)) {
assetInteraction.removeGroupFromMultiselectGroup(group);
for (const asset of assets) {
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
}
} else {
assetInteraction.addGroupToMultiselectGroup(group);
for (const asset of assets) {
handleSelectAsset(asset);
}
}
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
isSelectingAllAssets.set(true);
} else {
isSelectingAllAssets.set(false);
}
};
const onSelectAssets = async (asset: TimelineAsset) => { const onSelectAssets = async (asset: TimelineAsset) => {
if (!asset) { if (!asset) {
return; return;
@@ -162,8 +122,6 @@
onSelect?.(singleSelect, asset); onSelect?.(singleSelect, asset);
if (singleSelect) { if (singleSelect) {
// onScrollToTop();
return; return;
} }
@@ -186,60 +144,28 @@
assetInteraction.clearAssetSelectionCandidates(); assetInteraction.clearAssetSelectionCandidates();
if (assetInteraction.assetSelectionStart && rangeSelection) { if (assetInteraction.assetSelectionStart && rangeSelection) {
let startBucket = timelineManager.getMonthGroupByAssetId(assetInteraction.assetSelectionStart.id); const assets = await timelineManager.retrieveRange(assetInteraction.assetSelectionStart, asset);
let endBucket = timelineManager.getMonthGroupByAssetId(asset.id); for (const asset of assets) {
if (deselect) {
if (startBucket === null || endBucket === null) { assetInteraction.removeAssetFromMultiselectGroup(asset.id);
return; } else {
} handleSelectAsset(asset);
// Select/deselect assets in range (start,end)
let started = false;
for (const monthGroup of timelineManager.months) {
if (monthGroup === endBucket) {
break;
}
if (started) {
await timelineManager.loadSegment(monthGroup.yearMonth);
for (const asset of monthGroup.assetsIterator()) {
if (deselect) {
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
handleSelectAsset(asset);
}
}
}
if (monthGroup === startBucket) {
started = true;
}
}
// Update date group selection in range [start,end]
started = false;
for (const monthGroup of timelineManager.months) {
if (monthGroup === startBucket) {
started = true;
}
if (started) {
// Split month group into day groups and check each group
for (const dayGroup of monthGroup.dayGroups) {
const dayGroupTitle = dayGroup.groupTitle;
if (dayGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
assetInteraction.addGroupToMultiselectGroup(dayGroupTitle);
} else {
assetInteraction.removeGroupFromMultiselectGroup(dayGroupTitle);
}
}
}
if (monthGroup === endBucket) {
break;
} }
} }
} }
assetInteraction.setAssetSelectionStart(deselect ? null : asset); assetInteraction.setAssetSelectionStart(deselect ? null : asset);
}; };
const handleSelectAsset = (asset: TimelineAsset) => {
if ('albumAssets' in timelineManager) {
const tm = timelineManager as TimelineManager;
if (tm.albumAssets.has(asset.id)) {
return;
}
}
assetInteraction.selectAsset(asset);
};
const selectAssetCandidates = async (endAsset: TimelineAsset) => { const selectAssetCandidates = async (endAsset: TimelineAsset) => {
if (!shiftKeyIsDown) { if (!shiftKeyIsDown) {
return; return;
@@ -253,24 +179,18 @@
const assets = assetsSnapshot(await timelineManager.retrieveRange(startAsset, endAsset)); const assets = assetsSnapshot(await timelineManager.retrieveRange(startAsset, endAsset));
assetInteraction.setAssetSelectionCandidates(assets); assetInteraction.setAssetSelectionCandidates(assets);
}; };
$effect.root(() => {
if (timelineManager.scrollCompensation.monthGroup === segment) {
onScrollCompensationMonthInDOM(timelineManager.scrollCompensation);
}
});
</script> </script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} /> <svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
<TimelineMonth {@render content({
{customThumbnailLayout} onAssetOpen: handleOnAssetOpen,
{singleSelect} onAssetSelect: handleSelectAsset,
{withStacked} onHover: handleOnHover,
{showArchiveIcon} })}
{monthGroup}
{timelineManager}
{onScrollCompensationMonthInDOM}
onHover={handleOnHover}
onAssetOpen={handleOnAssetOpen}
onAssetSelect={handleAssetSelect}
onDayGroupSelect={handleDayGroupSelect}
isDayGroupSelected={(dayGroup: DayGroup) => assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
isAssetSelected={(asset) => assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)}
isAssetSelectionCandidate={(asset) => assetInteraction.hasSelectionCandidate(asset.id)}
isAssetDisabled={(asset) => timelineManager.albumAssets.has(asset.id)}
/>
@@ -4,8 +4,7 @@ import { CancellableTask } from '$lib/utils/cancellable-task';
import { clamp, debounce } from 'lodash-es'; import { clamp, debounce } from 'lodash-es';
import type { PhotostreamSegment, SegmentIdentifier } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte'; import type { PhotostreamSegment, SegmentIdentifier } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte'; import type { AssetDescriptor, TimelineAsset, TimelineManagerLayoutOptions, Viewport } from './types';
import type { TimelineManagerLayoutOptions, TimelineManagerOptions, Viewport } from './types';
export abstract class PhotostreamManager { export abstract class PhotostreamManager {
isInitialized = $state(false); isInitialized = $state(false);
@@ -30,8 +29,6 @@ export abstract class PhotostreamManager {
() => void 0, () => void 0,
); );
protected options: TimelineManagerOptions = {};
#viewportHeight = $state(0); #viewportHeight = $state(0);
#viewportWidth = $state(0); #viewportWidth = $state(0);
#scrollTop = $state(0); #scrollTop = $state(0);
@@ -188,10 +185,8 @@ export abstract class PhotostreamManager {
} }
} }
protected async init(options: TimelineManagerOptions) { async init() {
this.isInitialized = true; this.isInitialized = false;
// this.months = [];
await this.initTask.execute(async () => undefined, true); await this.initTask.execute(async () => undefined, true);
} }
@@ -209,7 +204,7 @@ export abstract class PhotostreamManager {
} }
if (!this.initTask.executed) { if (!this.initTask.executed) {
await (this.initTask.loading ? this.initTask.waitUntilCompletion() : this.init(this.options)); await (this.initTask.loading ? this.initTask.waitUntilCompletion() : this.init());
} }
const changedWidth = viewport.width !== this.viewportWidth; const changedWidth = viewport.width !== this.viewportWidth;
@@ -252,28 +247,24 @@ export abstract class PhotostreamManager {
return; return;
} }
if (segment.loader?.executed) { const result = await segment.load(cancelable);
return;
}
const result = await segment.loader?.execute(async (signal: AbortSignal) => {
await this.fetchSegment(segment, signal);
}, cancelable);
if (result === 'LOADED') { if (result === 'LOADED') {
updateIntersectionMonthGroup(this, segment); updateIntersectionMonthGroup(this, segment);
} }
} }
getSegmentMatcher(identifier: SegmentIdentifier) {
return (segment: MonthGroup) => {
return identifier;
};
}
getSegmentByIdentifier(identifier: SegmentIdentifier) { getSegmentByIdentifier(identifier: SegmentIdentifier) {
return this.months.find((segment) => identifier.matches(segment)); return this.months.find((segment) => identifier.matches(segment));
} }
protected abstract fetchSegment(segment: PhotostreamSegment, signal: AbortSignal): Promise<void>; getSegmentForAssetId(assetId: String) {
for (const month of this.months) {
const asset = month.assets.find((asset) => asset.id === assetId);
if (asset) {
return month;
}
}
}
refreshLayout() { refreshLayout() {
for (const month of this.months) { for (const month of this.months) {
@@ -290,4 +281,18 @@ export abstract class PhotostreamManager {
getMaxScroll() { getMaxScroll() {
return this.topSectionHeight + this.bottomSectionHeight + (this.timelineHeight - this.viewportHeight); return this.topSectionHeight + this.bottomSectionHeight + (this.timelineHeight - this.viewportHeight);
} }
}
async retrieveRange(start: AssetDescriptor, end: AssetDescriptor) {
const range: TimelineAsset[] = [];
let collecting = false;
for (const month of this.months) {
for (const asset of month.assets) {
if (asset.id === start.id) collecting = true;
if (collecting) range.push(asset);
if (asset.id === end.id) return range;
}
}
return range;
}
}
@@ -5,6 +5,7 @@ import { get } from 'svelte/store';
import type { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte'; import type { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
export type SegmentIdentifier = { export type SegmentIdentifier = {
matches(segment: PhotostreamSegment): boolean; matches(segment: PhotostreamSegment): boolean;
@@ -16,10 +17,12 @@ export abstract class PhotostreamSegment {
#height = $state(0); #height = $state(0);
#top = $state(0); #top = $state(0);
#assets = $derived.by(() => this.viewerAssets.map((viewerAsset) => viewerAsset.asset));
initialCount = $state(0); initialCount = $state(0);
percent = $state(0); percent = $state(0);
assetsCount = $derived(this.isLoaded ? this.getAssets().length : this.initialCount);
assetsCount = $derived.by(() => (this.isLoaded ? this.viewerAssets.length : this.initialCount));
loader = new CancellableTask( loader = new CancellableTask(
() => this.markLoaded(), () => this.markLoaded(),
() => this.markCanceled, () => this.markCanceled,
@@ -31,6 +34,8 @@ export abstract class PhotostreamSegment {
abstract get identifier(): SegmentIdentifier; abstract get identifier(): SegmentIdentifier;
abstract get id(): string;
get isLoaded() { get isLoaded() {
return this.#isLoaded; return this.#isLoaded;
} }
@@ -50,7 +55,7 @@ export abstract class PhotostreamSegment {
} }
this.#intersecting = newValue; this.#intersecting = newValue;
if (newValue) { if (newValue) {
this.load(); this.load(true);
} else { } else {
this.cancel(); this.cancel();
} }
@@ -60,9 +65,19 @@ export abstract class PhotostreamSegment {
return this.#intersecting; return this.#intersecting;
} }
abstract load(): Promise<void>; async load(cancelable: boolean): Promise<'DONE' | 'WAITED' | 'CANCELED' | 'LOADED' | 'ERRORED'> {
return await this.loader?.execute(async (signal: AbortSignal) => {
await this.fetch(signal);
}, cancelable);
}
abstract getAssets(): TimelineAsset[]; protected abstract fetch(signal: AbortSignal): Promise<void>;
get assets(): TimelineAsset[] {
return this.#assets;
}
abstract get viewerAssets(): ViewerAsset[];
set height(height: number) { set height(height: number) {
if (this.#height === height) { if (this.#height === height) {
@@ -130,4 +145,6 @@ export abstract class PhotostreamSegment {
this.intersecting = intersecting; this.intersecting = intersecting;
this.actuallyIntersecting = actuallyIntersecting; this.actuallyIntersecting = actuallyIntersecting;
} }
}
abstract findAssetAbsolutePosition(assetId: string): number;
}
@@ -0,0 +1,45 @@
import { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte';
import { SearchStreamSegment } from '$lib/managers/timeline-manager/SearchStreamSegment.svelte';
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
export type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query' | 'queryAssetId'> & { isVisible: boolean };
export class SearchStreamManager extends PhotostreamManager {
#isSmartSearchEnabled: boolean;
#searchTerms: SearchTerms;
#months: SearchStreamSegment[] = $state([]);
constructor(searchTerms: SearchTerms, options: { isSmartSearchEnabled: boolean }) {
super();
this.#searchTerms = searchTerms;
this.#isSmartSearchEnabled = options.isSmartSearchEnabled;
}
async init() {
this.isInitialized = false;
await this.initTask.execute(async () => {
// add some months to start the searches
for (let i = 1; i < 3; i++) {
this.#months.push(new SearchStreamSegment(this, { ...this.#searchTerms, page: i }));
}
}, true);
this.updateViewportGeometry(false);
}
get months(): SearchStreamSegment[] {
return this.#months;
}
get isSmartSearchEnabled() {
return this.#isSmartSearchEnabled;
}
loadNextPage() {
debugger;
// note: pages are 1-based
this.#months.push(new SearchStreamSegment(this, { ...this.#searchTerms, page: this.#months.length + 1 }));
this.updateViewportGeometry(false);
}
}
@@ -0,0 +1,99 @@
import { PhotostreamSegment, type SegmentIdentifier } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte';
import type { SearchStreamManager, SearchTerms } from '$lib/managers/timeline-manager/SearchStreamManager.svelte';
import { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
import { getJustifiedLayoutFromAssets, getPosition } from '$lib/utils/layout-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { searchAssets, searchSmart } from '@immich/sdk';
import { isEqual } from 'lodash-es';
export class SearchStreamSegment extends PhotostreamSegment {
manager: SearchStreamManager;
#identifier: SegmentIdentifier;
#id: string;
#searchTerms: SearchTerms;
#viewerAssets: ViewerAsset[] = $state([]);
constructor(manager: SearchStreamManager, searchTerms: SearchTerms) {
super();
this.manager = manager;
this.#searchTerms = searchTerms;
this.#id = JSON.stringify(searchTerms);
this.#identifier = {
matches(segment: SearchStreamSegment) {
return isEqual(segment.#searchTerms, searchTerms);
},
};
this.initialCount = searchTerms.size || 100;
}
get timelineManager(): SearchStreamManager {
return this.manager;
}
get identifier(): SegmentIdentifier {
return this.#identifier;
}
get id(): string {
return this.#id;
}
async fetch(signal: AbortSignal): Promise<void> {
const searchDto: SearchTerms = {
...this.#searchTerms,
withExif: true,
isVisible: true,
};
const { assets } =
('query' in searchDto || 'queryAssetId' in searchDto) && this.manager.isSmartSearchEnabled
? await searchSmart({ smartSearchDto: searchDto }, { signal })
: await searchAssets({ metadataSearchDto: searchDto }, { signal });
this.#viewerAssets = assets.items.map((asset) => new ViewerAsset(this, toTimelineAsset(asset)));
this.layout();
}
layout(): void {
const timelineAssets = this.#viewerAssets.map((viewerAsset) => viewerAsset.asset);
const rowWidth = Math.floor(this.timelineManager.viewportWidth);
const rowHeight = rowWidth < 850 ? 100 : 235;
const geometry = getJustifiedLayoutFromAssets(timelineAssets, {
spacing: 2,
heightTolerance: 0.15,
rowHeight,
rowWidth,
});
// this.width = geometry.containerWidth;
this.height = timelineAssets.length === 0 ? 0 : geometry.containerHeight;
for (let i = 0; i < this.#viewerAssets.length; i++) {
const position = getPosition(geometry, i);
this.#viewerAssets[i].position = position;
}
}
get viewerAssets(): ViewerAsset[] {
return this.#viewerAssets;
}
findAssetAbsolutePosition(assetId: string) {
const viewerAsset = this.#viewerAssets.find((viewAsset) => viewAsset.id === assetId);
if (viewerAsset) {
if (!viewerAsset.position) {
console.warn('No position for asset');
return -1;
}
return this.top + viewerAsset.position.top + this.timelineManager.headerHeight;
}
return -1;
}
updateIntersection({ intersecting, actuallyIntersecting }: { intersecting: boolean; actuallyIntersecting: boolean }) {
super.updateIntersection({ intersecting, actuallyIntersecting });
// if we're the last month, try to load next month
if (intersecting && this.timelineManager.months[this.timelineManager.months.length - 1] === this) {
this.timelineManager.loadNextPage();
}
}
}
@@ -1,7 +1,6 @@
import type { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte'; import type { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte';
import type { PhotostreamSegment } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte'; import type { PhotostreamSegment } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte';
import { TUNABLES } from '$lib/utils/tunables'; import { TUNABLES } from '$lib/utils/tunables';
import type { TimelineManager } from '../timeline-manager.svelte';
const { const {
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM }, TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
@@ -55,7 +54,7 @@ export function calculateSegmentIntersecting(
* Calculate intersection for viewer assets with additional parameters like header height and scroll compensation * Calculate intersection for viewer assets with additional parameters like header height and scroll compensation
*/ */
export function calculateViewerAssetIntersecting( export function calculateViewerAssetIntersecting(
timelineManager: TimelineManager, timelineManager: PhotostreamManager,
positionTop: number, positionTop: number,
positionHeight: number, positionHeight: number,
expandTop: number = INTERSECTION_EXPAND_TOP, expandTop: number = INTERSECTION_EXPAND_TOP,
@@ -1,5 +1,6 @@
import type { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte'; import type { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte';
import type { PhotostreamSegment } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte'; import type { PhotostreamSegment } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { MonthGroup } from '../month-group.svelte'; import type { MonthGroup } from '../month-group.svelte';
import type { UpdateGeometryOptions } from '../types'; import type { UpdateGeometryOptions } from '../types';
@@ -25,7 +26,7 @@ export function updateGeometry(
month.layout(noDefer); month.layout(noDefer);
} }
export function layoutMonthGroup(timelineManager: PhotostreamManager, month: MonthGroup, noDefer: boolean = false) { export function layoutMonthGroup(timelineManager: TimelineManager, month: MonthGroup, noDefer: boolean = false) {
let cumulativeHeight = 0; let cumulativeHeight = 0;
let cumulativeWidth = 0; let cumulativeWidth = 0;
let currentRowHeight = 0; let currentRowHeight = 0;
@@ -15,6 +15,7 @@ import {
} from '$lib/utils/timeline-util'; } from '$lib/utils/timeline-util';
import { layoutMonthGroup, updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte'; import { layoutMonthGroup, updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte';
import { PhotostreamSegment, type SegmentIdentifier } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte'; import { PhotostreamSegment, type SegmentIdentifier } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import { DayGroup } from './day-group.svelte'; import { DayGroup } from './day-group.svelte';
@@ -64,11 +65,10 @@ export class MonthGroup extends PhotostreamSegment {
return this.#yearMonth; return this.#yearMonth;
} }
load(): Promise<void> { fetch(signal: AbortSignal): Promise<void> {
return this.timelineManager.loadSegment(this.#identifier); return loadFromTimeBuckets(this.timelineManager, this, this.timelineManager.options, signal);
} }
get lastDayGroup() { get lastDayGroup() {
return this.dayGroups.at(-1); return this.dayGroups.at(-1);
} }
@@ -77,9 +77,9 @@ export class MonthGroup extends PhotostreamSegment {
return this.dayGroups[0]?.getFirstAsset(); return this.dayGroups[0]?.getFirstAsset();
} }
getAssets() { get viewerAssets() {
// eslint-disable-next-line unicorn/no-array-reduce // eslint-disable-next-line unicorn/no-array-reduce
return this.dayGroups.reduce((accumulator: TimelineAsset[], g: DayGroup) => accumulator.concat(g.getAssets()), []); return this.dayGroups.reduce((accumulator: ViewerAsset[], g: DayGroup) => accumulator.concat(g.viewerAssets), []);
} }
sortDayGroups() { sortDayGroups() {
@@ -221,12 +221,15 @@ export class MonthGroup extends PhotostreamSegment {
return this.getRandomDayGroup()?.getRandomAsset()?.asset; return this.getRandomDayGroup()?.getRandomAsset()?.asset;
} }
get id() {
return this.viewId;
}
get viewId() { get viewId() {
const { year, month } = this.yearMonth; const { year, month } = this.yearMonth;
return year + '-' + month; return year + '-' + month;
} }
findDayGroupForAsset(asset: TimelineAsset) { findDayGroupForAsset(asset: TimelineAsset) {
for (const group of this.dayGroups) { for (const group of this.dayGroups) {
if (group.viewerAssets.some((viewerAsset) => viewerAsset.id === asset.id)) { if (group.viewerAssets.some((viewerAsset) => viewerAsset.id === asset.id)) {
@@ -128,10 +128,10 @@ describe('TimelineManager', () => {
}); });
it('loads a month', async () => { it('loads a month', async () => {
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(0);
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
expect(sdkMock.getTimeBucket).toBeCalledTimes(1); expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(3);
}); });
it('ignores invalid months', async () => { it('ignores invalid months', async () => {
@@ -146,7 +146,7 @@ describe('TimelineManager', () => {
month?.cancel(); month?.cancel();
expect(abortSpy).toBeCalledTimes(1); expect(abortSpy).toBeCalledTimes(1);
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(3);
}); });
it('prevents loading months multiple times', async () => { it('prevents loading months multiple times', async () => {
@@ -166,10 +166,10 @@ describe('TimelineManager', () => {
month.cancel(); month.cancel();
await loadPromise; await loadPromise;
expect(month?.getAssets().length).toEqual(0); expect(month?.assets.length).toEqual(0);
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
expect(month!.getAssets().length).toEqual(3); expect(month!.assets.length).toEqual(3);
}); });
}); });
@@ -198,7 +198,7 @@ describe('TimelineManager', () => {
expect(timelineManager.months.length).toEqual(1); expect(timelineManager.months.length).toEqual(1);
expect(timelineManager.assetCount).toEqual(1); expect(timelineManager.assetCount).toEqual(1);
expect(timelineManager.months[0].getAssets().length).toEqual(1); expect(timelineManager.months[0].assets.length).toEqual(1);
expect(timelineManager.months[0].yearMonth.year).toEqual(2024); expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
expect(timelineManager.months[0].yearMonth.month).toEqual(1); expect(timelineManager.months[0].yearMonth.month).toEqual(1);
expect(timelineManager.months[0].getFirstAsset().id).toEqual(asset.id); expect(timelineManager.months[0].getFirstAsset().id).toEqual(asset.id);
@@ -215,7 +215,7 @@ describe('TimelineManager', () => {
expect(timelineManager.months.length).toEqual(1); expect(timelineManager.months.length).toEqual(1);
expect(timelineManager.assetCount).toEqual(2); expect(timelineManager.assetCount).toEqual(2);
expect(timelineManager.months[0].getAssets().length).toEqual(2); expect(timelineManager.months[0].assets.length).toEqual(2);
expect(timelineManager.months[0].yearMonth.year).toEqual(2024); expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
expect(timelineManager.months[0].yearMonth.month).toEqual(1); expect(timelineManager.months[0].yearMonth.month).toEqual(1);
}); });
@@ -240,10 +240,10 @@ describe('TimelineManager', () => {
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 }); const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 });
expect(month).not.toBeNull(); expect(month).not.toBeNull();
expect(month?.getAssets().length).toEqual(3); expect(month?.assets.length).toEqual(3);
expect(month?.getAssets()[0].id).toEqual(assetOne.id); expect(month?.assets[0].id).toEqual(assetOne.id);
expect(month?.getAssets()[1].id).toEqual(assetThree.id); expect(month?.assets[1].id).toEqual(assetThree.id);
expect(month?.getAssets()[2].id).toEqual(assetTwo.id); expect(month?.assets[2].id).toEqual(assetTwo.id);
}); });
it('orders months by descending date', () => { it('orders months by descending date', () => {
@@ -341,14 +341,14 @@ describe('TimelineManager', () => {
timelineManager.addAssets([asset]); timelineManager.addAssets([asset]);
expect(timelineManager.months.length).toEqual(1); expect(timelineManager.months.length).toEqual(1);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined(); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(1); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(1);
timelineManager.updateAssets([updatedAsset]); timelineManager.updateAssets([updatedAsset]);
expect(timelineManager.months.length).toEqual(2); expect(timelineManager.months.length).toEqual(2);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined(); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(0);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })).not.toBeUndefined(); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })).not.toBeUndefined();
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })?.getAssets().length).toEqual(1); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })?.assets.length).toEqual(1);
}); });
}); });
@@ -374,7 +374,7 @@ describe('TimelineManager', () => {
expect(timelineManager.assetCount).toEqual(2); expect(timelineManager.assetCount).toEqual(2);
expect(timelineManager.months.length).toEqual(1); expect(timelineManager.months.length).toEqual(1);
expect(timelineManager.months[0].getAssets().length).toEqual(2); expect(timelineManager.months[0].assets.length).toEqual(2);
}); });
it('removes asset from month', () => { it('removes asset from month', () => {
@@ -388,7 +388,7 @@ describe('TimelineManager', () => {
expect(timelineManager.assetCount).toEqual(1); expect(timelineManager.assetCount).toEqual(1);
expect(timelineManager.months.length).toEqual(1); expect(timelineManager.months.length).toEqual(1);
expect(timelineManager.months[0].getAssets().length).toEqual(1); expect(timelineManager.months[0].assets.length).toEqual(1);
}); });
it('does not remove month when empty', () => { it('does not remove month when empty', () => {
@@ -480,8 +480,8 @@ describe('TimelineManager', () => {
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 }); const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 });
const a = month!.getAssets()[0]; const a = month!.assets[0];
const b = month!.getAssets()[1]; const b = month!.assets[1];
const previous = await timelineManager.getLaterAsset(b); const previous = await timelineManager.getLaterAsset(b);
expect(previous).toEqual(a); expect(previous).toEqual(a);
}); });
@@ -492,8 +492,8 @@ describe('TimelineManager', () => {
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 }); const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 });
const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 }); const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 });
const a = month!.getAssets()[0]; const a = month!.assets[0];
const b = previousMonth!.getAssets()[0]; const b = previousMonth!.assets[0];
const previous = await timelineManager.getLaterAsset(a); const previous = await timelineManager.getLaterAsset(a);
expect(previous).toEqual(b); expect(previous).toEqual(b);
}); });
@@ -14,7 +14,6 @@ import { isEqual } from 'lodash-es';
import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity'; import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity';
import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte'; import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte';
import { import {
addAssetsToMonthGroups, addAssetsToMonthGroups,
runAssetOperation, runAssetOperation,
@@ -28,7 +27,6 @@ import {
} from '$lib/managers/timeline-manager/internal/search-support.svelte'; } from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte'; import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte';
import { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte'; import { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte';
import { PhotostreamSegment } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte';
import { DayGroup } from './day-group.svelte'; import { DayGroup } from './day-group.svelte';
import { isMismatched, updateObject } from './internal/utils.svelte'; import { isMismatched, updateObject } from './internal/utils.svelte';
import { MonthGroup } from './month-group.svelte'; import { MonthGroup } from './month-group.svelte';
@@ -71,6 +69,10 @@ export class TimelineManager extends PhotostreamManager {
return this.#months; return this.#months;
} }
get options() {
return this.#options;
}
async *assetsIterator(options?: { async *assetsIterator(options?: {
startMonthGroup?: MonthGroup; startMonthGroup?: MonthGroup;
startDayGroup?: DayGroup; startDayGroup?: DayGroup;
@@ -144,16 +146,17 @@ export class TimelineManager extends PhotostreamManager {
return; return;
} }
await this.initTask.reset(); await this.initTask.reset();
await this.init(options); this.#options = options;
await this.init();
this.updateViewportGeometry(false); this.updateViewportGeometry(false);
} }
async init(options: TimelineManagerOptions) { async init() {
this.isInitialized = false; this.isInitialized = false;
this.#months = []; this.#months = [];
this.albumAssets.clear(); this.albumAssets.clear();
await this.initTask.execute(async () => { await this.initTask.execute(async () => {
this.#options = options;
await this.#initializeMonthGroups(); await this.#initializeMonthGroups();
}, true); }, true);
} }
@@ -179,10 +182,6 @@ export class TimelineManager extends PhotostreamManager {
this.scrubberTimelineHeight = this.timelineHeight; this.scrubberTimelineHeight = this.timelineHeight;
} }
protected fetchSegment(segment: PhotostreamSegment, signal: AbortSignal): Promise<void> {
return loadFromTimeBuckets(this, segment as MonthGroup, this.#options, signal);
}
addAssets(assets: TimelineAsset[]) { addAssets(assets: TimelineAsset[]) {
const assetsToUpdate = assets.filter((asset) => !this.isExcluded(asset)); const assetsToUpdate = assets.filter((asset) => !this.isExcluded(asset));
const notUpdated = this.updateAssets(assetsToUpdate); const notUpdated = this.updateAssets(assetsToUpdate);
@@ -1,3 +1,4 @@
import type { PhotostreamSegment } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte';
import type { CommonPosition } from '$lib/utils/layout-utils'; import type { CommonPosition } from '$lib/utils/layout-utils';
import type { DayGroup } from './day-group.svelte'; import type { DayGroup } from './day-group.svelte';
@@ -5,16 +6,21 @@ import { calculateViewerAssetIntersecting } from './internal/intersection-suppor
import type { TimelineAsset } from './types'; import type { TimelineAsset } from './types';
export class ViewerAsset { export class ViewerAsset {
readonly #group: DayGroup; readonly #group: DayGroup | PhotostreamSegment;
intersecting = $derived.by(() => { intersecting = $derived.by(() => {
if (!this.position) { if (!this.position) {
return false; return false;
} }
if ((this.#group as DayGroup).sortAssets) {
const dayGroup = this.#group as DayGroup;
const store = dayGroup.monthGroup.timelineManager;
const positionTop = dayGroup.absoluteDayGroupTop + this.position.top;
return calculateViewerAssetIntersecting(store, positionTop, this.position.height);
}
const store = this.#group.monthGroup.timelineManager; const store = (this.#group as PhotostreamSegment).timelineManager;
const positionTop = this.#group.absoluteDayGroupTop + this.position.top; const positionTop = this.position.top + (this.#group as PhotostreamSegment).top;
return calculateViewerAssetIntersecting(store, positionTop, this.position.height); return calculateViewerAssetIntersecting(store, positionTop, this.position.height);
}); });
@@ -22,7 +28,7 @@ export class ViewerAsset {
asset: TimelineAsset = <TimelineAsset>$state(); asset: TimelineAsset = <TimelineAsset>$state();
id: string = $derived(this.asset.id); id: string = $derived(this.asset.id);
constructor(group: DayGroup, asset: TimelineAsset) { constructor(group: DayGroup | PhotostreamSegment, asset: TimelineAsset) {
this.#group = group; this.#group = group;
this.asset = asset; this.asset = asset;
} }
@@ -96,6 +96,7 @@
> >
<div class="flex justify-center overflow-hidden rounded-xl brightness-75 filter"> <div class="flex justify-center overflow-hidden rounded-xl brightness-75 filter">
<img <img
style:filter="blur(10px)"
src={getAssetThumbnailUrl({ id: item.data.id, size: AssetMediaSize.Thumbnail })} src={getAssetThumbnailUrl({ id: item.data.id, size: AssetMediaSize.Thumbnail })}
alt={item.value} alt={item.value}
class="object-cover aspect-square w-full" class="object-cover aspect-square w-full"
@@ -5,7 +5,6 @@
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte'; import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte'; import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte'; import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte';
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte'; import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
@@ -20,6 +19,7 @@
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte'; import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
import TagAction from '$lib/components/timeline/actions/TagAction.svelte'; import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Gallery from '$lib/components/timeline/Gallery.svelte';
import { AppRoute, QueryParameter } from '$lib/constants'; import { AppRoute, QueryParameter } from '$lib/constants';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
@@ -31,20 +31,16 @@
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils'; import { cancelMultiselect } from '$lib/utils/asset-utils';
import { parseUtcDate } from '$lib/utils/date-time'; import { parseUtcDate } from '$lib/utils/date-time';
import { handleError } from '$lib/utils/handle-error';
import { isAlbumsRoute, isPeopleRoute } from '$lib/utils/navigation'; import { isAlbumsRoute, isPeopleRoute } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { import {
type AlbumResponseDto, type AlbumResponseDto,
getPerson, getPerson,
getTagById, getTagById,
type MetadataSearchDto, type MetadataSearchDto,
searchAssets,
searchSmart,
type SmartSearchDto, type SmartSearchDto,
} from '@immich/sdk'; } from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner } from '@immich/ui'; import { IconButton } from '@immich/ui';
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js'; import { mdiArrowLeft, mdiDotsVertical, mdiPlus, mdiSelectAll } from '@mdi/js';
import { tick } from 'svelte'; import { tick } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -139,43 +135,51 @@
}; };
async function onSearchQueryUpdate() { async function onSearchQueryUpdate() {
debugger;
nextPage = 1; nextPage = 1;
searchResultAssets = []; searchResultAssets = [];
searchResultAlbums = []; searchResultAlbums = [];
await loadNextPage(true); // await loadNextPage(true);
} }
// eslint-disable-next-line svelte/valid-prop-names-in-kit-pages const searchTerms = $derived.by(() => ({
export const loadNextPage = async (force?: boolean) => { withExif: true,
if (!nextPage || (isLoading && !force)) { isVisible: true,
return; language: $lang,
} ...terms,
isLoading = true; }));
const searchDto: SearchTerms = { // // eslint-disable-next-line svelte/valid-prop-names-in-kit-pages
page: nextPage, // export const loadNextPage = async (force?: boolean) => {
withExif: true, // if (!nextPage || (isLoading && !force)) {
isVisible: true, // return;
language: $lang, // }
...terms, // isLoading = true;
};
try { // const searchDto: SearchTerms = {
const { albums, assets } = // page: nextPage,
('query' in searchDto || 'queryAssetId' in searchDto) && smartSearchEnabled // withExif: true,
? await searchSmart({ smartSearchDto: searchDto }) // isVisible: true,
: await searchAssets({ metadataSearchDto: searchDto }); // language: $lang,
// ...terms,
// };
searchResultAlbums.push(...albums.items); // try {
searchResultAssets.push(...assets.items.map((asset) => toTimelineAsset(asset))); // const { albums, assets } =
// ('query' in searchDto || 'queryAssetId' in searchDto) && smartSearchEnabled
// ? await searchSmart({ smartSearchDto: searchDto })
// : await searchAssets({ metadataSearchDto: searchDto });
nextPage = Number(assets.nextPage) || 0; // searchResultAlbums.push(...albums.items);
} catch (error) { // searchResultAssets.push(...assets.items.map((asset) => toTimelineAsset(asset)));
handleError(error, $t('loading_search_results_failed'));
} finally { // nextPage = Number(assets.nextPage) || 0;
isLoading = false; // } catch (error) {
} // handleError(error, $t('loading_search_results_failed'));
}; // } finally {
// isLoading = false;
// }
// };
function getHumanReadableDate(dateString: string) { function getHumanReadableDate(dateString: string) {
const date = parseUtcDate(dateString).startOf('day'); const date = parseUtcDate(dateString).startOf('day');
@@ -359,7 +363,7 @@
{/if} {/if}
<section <section
class="mb-12 bg-immich-bg dark:bg-immich-dark-bg m-4 max-h-screen" class="mb-0 bg-immich-bg dark:bg-immich-dark-bg m-4 max-h-screen"
bind:clientHeight={viewport.height} bind:clientHeight={viewport.height}
bind:clientWidth={viewport.width} bind:clientWidth={viewport.width}
bind:this={searchResultsElement} bind:this={searchResultsElement}
@@ -374,8 +378,12 @@
</div> </div>
</section> </section>
{/if} {/if}
<section id="search-content"> <section id="search-content" class="h-[calc(100dvh-144px)]">
{#if searchResultAssets.length > 0} {#key searchTerms}
<Gallery {searchTerms}></Gallery>
{/key}
<!-- {#if searchResultAssets.length > 0}
<Gallery {searchTerms}></Gallery>
<GalleryViewer <GalleryViewer
assets={searchResultAssets} assets={searchResultAssets}
{assetInteraction} {assetInteraction}
@@ -399,7 +407,7 @@
<div class="flex justify-center py-16 items-center"> <div class="flex justify-center py-16 items-center">
<LoadingSpinner size="giant" /> <LoadingSpinner size="giant" />
</div> </div>
{/if} {/if} -->
</section> </section>
<section> <section>