feat: add spinner to SearchResults

This commit is contained in:
midzelis 2025-09-19 01:54:14 +00:00
parent 90ab75f968
commit 592874208b
4 changed files with 40 additions and 10 deletions

View file

@ -9,8 +9,10 @@
import StreamWithViewer from '$lib/components/timeline/StreamWithViewer.svelte'; import StreamWithViewer from '$lib/components/timeline/StreamWithViewer.svelte';
import Skeleton from '$lib/elements/Skeleton.svelte'; import Skeleton from '$lib/elements/Skeleton.svelte';
import { SearchResultsManager } from '$lib/managers/searchresults-manager/SearchResultsManager.svelte'; import { SearchResultsManager } from '$lib/managers/searchresults-manager/SearchResultsManager.svelte';
import { SearchResultsSegment } from '$lib/managers/searchresults-manager/SearchResultsSegment.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { LoadingSpinner } from '@immich/ui';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
interface Props { interface Props {
@ -92,5 +94,12 @@
{/snippet} {/snippet}
</SelectableSegment> </SelectableSegment>
{/snippet} {/snippet}
{#snippet segmentFooter({ segment })}
{#if (segment as SearchResultsSegment).hasNextPage}
<div class="w-full flex justify-center items-center h-50">
<LoadingSpinner size="giant" />
</div>
{/if}
{/snippet}
</Photostream> </Photostream>
</StreamWithViewer> </StreamWithViewer>

View file

@ -18,6 +18,13 @@
}, },
] ]
>; >;
segmentFooter: Snippet<
[
{
segment: PhotostreamSegment;
},
]
>;
skeleton: Snippet< skeleton: Snippet<
[ [
{ {
@ -59,6 +66,8 @@
let { let {
segment, segment,
segmentFooter,
skeleton,
enableRouting, enableRouting,
timelineManager = $bindable(), timelineManager = $bindable(),
@ -72,7 +81,7 @@
isShowDeleteConfirmation = $bindable(false), isShowDeleteConfirmation = $bindable(false),
children, children,
skeleton,
empty, empty,
header, header,
handleTimelineScroll = () => {}, handleTimelineScroll = () => {},
@ -89,7 +98,6 @@
let { gridScrollTarget } = assetViewingStore; let { gridScrollTarget } = assetViewingStore;
let element: HTMLElement | undefined = $state(); let element: HTMLElement | undefined = $state();
let timelineElement: HTMLElement | undefined = $state();
const maxMd = $derived(mobileDevice.maxMd); const maxMd = $derived(mobileDevice.maxMd);
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0); const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
@ -97,6 +105,9 @@
$effect(() => { $effect(() => {
const layoutOptions = maxMd ? smallHeaderHeight : largeHeaderHeight; const layoutOptions = maxMd ? smallHeaderHeight : largeHeaderHeight;
timelineManager.setLayoutOptions(layoutOptions); timelineManager.setLayoutOptions(layoutOptions);
// this next line is important in order to ensure that the reactive signals of ViewerAsset.#intersecting
// are marked as dependencies of timeline.#scrollTop.
updateSlidingWindow();
}); });
const scrollTo = (top: number) => { const scrollTo = (top: number) => {
@ -205,7 +216,6 @@
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())} onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
> >
<section <section
bind:this={timelineElement}
id="virtual-timeline" id="virtual-timeline"
class:relative={true} class:relative={true}
class:invisible={showSkeleton} class:invisible={showSkeleton}
@ -245,6 +255,15 @@
})} })}
{/if} {/if}
</div> </div>
{#if segmentFooter}
<div
style:position="absolute"
style:transform={`translate3d(0,${absoluteHeight + monthGroup.height}px,0)`}
style:width="100%"
>
{@render segmentFooter?.({ segment: monthGroup })}
</div>
{/if}
{/each} {/each}
<!-- spacer for lead-out --> <!-- spacer for lead-out -->
<div <div

View file

@ -133,7 +133,7 @@ export abstract class PhotostreamManager {
const changed = value !== this.#viewportWidth; const changed = value !== this.#viewportWidth;
this.#viewportWidth = value; this.#viewportWidth = value;
this.suspendTransitions = true; this.suspendTransitions = true;
void this.updateViewportGeometry(changed); this.updateViewportGeometry(changed);
} }
get viewportWidth() { get viewportWidth() {
@ -143,7 +143,7 @@ export abstract class PhotostreamManager {
set viewportHeight(value: number) { set viewportHeight(value: number) {
this.#viewportHeight = value; this.#viewportHeight = value;
this.#suspendTransitions = true; this.#suspendTransitions = true;
void this.updateViewportGeometry(false); this.updateViewportGeometry(false);
} }
get viewportHeight() { get viewportHeight() {
@ -151,10 +151,8 @@ export abstract class PhotostreamManager {
} }
updateSlidingWindow(scrollTop: number) { updateSlidingWindow(scrollTop: number) {
if (this.#scrollTop !== scrollTop) { this.#scrollTop = scrollTop;
this.#scrollTop = scrollTop; this.updateIntersections();
this.updateIntersections();
}
} }
updateIntersections() { updateIntersections() {

View file

@ -21,7 +21,7 @@ export class SearchResultsSegment extends PhotostreamSegment {
#id: string; #id: string;
#searchTerms: SearchTerms; #searchTerms: SearchTerms;
#currentPage: string | null = null; #currentPage: string | null = null;
#nextPage: string | null = null; #nextPage: string | null = $state(null);
#viewerAssets: ViewerAsset[] = $state([]); #viewerAssets: ViewerAsset[] = $state([]);
@ -76,6 +76,10 @@ export class SearchResultsSegment extends PhotostreamSegment {
return this.#viewerAssets; return this.#viewerAssets;
} }
get hasNextPage() {
return this.#nextPage !== null;
}
findAssetAbsolutePosition(assetId: string) { findAssetAbsolutePosition(assetId: string) {
const viewerAsset = this.#viewerAssets.find((viewAsset) => viewAsset.id === assetId); const viewerAsset = this.#viewerAssets.find((viewAsset) => viewAsset.id === assetId);
if (viewerAsset) { if (viewerAsset) {