feat: search results page (without keyboard actions)
fix: missing responsive calculation in UserPageLayoutpull/22399/head
parent
398755e65e
commit
0ebf75a96f
|
|
@ -49,7 +49,7 @@
|
||||||
<div
|
<div
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
class="relative z-0 grid grid-cols-[--spacing(0)_auto] overflow-hidden sidebar:grid-cols-[--spacing(64)_auto]
|
class="relative z-0 grid grid-cols-[--spacing(0)_auto] overflow-hidden sidebar:grid-cols-[--spacing(64)_auto]
|
||||||
{hideNavbar ? 'h-dvh' : 'h-[calc(100dvh-var(--navbar-height))]'}
|
{hideNavbar ? 'h-dvh' : 'h-[calc(100dvh-var(--navbar-height))] max-md:h-[calc(100dvh-var(--navbar-height-md))]'}
|
||||||
{hideNavbar ? 'pt-(--navbar-height)' : ''}
|
{hideNavbar ? 'pt-(--navbar-height)' : ''}
|
||||||
{hideNavbar ? 'max-md:pt-(--navbar-height-md)' : ''}"
|
{hideNavbar ? 'max-md:pt-(--navbar-height-md)' : ''}"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||||
|
import SearchResultsAssetViewer from '$lib/components/search/SearchResultsAssetViewer.svelte';
|
||||||
|
|
||||||
|
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
|
||||||
|
import Photostream from '$lib/components/timeline/Photostream.svelte';
|
||||||
|
import SelectableSegment from '$lib/components/timeline/SelectableSegment.svelte';
|
||||||
|
import StreamWithViewer from '$lib/components/timeline/StreamWithViewer.svelte';
|
||||||
|
import Skeleton from '$lib/elements/Skeleton.svelte';
|
||||||
|
import { SearchResultsManager } from '$lib/managers/searchresults-manager/SearchResultsManager.svelte';
|
||||||
|
|
||||||
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
assetInteraction: AssetInteraction;
|
||||||
|
children?: Snippet<[]>;
|
||||||
|
|
||||||
|
stylePaddingHorizontalPx?: number;
|
||||||
|
styleMarginTopPx?: number;
|
||||||
|
searchResultsManager: SearchResultsManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { searchResultsManager, assetInteraction, children, stylePaddingHorizontalPx, styleMarginTopPx }: Props =
|
||||||
|
$props();
|
||||||
|
let viewer: Photostream | undefined = $state(undefined);
|
||||||
|
|
||||||
|
const onAfterNavigateComplete = ({ scrollToAssetQueryParam }: { scrollToAssetQueryParam: boolean }) =>
|
||||||
|
viewer?.completeAfterNavigate({ scrollToAssetQueryParam });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<StreamWithViewer timelineManager={searchResultsManager} {onAfterNavigateComplete}>
|
||||||
|
{#snippet assetViewer({ onViewerClose })}
|
||||||
|
<SearchResultsAssetViewer timelineManager={searchResultsManager} {onViewerClose} />
|
||||||
|
{/snippet}
|
||||||
|
<Photostream
|
||||||
|
bind:this={viewer}
|
||||||
|
{stylePaddingHorizontalPx}
|
||||||
|
{styleMarginTopPx}
|
||||||
|
showScrollbar={true}
|
||||||
|
alwaysShowScrollbar={true}
|
||||||
|
enableRouting={true}
|
||||||
|
timelineManager={searchResultsManager}
|
||||||
|
isShowDeleteConfirmation={true}
|
||||||
|
smallHeaderHeight={{ rowHeight: 100, headerHeight: 2 }}
|
||||||
|
largeHeaderHeight={{ rowHeight: 235, headerHeight: 2 }}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
|
||||||
|
{#snippet skeleton({ segment })}
|
||||||
|
<Skeleton height={segment.height - segment.timelineManager.headerHeight} />
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet segment({ segment, onScrollCompensationMonthInDOM })}
|
||||||
|
<SelectableSegment
|
||||||
|
{segment}
|
||||||
|
{onScrollCompensationMonthInDOM}
|
||||||
|
timelineManager={searchResultsManager}
|
||||||
|
{assetInteraction}
|
||||||
|
isSelectionMode={false}
|
||||||
|
singleSelect={false}
|
||||||
|
>
|
||||||
|
{#snippet content({ onAssetOpen, onAssetSelect, onAssetHover })}
|
||||||
|
<AssetLayout
|
||||||
|
photostreamManager={searchResultsManager}
|
||||||
|
viewerAssets={segment.viewerAssets}
|
||||||
|
height={segment.height}
|
||||||
|
width={searchResultsManager.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={() => onAssetHover(asset)}
|
||||||
|
selected={isAssetSelected}
|
||||||
|
selectionCandidate={isAssetSelectionCandidate}
|
||||||
|
thumbnailWidth={position.width}
|
||||||
|
thumbnailHeight={position.height}
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
</AssetLayout>
|
||||||
|
{/snippet}
|
||||||
|
</SelectableSegment>
|
||||||
|
{/snippet}
|
||||||
|
</Photostream>
|
||||||
|
</StreamWithViewer>
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
||||||
|
import { AppRoute, AssetAction } from '$lib/constants';
|
||||||
|
import { SearchResultsManager } from '$lib/managers/searchresults-manager/SearchResultsManager.svelte';
|
||||||
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
|
import { navigate } from '$lib/utils/navigation';
|
||||||
|
let { asset: viewingAsset, setAssetId, preloadAssets } = assetViewingStore;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
timelineManager: SearchResultsManager;
|
||||||
|
|
||||||
|
onViewerClose?: (asset: { id: string }) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { timelineManager, onViewerClose = () => Promise.resolve(void 0) }: Props = $props();
|
||||||
|
|
||||||
|
const handleNext = async (): Promise<boolean> => {
|
||||||
|
const next = timelineManager.findNextAsset($viewingAsset.id);
|
||||||
|
if (next) {
|
||||||
|
await navigateToAsset(next);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRandom = async (): Promise<{ id: string } | undefined> => {
|
||||||
|
const next = timelineManager.findRandomAsset();
|
||||||
|
if (next) {
|
||||||
|
await navigateToAsset(next);
|
||||||
|
return { id: next.id };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevious = async (): Promise<boolean> => {
|
||||||
|
const next = timelineManager.findPreviousAsset($viewingAsset.id);
|
||||||
|
if (next) {
|
||||||
|
await navigateToAsset(next);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateToAsset = async (asset?: { id: string }) => {
|
||||||
|
if (asset && asset.id !== $viewingAsset.id) {
|
||||||
|
await setAssetId(asset.id);
|
||||||
|
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAction = async (action: Action) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case AssetAction.ARCHIVE:
|
||||||
|
case AssetAction.DELETE:
|
||||||
|
case AssetAction.TRASH: {
|
||||||
|
timelineManager.removeAssets([action.asset.id]);
|
||||||
|
if (!(await handlePrevious())) {
|
||||||
|
await goto(AppRoute.PHOTOS);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#await import('../asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
||||||
|
<AssetViewer
|
||||||
|
asset={$viewingAsset}
|
||||||
|
preloadAssets={$preloadAssets}
|
||||||
|
onAction={handleAction}
|
||||||
|
onPrevious={handlePrevious}
|
||||||
|
onNext={handleNext}
|
||||||
|
onRandom={handleRandom}
|
||||||
|
onClose={onViewerClose}
|
||||||
|
/>
|
||||||
|
{/await}
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
segment: PhotostreamSegment;
|
segment: PhotostreamSegment;
|
||||||
|
stylePaddingHorizontalPx: number;
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
>;
|
>;
|
||||||
|
|
@ -37,7 +38,6 @@
|
||||||
alwaysShowScrollbar?: boolean;
|
alwaysShowScrollbar?: boolean;
|
||||||
showSkeleton?: boolean;
|
showSkeleton?: boolean;
|
||||||
isShowDeleteConfirmation?: boolean;
|
isShowDeleteConfirmation?: boolean;
|
||||||
styleMarginRightOverride?: string;
|
|
||||||
|
|
||||||
header?: Snippet<[scrollToFunction: (top: number) => void]>;
|
header?: Snippet<[scrollToFunction: (top: number) => void]>;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
|
|
@ -53,8 +53,9 @@
|
||||||
rowHeight: number;
|
rowHeight: number;
|
||||||
headerHeight: number;
|
headerHeight: number;
|
||||||
};
|
};
|
||||||
styleMarginContentHorizontal?: string;
|
stylePaddingHorizontalPx?: number;
|
||||||
styleMarginTop?: string;
|
styleMarginTopPx?: number;
|
||||||
|
styleMarginRightPx?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
@ -64,9 +65,9 @@
|
||||||
timelineManager = $bindable(),
|
timelineManager = $bindable(),
|
||||||
showSkeleton = $bindable(true),
|
showSkeleton = $bindable(true),
|
||||||
showScrollbar,
|
showScrollbar,
|
||||||
styleMarginRightOverride,
|
styleMarginRightPx = 0,
|
||||||
styleMarginContentHorizontal = '0px',
|
stylePaddingHorizontalPx = 0,
|
||||||
styleMarginTop = '0px',
|
styleMarginTopPx = 0,
|
||||||
alwaysShowScrollbar,
|
alwaysShowScrollbar,
|
||||||
|
|
||||||
isShowDeleteConfirmation = $bindable(false),
|
isShowDeleteConfirmation = $bindable(false),
|
||||||
|
|
@ -221,23 +222,26 @@
|
||||||
{ 'm-0': isEmpty },
|
{ 'm-0': isEmpty },
|
||||||
{ 'ms-0': !isEmpty },
|
{ 'ms-0': !isEmpty },
|
||||||
]}
|
]}
|
||||||
style:height={`calc(100% - ${styleMarginTop})`}
|
style:height={`calc(100% - ${styleMarginTopPx}px)`}
|
||||||
style:margin-top={styleMarginTop}
|
style:margin-top={styleMarginTopPx + 'px'}
|
||||||
style:margin-right={styleMarginRightOverride}
|
style:margin-right={styleMarginRightPx + 'px'}
|
||||||
|
style:padding-left={stylePaddingHorizontalPx + 'px'}
|
||||||
|
style:padding-right={stylePaddingHorizontalPx + 'px'}
|
||||||
style:scrollbar-width={showScrollbar ? 'thin' : 'none'}
|
style:scrollbar-width={showScrollbar ? 'thin' : 'none'}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
bind:clientHeight={timelineManager.viewportHeight}
|
bind:clientHeight={timelineManager.viewportHeight}
|
||||||
|
bind:clientWidth={
|
||||||
|
null, (v: number) => ((timelineManager.viewportWidth = v - stylePaddingHorizontalPx * 2), updateSlidingWindow())
|
||||||
|
}
|
||||||
bind:this={element}
|
bind:this={element}
|
||||||
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
|
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
bind:this={timelineElement}
|
bind:this={timelineElement}
|
||||||
id="virtual-timeline"
|
id="virtual-timeline"
|
||||||
style:margin-left={styleMarginContentHorizontal}
|
class:relative={true}
|
||||||
style:margin-right={styleMarginContentHorizontal}
|
|
||||||
class:invisible={showSkeleton}
|
class:invisible={showSkeleton}
|
||||||
style:height={timelineManager.timelineHeight + 'px'}
|
style:height={timelineManager.timelineHeight + 'px'}
|
||||||
bind:clientWidth={null, (v: number) => ((timelineManager.viewportWidth = v), updateSlidingWindow())}
|
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
use:resizeObserver={topSectionResizeObserver}
|
use:resizeObserver={topSectionResizeObserver}
|
||||||
|
|
@ -252,7 +256,6 @@
|
||||||
{@render empty?.()}
|
{@render empty?.()}
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#each timelineManager.months as monthGroup (monthGroup.id)}
|
{#each timelineManager.months as monthGroup (monthGroup.id)}
|
||||||
{@const shouldDisplay = monthGroup.intersecting && monthGroup.isLoaded}
|
{@const shouldDisplay = monthGroup.intersecting && monthGroup.isLoaded}
|
||||||
{@const absoluteHeight = monthGroup.top}
|
{@const absoluteHeight = monthGroup.top}
|
||||||
|
|
@ -266,7 +269,7 @@
|
||||||
style:width="100%"
|
style:width="100%"
|
||||||
>
|
>
|
||||||
{#if !shouldDisplay}
|
{#if !shouldDisplay}
|
||||||
{@render skeleton({ segment: monthGroup })}
|
{@render skeleton({ segment: monthGroup, stylePaddingHorizontalPx })}
|
||||||
{:else}
|
{:else}
|
||||||
{@render segment({
|
{@render segment({
|
||||||
segment: monthGroup,
|
segment: monthGroup,
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,7 @@
|
||||||
{timelineManager}
|
{timelineManager}
|
||||||
{showSkeleton}
|
{showSkeleton}
|
||||||
{isShowDeleteConfirmation}
|
{isShowDeleteConfirmation}
|
||||||
styleMarginRightOverride={scrubberWidth + 'px'}
|
styleMarginRightPx={scrubberWidth}
|
||||||
{handleTimelineScroll}
|
{handleTimelineScroll}
|
||||||
{segment}
|
{segment}
|
||||||
{skeleton}
|
{skeleton}
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,10 @@
|
||||||
menuItem?: boolean;
|
menuItem?: boolean;
|
||||||
unarchive?: boolean;
|
unarchive?: boolean;
|
||||||
manager?: PhotostreamManager;
|
manager?: PhotostreamManager;
|
||||||
|
removeOnArchive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onArchive, menuItem = false, unarchive = false, manager }: Props = $props();
|
let { onArchive, menuItem = false, unarchive = false, manager, removeOnArchive }: Props = $props();
|
||||||
|
|
||||||
let text = $derived(unarchive ? $t('unarchive') : $t('to_archive'));
|
let text = $derived(unarchive ? $t('unarchive') : $t('to_archive'));
|
||||||
let icon = $derived(unarchive ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline);
|
let icon = $derived(unarchive ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline);
|
||||||
|
|
@ -31,7 +32,12 @@
|
||||||
loading = true;
|
loading = true;
|
||||||
const ids = await archiveAssets(assets, visibility as AssetVisibility);
|
const ids = await archiveAssets(assets, visibility as AssetVisibility);
|
||||||
if (ids) {
|
if (ids) {
|
||||||
manager?.updateAssetOperation(ids, (asset) => ((asset.visibility = visibility), void 0));
|
manager?.updateAssetOperation(ids, (asset) => {
|
||||||
|
asset.visibility = visibility;
|
||||||
|
return {
|
||||||
|
remove: removeOnArchive,
|
||||||
|
};
|
||||||
|
});
|
||||||
onArchive?.(ids, visibility ? AssetVisibility.Archive : AssetVisibility.Timeline);
|
onArchive?.(ids, visibility ? AssetVisibility.Archive : AssetVisibility.Timeline);
|
||||||
clearSelect();
|
clearSelect();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,11 @@
|
||||||
const assets = [...getOwnedAssets()];
|
const assets = [...getOwnedAssets()];
|
||||||
const undo = (assets: TimelineAsset[]) => {
|
const undo = (assets: TimelineAsset[]) => {
|
||||||
manager?.upsertAssets(assets);
|
manager?.upsertAssets(assets);
|
||||||
|
manager?.order(assetOrdering ?? []);
|
||||||
onUndoDelete?.(assets);
|
onUndoDelete?.(assets);
|
||||||
};
|
};
|
||||||
await deleteAssets(force, onAssetDelete, assets, undo);
|
await deleteAssets(force, onAssetDelete, assets, undo);
|
||||||
|
const assetOrdering = manager?.assets.map((asset) => asset.id);
|
||||||
manager?.removeAssets(assets.map((asset) => asset.id));
|
manager?.removeAssets(assets.map((asset) => asset.id));
|
||||||
clearSelect();
|
clearSelect();
|
||||||
isShowConfirmation = false;
|
isShowConfirmation = false;
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,13 @@
|
||||||
interface Props {
|
interface Props {
|
||||||
height: number;
|
height: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
stylePaddingHorizontalPx?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { height = 0, title }: Props = $props();
|
let { height = 0, title, stylePaddingHorizontalPx = 0 }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="overflow-clip" style:height={height + 'px'}>
|
<skeleton class="overflow-clip" style:height={height + 'px'}>
|
||||||
{#if title}
|
{#if title}
|
||||||
<div
|
<div
|
||||||
class="flex pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-light dark:text-immich-dark-fg md:text-sm"
|
class="flex pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-light dark:text-immich-dark-fg md:text-sm"
|
||||||
|
|
@ -16,11 +17,13 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div
|
<div
|
||||||
class="animate-pulse absolute h-full ms-[10px] me-[10px]"
|
class="animate-pulse absolute h-full"
|
||||||
style:width="calc(100% - 20px)"
|
style:width="100%"
|
||||||
|
style:padding-left={stylePaddingHorizontalPx + 'px'}
|
||||||
|
style:padding-right={stylePaddingHorizontalPx + 'px'}
|
||||||
data-skeleton="true"
|
data-skeleton="true"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</skeleton>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
[data-skeleton] {
|
[data-skeleton] {
|
||||||
|
|
|
||||||
|
|
@ -328,6 +328,10 @@ export abstract class PhotostreamManager {
|
||||||
return Promise.resolve(this.retrieveLoadedRange(start, end));
|
return Promise.resolve(this.retrieveLoadedRange(start, end));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get assets() {
|
||||||
|
return this.months.flatMap((segment) => segment.assets).flat();
|
||||||
|
}
|
||||||
|
|
||||||
updateAssetOperation(ids: string[], operation: AssetOperation) {
|
updateAssetOperation(ids: string[], operation: AssetOperation) {
|
||||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||||
return this.#runAssetOperation(new Set(ids), operation);
|
return this.#runAssetOperation(new Set(ids), operation);
|
||||||
|
|
@ -429,4 +433,10 @@ export abstract class PhotostreamManager {
|
||||||
}
|
}
|
||||||
return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
|
return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests that the manager's content be explictly set to the given id ordering.
|
||||||
|
* A particular Photostream manager may ignore this request.
|
||||||
|
*/
|
||||||
|
order(_: string[]) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,13 +66,18 @@ export abstract class PhotostreamSegment {
|
||||||
}
|
}
|
||||||
|
|
||||||
async load(cancelable: boolean): Promise<'DONE' | 'WAITED' | 'CANCELED' | 'LOADED' | 'ERRORED'> {
|
async load(cancelable: boolean): Promise<'DONE' | 'WAITED' | 'CANCELED' | 'LOADED' | 'ERRORED'> {
|
||||||
return await this.loader?.execute(async (signal: AbortSignal) => {
|
return await this.loader.execute(async (signal: AbortSignal) => {
|
||||||
await this.fetch(signal);
|
await this.fetch(signal);
|
||||||
}, cancelable);
|
}, cancelable);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected abstract fetch(signal: AbortSignal): Promise<void>;
|
protected abstract fetch(signal: AbortSignal): Promise<void>;
|
||||||
|
|
||||||
|
async reload(cancelable: boolean): Promise<'DONE' | 'WAITED' | 'CANCELED' | 'LOADED' | 'ERRORED'> {
|
||||||
|
await this.loader.reset();
|
||||||
|
return await this.load(cancelable);
|
||||||
|
}
|
||||||
|
|
||||||
get assets(): TimelineAsset[] {
|
get assets(): TimelineAsset[] {
|
||||||
return this.#assets;
|
return this.#assets;
|
||||||
}
|
}
|
||||||
|
|
@ -139,7 +144,7 @@ export abstract class PhotostreamSegment {
|
||||||
this.loader?.cancel();
|
this.loader?.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
layout(_: boolean) {}
|
layout(_?: boolean) {}
|
||||||
|
|
||||||
updateIntersection({ intersecting, actuallyIntersecting }: { intersecting: boolean; actuallyIntersecting: boolean }) {
|
updateIntersection({ intersecting, actuallyIntersecting }: { intersecting: boolean; actuallyIntersecting: boolean }) {
|
||||||
this.intersecting = intersecting;
|
this.intersecting = intersecting;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
|
||||||
|
import { SearchResultsSegment } from '$lib/managers/searchresults-manager/SearchResultsSegment.svelte';
|
||||||
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
|
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
|
||||||
|
import { isEqual } from 'lodash-es';
|
||||||
|
|
||||||
|
export type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query' | 'queryAssetId'> & { isVisible: boolean };
|
||||||
|
|
||||||
|
export type Options = {
|
||||||
|
searchTerms: SearchTerms;
|
||||||
|
isSmartSearchEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class SearchResultsManager extends PhotostreamManager {
|
||||||
|
#options: Options = {
|
||||||
|
searchTerms: {
|
||||||
|
isVisible: false,
|
||||||
|
},
|
||||||
|
isSmartSearchEnabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
#months: SearchResultsSegment[] = $state([]);
|
||||||
|
|
||||||
|
get options() {
|
||||||
|
return this.#options;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOptions(options: Options) {
|
||||||
|
if (isEqual(this.#options, options)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.initTask.reset();
|
||||||
|
this.#options = options;
|
||||||
|
await this.init();
|
||||||
|
this.updateViewportGeometry(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
this.isInitialized = false;
|
||||||
|
await this.initTask.execute(() => {
|
||||||
|
this.#months.splice(0);
|
||||||
|
this.#months.push(new SearchResultsSegment(this, '1', { ...this.#options.searchTerms }));
|
||||||
|
return Promise.resolve();
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
this.updateViewportGeometry(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
get months(): SearchResultsSegment[] {
|
||||||
|
return this.#months;
|
||||||
|
}
|
||||||
|
|
||||||
|
findNextAsset(currentAssetId: string): { id: string } | undefined {
|
||||||
|
for (let segmentIndex = 0; segmentIndex < this.#months.length; segmentIndex++) {
|
||||||
|
const segment = this.#months[segmentIndex];
|
||||||
|
const assetIndex = segment.assets.findIndex((asset) => asset.id === currentAssetId);
|
||||||
|
|
||||||
|
if (assetIndex !== -1) {
|
||||||
|
// Found the current asset
|
||||||
|
if (assetIndex < segment.assets.length - 1) {
|
||||||
|
// Next asset is in the same segment
|
||||||
|
return segment.assets[assetIndex + 1];
|
||||||
|
} else if (segmentIndex < this.#months.length - 1) {
|
||||||
|
// Next asset is in the next segment
|
||||||
|
const nextSegment = this.#months[segmentIndex + 1];
|
||||||
|
if (nextSegment.assets.length > 0) {
|
||||||
|
return nextSegment.assets[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
findPreviousAsset(currentAssetId: string): { id: string } | undefined {
|
||||||
|
for (let segmentIndex = 0; segmentIndex < this.#months.length; segmentIndex++) {
|
||||||
|
const segment = this.#months[segmentIndex];
|
||||||
|
const assetIndex = segment.assets.findIndex((asset) => asset.id === currentAssetId);
|
||||||
|
|
||||||
|
if (assetIndex !== -1) {
|
||||||
|
// Found the current asset
|
||||||
|
if (assetIndex > 0) {
|
||||||
|
// Previous asset is in the same segment
|
||||||
|
return segment.assets[assetIndex - 1];
|
||||||
|
} else if (segmentIndex > 0) {
|
||||||
|
// Previous asset is in the previous segment
|
||||||
|
const previousSegment = this.#months[segmentIndex - 1];
|
||||||
|
if (previousSegment.assets.length > 0) {
|
||||||
|
return previousSegment.assets.at(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
findRandomAsset(): { id: string } | undefined {
|
||||||
|
// Get all loaded assets across all segments
|
||||||
|
const allAssets = this.#months.flatMap((segment) => segment.assets);
|
||||||
|
|
||||||
|
if (allAssets.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a random asset
|
||||||
|
const randomIndex = Math.floor(Math.random() * allAssets.length);
|
||||||
|
return allAssets[randomIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected upsertAssetIntoSegment(asset: TimelineAsset): void {
|
||||||
|
this.#months.at(-1)?.addTimelineAsset(asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
order(ids: string[]) {
|
||||||
|
this.#months.at(-1)?.order(ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
import {
|
||||||
|
PhotostreamSegment,
|
||||||
|
type SegmentIdentifier,
|
||||||
|
} from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
|
||||||
|
import type {
|
||||||
|
SearchResultsManager,
|
||||||
|
SearchTerms,
|
||||||
|
} from '$lib/managers/searchresults-manager/SearchResultsManager.svelte';
|
||||||
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
|
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 { TUNABLES } from '$lib/utils/tunables';
|
||||||
|
import { searchAssets, searchSmart } from '@immich/sdk';
|
||||||
|
import { isEqual } from 'lodash-es';
|
||||||
|
const {
|
||||||
|
TIMELINE: { INTERSECTION_EXPAND_BOTTOM },
|
||||||
|
} = TUNABLES;
|
||||||
|
export class SearchResultsSegment extends PhotostreamSegment {
|
||||||
|
#manager: SearchResultsManager;
|
||||||
|
#identifier: SegmentIdentifier;
|
||||||
|
#id: string;
|
||||||
|
#searchTerms: SearchTerms;
|
||||||
|
#currentPage: string | null = null;
|
||||||
|
#nextPage: string | null = null;
|
||||||
|
|
||||||
|
#viewerAssets: ViewerAsset[] = $state([]);
|
||||||
|
|
||||||
|
constructor(manager: SearchResultsManager, currentPage: string, searchTerms: SearchTerms) {
|
||||||
|
super();
|
||||||
|
this.#currentPage = currentPage;
|
||||||
|
this.#manager = manager;
|
||||||
|
this.#searchTerms = searchTerms;
|
||||||
|
this.#id = JSON.stringify(searchTerms);
|
||||||
|
this.#identifier = {
|
||||||
|
matches(segment: SearchResultsSegment) {
|
||||||
|
return isEqual(segment.#searchTerms, searchTerms);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.initialCount = searchTerms.size || 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
get timelineManager(): SearchResultsManager {
|
||||||
|
return this.#manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
get identifier(): SegmentIdentifier {
|
||||||
|
return this.#identifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
get id(): string {
|
||||||
|
return this.#id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetch(): Promise<void> {
|
||||||
|
if (this.#currentPage === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const searchDto: SearchTerms = {
|
||||||
|
...this.#searchTerms,
|
||||||
|
withExif: true,
|
||||||
|
isVisible: true,
|
||||||
|
page: +this.#currentPage,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { assets } =
|
||||||
|
('query' in searchDto || 'queryAssetId' in searchDto) && this.#manager.options.isSmartSearchEnabled
|
||||||
|
? await searchSmart({ smartSearchDto: searchDto })
|
||||||
|
: await searchAssets({ metadataSearchDto: searchDto });
|
||||||
|
this.#nextPage = assets.nextPage;
|
||||||
|
|
||||||
|
this.#viewerAssets.push(...assets.items.map((asset) => new ViewerAsset(this, toTimelineAsset(asset))));
|
||||||
|
this.layout();
|
||||||
|
}
|
||||||
|
|
||||||
|
layout(): void {
|
||||||
|
const timelineAssets = this.#viewerAssets.map((viewerAsset) => viewerAsset.asset);
|
||||||
|
const layoutOptions = this.timelineManager.createLayoutOptions();
|
||||||
|
const geometry = getJustifiedLayoutFromAssets(timelineAssets, layoutOptions);
|
||||||
|
|
||||||
|
this.height = timelineAssets.length === 0 ? 0 : geometry.containerHeight + this.timelineManager.headerHeight;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadNextPage() {
|
||||||
|
if (this.#nextPage !== null) {
|
||||||
|
if (this.#currentPage === this.#nextPage) {
|
||||||
|
// there's an active load
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#currentPage = this.#nextPage;
|
||||||
|
void this.reload(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateIntersection({ intersecting, actuallyIntersecting }: { intersecting: boolean; actuallyIntersecting: boolean }) {
|
||||||
|
super.updateIntersection({ intersecting, actuallyIntersecting });
|
||||||
|
const loadNextPage =
|
||||||
|
this.timelineManager.timelineHeight - this.timelineManager.visibleWindow.bottom < INTERSECTION_EXPAND_BOTTOM;
|
||||||
|
if (loadNextPage) {
|
||||||
|
this.loadNextPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addTimelineAsset(asset: TimelineAsset) {
|
||||||
|
this.#viewerAssets.push(new ViewerAsset(this, asset));
|
||||||
|
this.layout();
|
||||||
|
}
|
||||||
|
|
||||||
|
order(ids: string[]) {
|
||||||
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||||
|
const idMap = new Map(ids.map((id, index) => [id, index]));
|
||||||
|
this.#viewerAssets.sort((a, b) => {
|
||||||
|
const indexA = idMap.get(a.id) ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const indexB = idMap.get(b.id) ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
return indexA - indexB;
|
||||||
|
});
|
||||||
|
this.layout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -306,7 +306,7 @@ export class MonthGroup extends PhotostreamSegment {
|
||||||
this.loader?.cancel();
|
this.loader?.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
layout(noDefer: boolean) {
|
layout(noDefer?: boolean) {
|
||||||
layoutMonthGroup(this.timelineManager, this, noDefer);
|
layoutMonthGroup(this.timelineManager, this, noDefer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { PhotostreamSegment } from '$lib/managers/photostream-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,15 +6,20 @@ 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 store = this.#group.monthGroup.timelineManager;
|
const dayGroup = this.#group as DayGroup;
|
||||||
const positionTop = this.#group.absoluteDayGroupTop + this.position.top;
|
const store = dayGroup.monthGroup.timelineManager;
|
||||||
|
const positionTop = dayGroup.absoluteDayGroupTop + this.position.top;
|
||||||
|
return calculateViewerAssetIntersecting(store, positionTop, this.position.height);
|
||||||
|
}
|
||||||
|
const store = (this.#group as PhotostreamSegment).timelineManager;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { afterNavigate, goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { shortcut } from '$lib/actions/shortcut';
|
import { shortcut } from '$lib/actions/shortcut';
|
||||||
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
|
import SearchResults from '$lib/components/search/SearchResults.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';
|
||||||
import AssetJobActions from '$lib/components/timeline/actions/AssetJobActions.svelte';
|
import AssetJobActions from '$lib/components/timeline/actions/AssetJobActions.svelte';
|
||||||
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
|
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
|
||||||
import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte';
|
import ChangeDescriptionAction from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte';
|
||||||
import ChangeLocation from '$lib/components/timeline/actions/ChangeLocationAction.svelte';
|
import ChangeLocation from '$lib/components/timeline/actions/ChangeLocationAction.svelte';
|
||||||
import CreateSharedLink from '$lib/components/timeline/actions/CreateSharedLinkAction.svelte';
|
import CreateSharedLink from '$lib/components/timeline/actions/CreateSharedLinkAction.svelte';
|
||||||
import DeleteAssets from '$lib/components/timeline/actions/DeleteAssetsAction.svelte';
|
import DeleteAssets from '$lib/components/timeline/actions/DeleteAssetsAction.svelte';
|
||||||
|
|
@ -20,66 +19,39 @@
|
||||||
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 { AppRoute, QueryParameter } from '$lib/constants';
|
import { AppRoute, QueryParameter } from '$lib/constants';
|
||||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
import { SearchResultsManager } from '$lib/managers/searchresults-manager/SearchResultsManager.svelte';
|
||||||
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
import type { Viewport } from '$lib/managers/timeline-manager/types';
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { lang, locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { featureFlags } from '$lib/stores/server-config.store';
|
import { featureFlags } from '$lib/stores/server-config.store';
|
||||||
import { preferences } from '$lib/stores/user.store';
|
import { preferences } from '$lib/stores/user.store';
|
||||||
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 { getPerson, getTagById, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk';
|
||||||
import { isAlbumsRoute, isPeopleRoute } from '$lib/utils/navigation';
|
import { IconButton } from '@immich/ui';
|
||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
import { mdiArrowLeft, mdiDotsVertical, mdiPlus, mdiSelectAll } from '@mdi/js';
|
||||||
import {
|
|
||||||
type AlbumResponseDto,
|
|
||||||
getPerson,
|
|
||||||
getTagById,
|
|
||||||
type MetadataSearchDto,
|
|
||||||
searchAssets,
|
|
||||||
searchSmart,
|
|
||||||
type SmartSearchDto,
|
|
||||||
} from '@immich/sdk';
|
|
||||||
import { Icon, IconButton, LoadingSpinner } from '@immich/ui';
|
|
||||||
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
|
|
||||||
import { tick } from 'svelte';
|
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
let { isViewing: showAssetViewer } = assetViewingStore;
|
let { isViewing: showAssetViewer } = assetViewingStore;
|
||||||
const viewport: Viewport = $state({ width: 0, height: 0 });
|
const viewport: Viewport = $state({ width: 0, height: 0 });
|
||||||
let searchResultsElement: HTMLElement | undefined = $state();
|
|
||||||
|
|
||||||
// The GalleryViewer pushes it's own history state, which causes weird
|
|
||||||
// behavior for history.back(). To prevent that we store the previous page
|
|
||||||
// manually and navigate back to that.
|
|
||||||
let previousRoute = $state(AppRoute.EXPLORE as string);
|
let previousRoute = $state(AppRoute.EXPLORE as string);
|
||||||
|
|
||||||
let nextPage = $state(1);
|
|
||||||
let searchResultAlbums: AlbumResponseDto[] = $state([]);
|
|
||||||
let searchResultAssets: TimelineAsset[] = $state([]);
|
|
||||||
let isLoading = $state(true);
|
|
||||||
let scrollY = $state(0);
|
|
||||||
let scrollYHistory = 0;
|
|
||||||
|
|
||||||
const assetInteraction = new AssetInteraction();
|
const assetInteraction = new AssetInteraction();
|
||||||
|
|
||||||
type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query' | 'queryAssetId'>;
|
type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query' | 'queryAssetId'>;
|
||||||
let searchQuery = $derived(page.url.searchParams.get(QueryParameter.QUERY));
|
let searchQuery = $derived(page.url.searchParams.get(QueryParameter.QUERY));
|
||||||
let smartSearchEnabled = $derived($featureFlags.loaded && $featureFlags.smartSearch);
|
let isSmartSearchEnabled = $derived($featureFlags.loaded && $featureFlags.smartSearch);
|
||||||
let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {});
|
let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {});
|
||||||
|
|
||||||
$effect(() => {
|
const searchResultsManager = new SearchResultsManager();
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
||||||
terms;
|
|
||||||
setTimeout(() => {
|
|
||||||
handlePromiseError(onSearchQueryUpdate());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let timelineManager = new TimelineManager();
|
$effect(() => {
|
||||||
|
void searchResultsManager.updateOptions({ isSmartSearchEnabled, searchTerms: terms });
|
||||||
|
});
|
||||||
|
|
||||||
const onEscape = () => {
|
const onEscape = () => {
|
||||||
if ($showAssetViewer) {
|
if ($showAssetViewer) {
|
||||||
|
|
@ -90,91 +62,16 @@
|
||||||
assetInteraction.selectedAssets = [];
|
assetInteraction.selectedAssets = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handlePromiseError(goto(previousRoute));
|
|
||||||
};
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (scrollY) {
|
|
||||||
scrollYHistory = scrollY;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
afterNavigate(({ from }) => {
|
|
||||||
// Prevent setting previousRoute to the current page.
|
|
||||||
if (from?.url && from.route.id !== page.route.id) {
|
|
||||||
previousRoute = from.url.href;
|
|
||||||
}
|
|
||||||
const route = from?.route?.id;
|
|
||||||
|
|
||||||
if (isPeopleRoute(route)) {
|
|
||||||
previousRoute = AppRoute.PHOTOS;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAlbumsRoute(route)) {
|
|
||||||
previousRoute = AppRoute.EXPLORE;
|
|
||||||
}
|
|
||||||
|
|
||||||
tick()
|
|
||||||
.then(() => {
|
|
||||||
window.scrollTo(0, scrollYHistory);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// do nothing
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const onAssetDelete = (assetIds: string[]) => {
|
|
||||||
const assetIdSet = new Set(assetIds);
|
|
||||||
searchResultAssets = searchResultAssets.filter((asset: TimelineAsset) => !assetIdSet.has(asset.id));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSetVisibility = (assetIds: string[]) => {
|
const handleSetVisibility = (assetIds: string[]) => {
|
||||||
timelineManager.removeAssets(assetIds);
|
searchResultsManager.removeAssets(assetIds);
|
||||||
assetInteraction.clearMultiselect();
|
assetInteraction.clearMultiselect();
|
||||||
onAssetDelete(assetIds);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
const handleSelectAll = () => {
|
||||||
assetInteraction.selectAssets(searchResultAssets);
|
const allAssets = searchResultsManager.months.flatMap((segment) => segment.assets);
|
||||||
};
|
assetInteraction.selectAssets(allAssets);
|
||||||
|
|
||||||
async function onSearchQueryUpdate() {
|
|
||||||
nextPage = 1;
|
|
||||||
searchResultAssets = [];
|
|
||||||
searchResultAlbums = [];
|
|
||||||
await loadNextPage(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line svelte/valid-prop-names-in-kit-pages
|
|
||||||
export const loadNextPage = async (force?: boolean) => {
|
|
||||||
if (!nextPage || (isLoading && !force)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
isLoading = true;
|
|
||||||
|
|
||||||
const searchDto: SearchTerms = {
|
|
||||||
page: nextPage,
|
|
||||||
withExif: true,
|
|
||||||
isVisible: true,
|
|
||||||
language: $lang,
|
|
||||||
...terms,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { albums, assets } =
|
|
||||||
('query' in searchDto || 'queryAssetId' in searchDto) && smartSearchEnabled
|
|
||||||
? await searchSmart({ smartSearchDto: searchDto })
|
|
||||||
: await searchAssets({ metadataSearchDto: searchDto });
|
|
||||||
|
|
||||||
searchResultAlbums.push(...albums.items);
|
|
||||||
searchResultAssets.push(...assets.items.map((asset) => toTimelineAsset(asset)));
|
|
||||||
|
|
||||||
nextPage = Number(assets.nextPage) || 0;
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, $t('loading_search_results_failed'));
|
|
||||||
} finally {
|
|
||||||
isLoading = false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function getHumanReadableDate(dateString: string) {
|
function getHumanReadableDate(dateString: string) {
|
||||||
|
|
@ -248,8 +145,7 @@
|
||||||
cancelMultiselect(assetInteraction);
|
cancelMultiselect(assetInteraction);
|
||||||
|
|
||||||
if (terms.isNotInAlbum.toString() == 'true') {
|
if (terms.isNotInAlbum.toString() == 'true') {
|
||||||
const assetIdSet = new Set(assetIds);
|
searchResultsManager.removeAssets(assetIds);
|
||||||
searchResultAssets = searchResultAssets.filter((asset) => !assetIdSet.has(asset.id));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -258,12 +154,11 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window bind:scrollY />
|
|
||||||
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onEscape }} />
|
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onEscape }} />
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
{#if assetInteraction.selectionActive}
|
{#if assetInteraction.selectionActive}
|
||||||
<div class="fixed top-0 start-0 w-full">
|
<div class="fixed z-1 top-0 start-0 w-full">
|
||||||
<AssetSelectControlBar
|
<AssetSelectControlBar
|
||||||
assets={assetInteraction.selectedAssets}
|
assets={assetInteraction.selectedAssets}
|
||||||
clearSelect={() => cancelMultiselect(assetInteraction)}
|
clearSelect={() => cancelMultiselect(assetInteraction)}
|
||||||
|
|
@ -281,34 +176,29 @@
|
||||||
<AddToAlbum {onAddToAlbum} />
|
<AddToAlbum {onAddToAlbum} />
|
||||||
<AddToAlbum shared {onAddToAlbum} />
|
<AddToAlbum shared {onAddToAlbum} />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
<FavoriteAction
|
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} manager={searchResultsManager} />
|
||||||
removeFavorite={assetInteraction.isAllFavorite}
|
|
||||||
onFavorite={(assetIds, isFavorite) => {
|
|
||||||
for (const assetId of assetIds) {
|
|
||||||
const asset = searchResultAssets.find((searchAsset) => searchAsset.id === assetId);
|
|
||||||
if (asset) {
|
|
||||||
asset.isFavorite = isFavorite;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||||
<DownloadAction menuItem />
|
<DownloadAction menuItem />
|
||||||
<ChangeDate menuItem />
|
<ChangeDate menuItem />
|
||||||
<ChangeLocation menuItem />
|
<ChangeLocation menuItem />
|
||||||
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
|
<ArchiveAction
|
||||||
|
menuItem
|
||||||
|
removeOnArchive={true}
|
||||||
|
unarchive={assetInteraction.isAllArchived}
|
||||||
|
manager={searchResultsManager}
|
||||||
|
/>
|
||||||
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
||||||
<TagAction menuItem />
|
<TagAction menuItem />
|
||||||
{/if}
|
{/if}
|
||||||
<DeleteAssets menuItem {onAssetDelete} onUndoDelete={onSearchQueryUpdate} />
|
<DeleteAssets menuItem manager={searchResultsManager} />
|
||||||
<hr />
|
<hr />
|
||||||
<AssetJobActions />
|
<AssetJobActions />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="fixed top-0 start-0 w-full">
|
<div class="fixed z-1 top-0 start-0 w-full">
|
||||||
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
|
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
|
||||||
<div class="absolute bg-light"></div>
|
<div class="absolute bg-light"></div>
|
||||||
<div class="w-full flex-1 ps-4">
|
<div class="w-full flex-1 ps-4">
|
||||||
|
|
@ -319,92 +209,55 @@
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if terms}
|
|
||||||
<section
|
|
||||||
id="search-chips"
|
|
||||||
class="mt-24 text-center w-full flex gap-5 place-content-center place-items-center flex-wrap px-24"
|
|
||||||
>
|
|
||||||
{#each getObjectKeys(terms) as searchKey (searchKey)}
|
|
||||||
{@const value = terms[searchKey]}
|
|
||||||
<div class="flex place-content-center place-items-center items-stretch text-xs">
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-center bg-immich-primary py-2 px-4 text-white dark:text-black dark:bg-immich-dark-primary
|
|
||||||
{value === true ? 'rounded-full' : 'rounded-s-full'}"
|
|
||||||
>
|
|
||||||
{getHumanReadableSearchKey(searchKey as keyof SearchTerms)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if value !== true}
|
|
||||||
<div class="bg-gray-300 py-2 px-4 dark:bg-gray-800 dark:text-white rounded-e-full">
|
|
||||||
{#if (searchKey === 'takenAfter' || searchKey === 'takenBefore') && typeof value === 'string'}
|
|
||||||
{getHumanReadableDate(value)}
|
|
||||||
{:else if searchKey === 'personIds' && Array.isArray(value)}
|
|
||||||
{#await getPersonName(value) then personName}
|
|
||||||
{personName}
|
|
||||||
{/await}
|
|
||||||
{:else if searchKey === 'tagIds' && (Array.isArray(value) || value === null)}
|
|
||||||
{#await getTagNames(value) then tagNames}
|
|
||||||
{tagNames}
|
|
||||||
{/await}
|
|
||||||
{:else if value === null || value === ''}
|
|
||||||
{$t('unknown')}
|
|
||||||
{:else}
|
|
||||||
{value}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<section
|
<section
|
||||||
class="mb-12 bg-immich-bg dark:bg-immich-dark-bg m-4 max-h-screen"
|
class="absolute top-0 right-0 left-0 bottom-0 mb-0 bg-immich-bg dark:bg-immich-dark-bg max-h-screen"
|
||||||
bind:clientHeight={viewport.height}
|
bind:clientHeight={viewport.height}
|
||||||
bind:clientWidth={viewport.width}
|
bind:clientWidth={viewport.width}
|
||||||
bind:this={searchResultsElement}
|
|
||||||
>
|
>
|
||||||
{#if searchResultAlbums.length > 0}
|
<SearchResults {searchResultsManager} {assetInteraction} stylePaddingHorizontalPx={16}>
|
||||||
<section>
|
{#if terms}
|
||||||
<div class="uppercase ms-6 text-4xl font-medium text-black/70 dark:text-white/80">{$t('albums')}</div>
|
<section
|
||||||
<AlbumCardGroup albums={searchResultAlbums} showDateRange showItemCount />
|
id="search-chips"
|
||||||
|
class="md:mt-[94px] mt-[72px] mb-4 text-center w-full flex gap-5 place-content-center place-items-center flex-wrap"
|
||||||
|
>
|
||||||
|
{#each getObjectKeys(terms) as searchKey (searchKey)}
|
||||||
|
{@const value = terms[searchKey]}
|
||||||
|
<div class="flex place-content-center place-items-center items-stretch text-xs">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center bg-immich-primary py-2 px-4 text-white dark:text-black dark:bg-immich-dark-primary
|
||||||
|
{value === true ? 'rounded-full' : 'rounded-s-full'}"
|
||||||
|
>
|
||||||
|
{getHumanReadableSearchKey(searchKey as keyof SearchTerms)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="uppercase m-6 text-4xl font-medium text-black/70 dark:text-white/80">
|
{#if value !== true}
|
||||||
{$t('photos_and_videos')}
|
<div class="bg-gray-300 py-2 px-4 dark:bg-gray-800 dark:text-white rounded-e-full">
|
||||||
</div>
|
{#if (searchKey === 'takenAfter' || searchKey === 'takenBefore') && typeof value === 'string'}
|
||||||
</section>
|
{getHumanReadableDate(value)}
|
||||||
{/if}
|
{:else if searchKey === 'personIds' && Array.isArray(value)}
|
||||||
<section id="search-content">
|
{#await getPersonName(value) then personName}
|
||||||
{#if searchResultAssets.length > 0}
|
{personName}
|
||||||
<GalleryViewer
|
{/await}
|
||||||
assets={searchResultAssets}
|
{:else if searchKey === 'tagIds' && (Array.isArray(value) || value === null)}
|
||||||
{assetInteraction}
|
{#await getTagNames(value) then tagNames}
|
||||||
onIntersected={loadNextPage}
|
{tagNames}
|
||||||
showArchiveIcon={true}
|
{/await}
|
||||||
{viewport}
|
{:else if value === null || value === ''}
|
||||||
onReload={onSearchQueryUpdate}
|
{$t('unknown')}
|
||||||
slidingWindowOffset={searchResultsElement.offsetTop}
|
{:else}
|
||||||
/>
|
{value}
|
||||||
{:else if !isLoading}
|
{/if}
|
||||||
<div class="flex min-h-[calc(66vh-11rem)] w-full place-content-center items-center dark:text-white">
|
</div>
|
||||||
<div class="flex flex-col content-center items-center text-center">
|
{/if}
|
||||||
<Icon icon={mdiImageOffOutline} size="3.5em" />
|
</div>
|
||||||
<p class="mt-5 text-3xl font-medium">{$t('no_results')}</p>
|
{/each}
|
||||||
<p class="text-base font-normal">{$t('no_results_description')}</p>
|
</section>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
</SearchResults>
|
||||||
{#if isLoading}
|
|
||||||
<div class="flex justify-center py-16 items-center">
|
|
||||||
<LoadingSpinner size="giant" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
{#if assetInteraction.selectionActive}
|
{#if assetInteraction.selectionActive}
|
||||||
<div class="fixed top-0 start-0 w-full">
|
<div class="fixed z-1 top-0 start-0 w-full">
|
||||||
<AssetSelectControlBar
|
<AssetSelectControlBar
|
||||||
assets={assetInteraction.selectedAssets}
|
assets={assetInteraction.selectedAssets}
|
||||||
clearSelect={() => cancelMultiselect(assetInteraction)}
|
clearSelect={() => cancelMultiselect(assetInteraction)}
|
||||||
|
|
@ -422,38 +275,33 @@
|
||||||
<AddToAlbum {onAddToAlbum} />
|
<AddToAlbum {onAddToAlbum} />
|
||||||
<AddToAlbum shared {onAddToAlbum} />
|
<AddToAlbum shared {onAddToAlbum} />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
<FavoriteAction
|
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} manager={searchResultsManager} />
|
||||||
removeFavorite={assetInteraction.isAllFavorite}
|
|
||||||
onFavorite={(ids, isFavorite) => {
|
|
||||||
for (const id of ids) {
|
|
||||||
const asset = searchResultAssets.find((asset) => asset.id === id);
|
|
||||||
if (asset) {
|
|
||||||
asset.isFavorite = isFavorite;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||||
<DownloadAction menuItem />
|
<DownloadAction menuItem />
|
||||||
<ChangeDate menuItem />
|
<ChangeDate menuItem />
|
||||||
<ChangeDescription menuItem />
|
<ChangeDescriptionAction menuItem />
|
||||||
<ChangeLocation menuItem />
|
<ChangeLocation menuItem />
|
||||||
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
|
<ArchiveAction
|
||||||
|
menuItem
|
||||||
|
removeOnArchive={true}
|
||||||
|
unarchive={assetInteraction.isAllArchived}
|
||||||
|
manager={searchResultsManager}
|
||||||
|
/>
|
||||||
{#if assetInteraction.isAllUserOwned}
|
{#if assetInteraction.isAllUserOwned}
|
||||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
||||||
<TagAction menuItem />
|
<TagAction menuItem />
|
||||||
{/if}
|
{/if}
|
||||||
<DeleteAssets menuItem {onAssetDelete} onUndoDelete={onSearchQueryUpdate} />
|
<DeleteAssets menuItem manager={searchResultsManager} />
|
||||||
<hr />
|
<hr />
|
||||||
<AssetJobActions />
|
<AssetJobActions />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="fixed top-0 start-0 w-full">
|
<div class="fixed z-1 top-0 start-0 w-full">
|
||||||
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
|
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
|
||||||
<div class="absolute bg-light"></div>
|
<div class="absolute bg-light"></div>
|
||||||
<div class="w-full flex-1 ps-4">
|
<div class="w-full flex-1 ps-4">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue