Compare commits

...

5 Commits

Author SHA1 Message Date
midzelis
50d462b95f feat: search results keyboard actions 2025-10-07 01:29:52 +00:00
midzelis
0ebf75a96f feat: search results page (without keyboard actions)
fix: missing responsive calculation in UserPageLayout
2025-10-07 01:29:52 +00:00
midzelis
398755e65e feat: Extract common StreamWithViewer component
- Create new StreamWithViewer component to handle asset viewer lifecycle
  and navigation
  - Move beforeNavigate/afterNavigate hooks from Timeline to StreamWithViewer
  - Extract asset viewer Portal rendering and close handler to wrapper
  component
  - Move timeline segment loading logic for viewed assets to StreamWithViewer
  - Simplify Timeline component by removing ~76 lines of navigation/viewer
  code
  - Remove showSkeleton state management from Timeline (now handled by
  PhotostreamWithScrubber)

  This separation of concerns makes the Timeline component more focused on
  rendering while StreamWithViewer handles all viewer-related navigation and state
  management.The new component can be reused by other photostream-like components that
  need asset viewer functionality.
2025-10-07 00:56:15 +00:00
midzelis
79adb016e8 feat: photostream can have scrollbar, style options, standardize small/large layout sizes
- Add configurable header height props for responsive layouts
  (smallHeaderHeight/largeHeaderHeight)
  - Add style customization props: styleMarginContentHorizontal,
  styleMarginTop, alwaysShowScrollbar
  - Replace hardcoded layout values with configurable props
  - Change root element from <section> to custom <photostream> element for
  better semantic structure
  - Move viewport width binding to inner timeline element for more accurate
  measurements
  - Simplify HMR handler by removing file-specific checks
  - Add segment loading check to prevent rendering unloaded segments
  - Add spacing margin between month groups using layout options
  - Change scrollbar-width from 'auto' to 'thin' for consistency
  - Remove unused UpdatePayload type import
2025-10-07 00:56:15 +00:00
midzelis
1b60c9d32f feat: skeleton title is optional
feat: skeleton title optional
2025-10-07 00:56:15 +00:00
19 changed files with 1024 additions and 423 deletions

View File

@@ -1,5 +1,11 @@
import type { ActionReturn } from 'svelte/action';
interface ExplainedShortcut {
key: string[];
action: string;
info?: string;
}
export type Shortcut = {
key: string;
alt?: boolean;
@@ -14,6 +20,7 @@ export type ShortcutOptions<T = HTMLElement> = {
ignoreInputFields?: boolean;
onShortcut: (event: KeyboardEvent & { currentTarget: T }) => unknown;
preventDefault?: boolean;
explainedShortcut?: ExplainedShortcut;
};
export const shortcutLabel = (shortcut: Shortcut) => {

View File

@@ -49,7 +49,7 @@
<div
tabindex="-1"
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 ? 'max-md:pt-(--navbar-height-md)' : ''}"
>

View File

@@ -0,0 +1,237 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
import { AppRoute } from '$lib/constants';
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils';
import { deleteAssets } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils';
import { moveFocus } from '$lib/utils/focus-util';
import { AssetVisibility } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { t } from 'svelte-i18n';
let { isViewing: isViewerOpen } = assetViewingStore;
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
interface Props {
timelineManager: PhotostreamManager;
assetInteraction: AssetInteraction;
isShowDeleteConfirmation?: boolean;
onReload?: (() => void) | undefined;
}
let {
timelineManager,
assetInteraction,
isShowDeleteConfirmation = false,
onReload,
}: Props = $props();
const selectAllAssets = () => {
const allAssets = timelineManager.months.flatMap((segment) => segment.assets);
assetInteraction.selectAssets(allAssets);
};
const deselectAllAssets = () => {
cancelMultiselect(assetInteraction);
};
const onDelete = () => {
const hasTrashedAsset = assetInteraction.selectedAssets.some((asset) => asset.isTrashed);
if ($showDeleteModal && (!isTrashEnabled || hasTrashedAsset)) {
isShowDeleteConfirmation = true;
return;
}
handlePromiseError(trashOrDelete(hasTrashedAsset));
};
const onForceDelete = () => {
if ($showDeleteModal) {
isShowDeleteConfirmation = true;
return;
}
handlePromiseError(trashOrDelete(true));
};
const trashOrDelete = async (force: boolean = false) => {
isShowDeleteConfirmation = false;
await deleteAssets(
!(isTrashEnabled && !force),
(assetIds) => timelineManager.removeAssets(assetIds),
assetInteraction.selectedAssets,
onReload,
);
assetInteraction.clearMultiselect();
};
const toggleArchive = async () => {
const assets = assetInteraction.selectedAssets;
const visibility = assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive;
const ids = await archiveAssets(assets, visibility);
const idSet = new Set(ids);
if (ids) {
for (const asset of assets) {
if (idSet.has(asset.id)) {
asset.visibility = visibility;
}
}
deselectAllAssets();
}
};
const focusNextAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'next');
const focusPreviousAsset = () =>
moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'previous');
let isShortcutModalOpen = false;
const handleOpenShortcutModal = async () => {
if (isShortcutModalOpen) {
return;
}
isShortcutModalOpen = true;
await modalManager.show(ShortcutsModal, { shortcuts: getShortcuts() });
isShortcutModalOpen = false;
};
const getShortcuts = () => {
const general = Object.values(generalShortcuts);
const actions = Object.values(actionsShortcuts);
return {
general: general
.filter((general) => 'explainedShortcut' in general)
.map((generalShortcut) => generalShortcut.explainedShortcut!),
actions: actions.filter((action) => 'explainedShortcut' in action).map((action) => action.explainedShortcut!),
};
};
const generalShortcuts = {
OPEN_HELP: {
shortcut: { key: '?', shift: true },
onShortcut: handleOpenShortcutModal,
explainedShortcut: {
key: ['⇧', '?'],
action: 'Open this dialog',
},
},
EXPLORE: {
shortcut: { key: '/' },
onShortcut: () => goto(AppRoute.EXPLORE),
explainedShortcut: {
key: ['/'],
action: $t('explore'),
},
},
SELECT_ALL: {
shortcut: { key: 'A', ctrl: true },
onShortcut: () => selectAllAssets(),
explainedShortcut: {
key: ['Ctrl', 'a'],
action: $t('select_all'),
},
},
ARROW_RIGHT: {
shortcut: { key: 'ArrowRight' },
preventDefault: false,
onShortcut: focusNextAsset,
explainedShortcut: {
key: ['←', '→'],
action: $t('previous_or_next_photo'),
},
},
ARROW_LEFT: {
shortcut: { key: 'ArrowLeft' },
preventDefault: false,
onShortcut: focusPreviousAsset,
},
};
const actionsShortcuts = {
ESCAPE: {
shortcut: { key: 'Escape' },
onShortcut: deselectAllAssets,
explainedShortcut: {
key: ['Esc'],
action: $t('back_close_deselect'),
},
},
DELETE: {
shortcut: { key: 'Delete' },
onShortcut: onDelete,
explainedShortcut: {
key: ['Del'],
action: $t('trash_delete_asset'),
info: $t('shift_to_permanent_delete'),
},
},
FORCE_DELETE: {
shortcut: { key: 'Delete', shift: true },
onShortcut: onForceDelete,
},
DESELECT_ALL: {
shortcut: { key: 'D', ctrl: true },
onShortcut: () => deselectAllAssets(),
explainedShortcut: {
key: ['Ctrl', 'd'],
action: $t('deselect_all'),
},
},
TOGGLE_ARCHIVE: {
shortcut: { key: 'a', shift: true },
onShortcut: toggleArchive,
explainedShortcut: {
key: ['⇧', 'a'],
action: $t('select_all'),
},
},
};
const shortcutList = $derived(
(() => {
if ($isViewerOpen) {
return [];
}
const shortcuts: ShortcutOptions[] = [
generalShortcuts.OPEN_HELP,
generalShortcuts.EXPLORE,
generalShortcuts.SELECT_ALL,
generalShortcuts.ARROW_RIGHT,
generalShortcuts.ARROW_LEFT,
];
if (assetInteraction.selectionActive) {
shortcuts.push(
actionsShortcuts.ESCAPE,
actionsShortcuts.DELETE,
actionsShortcuts.FORCE_DELETE,
actionsShortcuts.DESELECT_ALL,
actionsShortcuts.TOGGLE_ARCHIVE,
);
}
return shortcuts;
})(),
);
</script>
<svelte:document use:shortcuts={shortcutList} />
{#if isShowDeleteConfirmation}
<DeleteAssetDialog
size={assetInteraction.selectedAssets.length}
onCancel={() => (isShowDeleteConfirmation = false)}
onConfirm={() => handlePromiseError(trashOrDelete(true))}
/>
{/if}

View File

@@ -0,0 +1,93 @@
<script lang="ts">
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import SearchKeyboardShortcuts from '$lib/components/search/SearchKeyboardShortcuts.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}
<SearchKeyboardShortcuts {assetInteraction} timelineManager={searchResultsManager} />
<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>

View File

@@ -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}

View File

@@ -8,7 +8,6 @@
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { onMount, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
interface Props {
segment: Snippet<
@@ -24,6 +23,7 @@
[
{
segment: PhotostreamSegment;
stylePaddingHorizontalPx: number;
},
]
>;
@@ -35,14 +35,27 @@
enableRouting: boolean;
timelineManager: PhotostreamManager;
alwaysShowScrollbar?: boolean;
showSkeleton?: boolean;
isShowDeleteConfirmation?: boolean;
styleMarginRightOverride?: string;
header?: Snippet<[scrollToFunction: (top: number) => void]>;
children?: Snippet;
empty?: Snippet;
handleTimelineScroll?: () => void;
smallHeaderHeight?: {
rowHeight: number;
headerHeight: number;
};
largeHeaderHeight?: {
rowHeight: number;
headerHeight: number;
};
stylePaddingHorizontalPx?: number;
styleMarginTopPx?: number;
styleMarginRightPx?: number;
}
let {
@@ -51,14 +64,27 @@
enableRouting,
timelineManager = $bindable(),
showSkeleton = $bindable(true),
styleMarginRightOverride,
isShowDeleteConfirmation = $bindable(false),
showScrollbar,
styleMarginRightPx = 0,
stylePaddingHorizontalPx = 0,
styleMarginTopPx = 0,
alwaysShowScrollbar,
isShowDeleteConfirmation = $bindable(false),
children,
skeleton,
empty,
header,
handleTimelineScroll = () => {},
smallHeaderHeight = {
rowHeight: 100,
headerHeight: 32,
},
largeHeaderHeight = {
rowHeight: 235,
headerHeight: 48,
},
}: Props = $props();
let { gridScrollTarget } = assetViewingStore;
@@ -70,15 +96,7 @@
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
$effect(() => {
const layoutOptions = maxMd
? {
rowHeight: 100,
headerHeight: 32,
}
: {
rowHeight: 235,
headerHeight: 48,
};
const layoutOptions = maxMd ? smallHeaderHeight : largeHeaderHeight;
timelineManager.setLayoutOptions(layoutOptions);
});
@@ -173,7 +191,7 @@
</script>
<HotModuleReload
onAfterUpdate={(payload: UpdatePayload) => {
onAfterUpdate={() => {
// when hmr happens, skeleton is initialized to true by default
// normally, loading asset-grid is part of a navigation event, and the completion of
// that event triggers a scroll-to-asset, if necessary, when then clears the skeleton.
@@ -186,36 +204,42 @@
}
void completeAfterNavigate({ scrollToAssetQueryParam: true });
};
const assetGridUpdate = payload.updates.some((update) => update.path.endsWith('Photostream.svelte'));
if (assetGridUpdate) {
// wait 500ms for the update to be fully swapped in
setTimeout(finishHmr, 500);
}
// wait 500ms for the update to be fully swapped in
setTimeout(finishHmr, 500);
}}
/>
{@render header?.(scrollTo)}
<!-- Right margin MUST be equal to the width of scrubber -->
<section
<photostream
id="asset-grid"
class={[
'h-full overflow-y-auto outline-none',
'overflow-y-auto outline-none',
{ 'scrollbar-hidden': !showScrollbar },
{ 'overflow-y-scroll': alwaysShowScrollbar },
{ 'm-0': isEmpty },
{ 'ms-0': !isEmpty },
]}
style:margin-right={styleMarginRightOverride}
style:scrollbar-width={showScrollbar ? 'auto' : 'none'}
style:height={`calc(100% - ${styleMarginTopPx}px)`}
style:margin-top={styleMarginTopPx + 'px'}
style:margin-right={styleMarginRightPx + 'px'}
style:padding-left={stylePaddingHorizontalPx + 'px'}
style:padding-right={stylePaddingHorizontalPx + 'px'}
style:scrollbar-width={showScrollbar ? 'thin' : 'none'}
tabindex="-1"
bind:clientHeight={timelineManager.viewportHeight}
bind:clientWidth={null, (v: number) => ((timelineManager.viewportWidth = v), updateSlidingWindow())}
bind:clientWidth={
null, (v: number) => ((timelineManager.viewportWidth = v - stylePaddingHorizontalPx * 2), updateSlidingWindow())
}
bind:this={element}
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
>
<section
bind:this={timelineElement}
id="virtual-timeline"
class:relative={true}
class:invisible={showSkeleton}
style:height={timelineManager.timelineHeight + 'px'}
>
@@ -232,19 +256,20 @@
{@render empty?.()}
{/if}
</section>
{#each timelineManager.months as monthGroup (monthGroup.id)}
{@const shouldDisplay = monthGroup.intersecting}
{@const shouldDisplay = monthGroup.intersecting && monthGroup.isLoaded}
{@const absoluteHeight = monthGroup.top}
<div
class="month-group"
style:height={monthGroup.height + 'px'}
style:margin-bottom={timelineManager.createLayoutOptions().spacing + 'px'}
style:position="absolute"
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:height={`${monthGroup.height}px`}
style:width="100%"
>
{#if !shouldDisplay}
{@render skeleton({ segment: monthGroup })}
{@render skeleton({ segment: monthGroup, stylePaddingHorizontalPx })}
{:else}
{@render segment({
segment: monthGroup,
@@ -263,9 +288,12 @@
style:transform={`translate3d(0,${timelineManager.timelineHeight}px,0)`}
></div>
</section>
</section>
</photostream>
<style>
photostream {
display: block;
}
#asset-grid {
contain: strict;
scrollbar-width: none;

View File

@@ -172,7 +172,7 @@
{timelineManager}
{showSkeleton}
{isShowDeleteConfirmation}
styleMarginRightOverride={scrubberWidth + 'px'}
styleMarginRightPx={scrubberWidth}
{handleTimelineScroll}
{segment}
{skeleton}

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { page } from '$app/state';
import Portal from '$lib/elements/Portal.svelte';
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
import { getSegmentIdentifier, getTimes } from '$lib/utils/timeline-util';
import { DateTime } from 'luxon';
import { type Snippet } from 'svelte';
interface Props {
timelineManager: PhotostreamManager;
children?: Snippet;
assetViewer: Snippet<[{ onViewerClose: (asset: { id: string }) => Promise<void> }]>;
onAfterNavigateComplete: (args: { scrollToAssetQueryParam: boolean }) => void;
}
let { timelineManager, children, assetViewer, onAfterNavigateComplete }: Props = $props();
let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore;
// tri-state boolean
let initialLoadWasAssetViewer: boolean | null = null;
let hasNavigatedToOrFromAssetViewer: boolean = false;
let timelineScrollPositionInitialized = false;
beforeNavigate(({ from, to }) => {
timelineManager.suspendTransitions = true;
hasNavigatedToOrFromAssetViewer = isAssetViewerRoute(to) || isAssetViewerRoute(from);
});
const completeAfterNavigate = () => {
const assetViewerPage = !!(page.route.id?.endsWith('/[[assetId=id]]') && page.params.assetId);
let isInitial = false;
// Set initial load state only once
if (initialLoadWasAssetViewer === null) {
initialLoadWasAssetViewer = assetViewerPage && !hasNavigatedToOrFromAssetViewer;
isInitial = true;
}
let scrollToAssetQueryParam = false;
if (
!timelineScrollPositionInitialized &&
((isInitial && !assetViewerPage) || // Direct timeline load
(!isInitial && hasNavigatedToOrFromAssetViewer)) // Navigated from asset viewer
) {
scrollToAssetQueryParam = true;
timelineScrollPositionInitialized = true;
}
return onAfterNavigateComplete({ scrollToAssetQueryParam });
};
afterNavigate(({ complete }) => void complete.then(completeAfterNavigate, completeAfterNavigate));
const onViewerClose = async (asset: { id: string }) => {
assetViewingStore.showAssetViewer(false);
$gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
};
$effect(() => {
if ($showAssetViewer) {
const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60);
void timelineManager.loadSegment(getSegmentIdentifier({ year: localDateTime.year, month: localDateTime.month }));
}
});
</script>
{@render children?.()}
<Portal target="body">
{#if $showAssetViewer}
{@render assetViewer({ onViewerClose })}
{/if}
</Portal>

View File

@@ -1,25 +1,19 @@
<script lang="ts">
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { page } from '$app/state';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import MonthSegment from '$lib/components/timeline/MonthSegment.svelte';
import PhotostreamWithScrubber from '$lib/components/timeline/PhotostreamWithScrubber.svelte';
import SelectableDay from '$lib/components/timeline/SelectableDay.svelte';
import SelectableSegment from '$lib/components/timeline/SelectableSegment.svelte';
import StreamWithViewer from '$lib/components/timeline/StreamWithViewer.svelte';
import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte';
import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte';
import { AssetAction } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte';
import Skeleton from '$lib/elements/Skeleton.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.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 { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
import { getSegmentIdentifier, getTimes } from '$lib/utils/timeline-util';
import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { type Snippet } from 'svelte';
interface Props {
@@ -74,140 +68,90 @@
customThumbnailLayout,
}: Props = $props();
let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore;
let viewer: PhotostreamWithScrubber | undefined = $state(undefined);
let viewer: PhotostreamWithScrubber | undefined = $state();
let showSkeleton: boolean = $state(true);
// tri-state boolean
let initialLoadWasAssetViewer: boolean | null = null;
let hasNavigatedToOrFromAssetViewer: boolean = false;
let timelineScrollPositionInitialized = false;
beforeNavigate(({ from, to }) => {
timelineManager.suspendTransitions = true;
hasNavigatedToOrFromAssetViewer = isAssetViewerRoute(to) || isAssetViewerRoute(from);
});
const completeAfterNavigate = () => {
const assetViewerPage = !!(page.route.id?.endsWith('/[[assetId=id]]') && page.params.assetId);
let isInitial = false;
// Set initial load state only once
if (initialLoadWasAssetViewer === null) {
initialLoadWasAssetViewer = assetViewerPage && !hasNavigatedToOrFromAssetViewer;
isInitial = true;
}
let scrollToAssetQueryParam = false;
if (
!timelineScrollPositionInitialized &&
((isInitial && !assetViewerPage) || // Direct timeline load
(!isInitial && hasNavigatedToOrFromAssetViewer)) // Navigated from asset viewer
) {
scrollToAssetQueryParam = true;
timelineScrollPositionInitialized = true;
}
return viewer?.completeAfterNavigate({ scrollToAssetQueryParam });
};
afterNavigate(({ complete }) => void complete.then(completeAfterNavigate, completeAfterNavigate));
const onViewerClose = async (asset: { id: string }) => {
assetViewingStore.showAssetViewer(false);
showSkeleton = true;
$gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
};
$effect(() => {
if ($showAssetViewer) {
const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60);
void timelineManager.loadSegment(getSegmentIdentifier({ year: localDateTime.year, month: localDateTime.month }));
}
});
const onAfterNavigateComplete = ({ scrollToAssetQueryParam }: { scrollToAssetQueryParam: boolean }) =>
viewer?.completeAfterNavigate({ scrollToAssetQueryParam });
</script>
<TimelineKeyboardActions
scrollToAsset={async (asset) => (await viewer?.scrollToAsset(asset)) ?? Promise.resolve(false)}
{timelineManager}
{assetInteraction}
bind:isShowDeleteConfirmation
{onEscape}
/>
<PhotostreamWithScrubber
bind:this={viewer}
{enableRouting}
{timelineManager}
{isShowDeleteConfirmation}
{showSkeleton}
{children}
{empty}
>
{#snippet skeleton({ segment })}
<Skeleton
height={segment.height - segment.timelineManager.headerHeight}
title={(segment as MonthGroup).monthGroupTitle}
/>
{/snippet}
{#snippet segment({ segment, onScrollCompensationMonthInDOM })}
<SelectableSegment
{segment}
{onScrollCompensationMonthInDOM}
{timelineManager}
{assetInteraction}
{isSelectionMode}
{singleSelect}
{onAssetOpen}
{onAssetSelect}
>
{#snippet content({ onAssetOpen, onAssetSelect, onAssetHover })}
<SelectableDay {assetInteraction} {onAssetSelect}>
{#snippet content({ onDayGroupSelect, onDayGroupAssetSelect })}
<MonthSegment
{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={(isMouseOver) => {
if (isMouseOver) {
onAssetHover(asset);
} else {
onAssetHover(null);
}
}}
selected={isAssetSelected}
selectionCandidate={isAssetSelectionCandidate}
disabled={isAssetDisabled}
thumbnailWidth={position.width}
thumbnailHeight={position.height}
/>
{/snippet}
</MonthSegment>
{/snippet}
</SelectableDay>
{/snippet}
</SelectableSegment>
{/snippet}
</PhotostreamWithScrubber>
<Portal target="body">
{#if $showAssetViewer}
<StreamWithViewer {timelineManager} {onAfterNavigateComplete}>
{#snippet assetViewer({ onViewerClose })}
<TimelineAssetViewer {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} {onViewerClose} />
{/if}
</Portal>
{/snippet}
<TimelineKeyboardActions
scrollToAsset={async (asset) => (await viewer?.scrollToAsset(asset)) ?? Promise.resolve(false)}
{timelineManager}
{assetInteraction}
bind:isShowDeleteConfirmation
{onEscape}
/>
<PhotostreamWithScrubber
bind:this={viewer}
{enableRouting}
{timelineManager}
{isShowDeleteConfirmation}
{children}
{empty}
>
{#snippet skeleton({ segment })}
<Skeleton
height={segment.height - segment.timelineManager.headerHeight}
title={(segment as MonthGroup).monthGroupTitle}
/>
{/snippet}
{#snippet segment({ segment, onScrollCompensationMonthInDOM })}
<SelectableSegment
{segment}
{onScrollCompensationMonthInDOM}
{timelineManager}
{assetInteraction}
{isSelectionMode}
{singleSelect}
{onAssetOpen}
{onAssetSelect}
>
{#snippet content({ onAssetOpen, onAssetSelect, onAssetHover })}
<SelectableDay {assetInteraction} {onAssetSelect}>
{#snippet content({ onDayGroupSelect, onDayGroupAssetSelect })}
<MonthSegment
{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={(isMouseOver) => {
if (isMouseOver) {
onAssetHover(asset);
} else {
onAssetHover(null);
}
}}
selected={isAssetSelected}
selectionCandidate={isAssetSelectionCandidate}
disabled={isAssetDisabled}
thumbnailWidth={position.width}
thumbnailHeight={position.height}
/>
{/snippet}
</MonthSegment>
{/snippet}
</SelectableDay>
{/snippet}
</SelectableSegment>
{/snippet}
</PhotostreamWithScrubber>
</StreamWithViewer>

View File

@@ -14,9 +14,10 @@
menuItem?: boolean;
unarchive?: boolean;
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 icon = $derived(unarchive ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline);
@@ -31,7 +32,12 @@
loading = true;
const ids = await archiveAssets(assets, visibility as AssetVisibility);
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);
clearSelect();
}

View File

@@ -41,9 +41,11 @@
const assets = [...getOwnedAssets()];
const undo = (assets: TimelineAsset[]) => {
manager?.upsertAssets(assets);
manager?.order(assetOrdering ?? []);
onUndoDelete?.(assets);
};
await deleteAssets(force, onAssetDelete, assets, undo);
const assetOrdering = manager?.assets.map((asset) => asset.id);
manager?.removeAssets(assets.map((asset) => asset.id));
clearSelect();
isShowConfirmation = false;

View File

@@ -1,24 +1,29 @@
<script lang="ts">
interface Props {
height: number;
title: string;
title?: string;
stylePaddingHorizontalPx?: number;
}
let { height = 0, title }: Props = $props();
let { height = 0, title, stylePaddingHorizontalPx = 0 }: Props = $props();
</script>
<div class="overflow-clip" style:height={height + 'px'}>
<skeleton class="overflow-clip" style:height={height + 'px'}>
{#if title}
<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"
>
{title}
</div>
{/if}
<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"
>
{title}
</div>
<div
class="animate-pulse absolute h-full ms-[10px] me-[10px]"
style:width="calc(100% - 20px)"
class="animate-pulse absolute h-full"
style:width="100%"
style:padding-left={stylePaddingHorizontalPx + 'px'}
style:padding-right={stylePaddingHorizontalPx + 'px'}
data-skeleton="true"
></div>
</div>
</skeleton>
<style>
[data-skeleton] {

View File

@@ -328,6 +328,10 @@ export abstract class PhotostreamManager {
return Promise.resolve(this.retrieveLoadedRange(start, end));
}
get assets() {
return this.months.flatMap((segment) => segment.assets).flat();
}
updateAssetOperation(ids: string[], operation: AssetOperation) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
return this.#runAssetOperation(new Set(ids), operation);
@@ -429,4 +433,10 @@ export abstract class PhotostreamManager {
}
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[]) {}
}

View File

@@ -66,13 +66,18 @@ export abstract class PhotostreamSegment {
}
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);
}, cancelable);
}
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[] {
return this.#assets;
}
@@ -139,7 +144,7 @@ export abstract class PhotostreamSegment {
this.loader?.cancel();
}
layout(_: boolean) {}
layout(_?: boolean) {}
updateIntersection({ intersecting, actuallyIntersecting }: { intersecting: boolean; actuallyIntersecting: boolean }) {
this.intersecting = intersecting;

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -306,7 +306,7 @@ export class MonthGroup extends PhotostreamSegment {
this.loader?.cancel();
}
layout(noDefer: boolean) {
layout(noDefer?: boolean) {
layoutMonthGroup(this.timelineManager, this, noDefer);
}

View File

@@ -1,3 +1,4 @@
import type { PhotostreamSegment } from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
import type { CommonPosition } from '$lib/utils/layout-utils';
import type { DayGroup } from './day-group.svelte';
@@ -5,15 +6,20 @@ import { calculateViewerAssetIntersecting } from './internal/intersection-suppor
import type { TimelineAsset } from './types';
export class ViewerAsset {
readonly #group: DayGroup;
readonly #group: DayGroup | PhotostreamSegment;
intersecting = $derived.by(() => {
if (!this.position) {
return false;
}
const store = this.#group.monthGroup.timelineManager;
const positionTop = this.#group.absoluteDayGroupTop + this.position.top;
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 as PhotostreamSegment).timelineManager;
const positionTop = this.position.top + (this.#group as PhotostreamSegment).top;
return calculateViewerAssetIntersecting(store, positionTop, this.position.height);
});
@@ -22,7 +28,7 @@ export class ViewerAsset {
asset: TimelineAsset = <TimelineAsset>$state();
id: string = $derived(this.asset.id);
constructor(group: DayGroup, asset: TimelineAsset) {
constructor(group: DayGroup | PhotostreamSegment, asset: TimelineAsset) {
this.#group = group;
this.asset = asset;
}

View File

@@ -1,17 +1,16 @@
<script lang="ts">
import { afterNavigate, goto } from '$app/navigation';
import { goto } from '$app/navigation';
import { page } from '$app/state';
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 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 AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte';
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
import AssetJobActions from '$lib/components/timeline/actions/AssetJobActions.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 CreateSharedLink from '$lib/components/timeline/actions/CreateSharedLinkAction.svelte';
import DeleteAssets from '$lib/components/timeline/actions/DeleteAssetsAction.svelte';
@@ -20,66 +19,39 @@
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import { SearchResultsManager } from '$lib/managers/searchresults-manager/SearchResultsManager.svelte';
import type { Viewport } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
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 { preferences } from '$lib/stores/user.store';
import { handlePromiseError } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { parseUtcDate } from '$lib/utils/date-time';
import { handleError } from '$lib/utils/handle-error';
import { isAlbumsRoute, isPeopleRoute } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util';
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 { getPerson, getTagById, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiArrowLeft, mdiDotsVertical, mdiPlus, mdiSelectAll } from '@mdi/js';
import { t } from 'svelte-i18n';
let { isViewing: showAssetViewer } = assetViewingStore;
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 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();
type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query' | 'queryAssetId'>;
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) : {});
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
terms;
setTimeout(() => {
handlePromiseError(onSearchQueryUpdate());
});
});
const searchResultsManager = new SearchResultsManager();
let timelineManager = new TimelineManager();
$effect(() => {
void searchResultsManager.updateOptions({ isSmartSearchEnabled, searchTerms: terms });
});
const onEscape = () => {
if ($showAssetViewer) {
@@ -90,91 +62,16 @@
assetInteraction.selectedAssets = [];
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[]) => {
timelineManager.removeAssets(assetIds);
searchResultsManager.removeAssets(assetIds);
assetInteraction.clearMultiselect();
onAssetDelete(assetIds);
};
const handleSelectAll = () => {
assetInteraction.selectAssets(searchResultAssets);
};
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;
}
const allAssets = searchResultsManager.months.flatMap((segment) => segment.assets);
assetInteraction.selectAssets(allAssets);
};
function getHumanReadableDate(dateString: string) {
@@ -248,8 +145,7 @@
cancelMultiselect(assetInteraction);
if (terms.isNotInAlbum.toString() == 'true') {
const assetIdSet = new Set(assetIds);
searchResultAssets = searchResultAssets.filter((asset) => !assetIdSet.has(asset.id));
searchResultsManager.removeAssets(assetIds);
}
};
@@ -258,12 +154,11 @@
}
</script>
<svelte:window bind:scrollY />
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onEscape }} />
<section>
{#if assetInteraction.selectionActive}
<div class="fixed top-0 start-0 w-full">
<div class="fixed z-1 top-0 start-0 w-full">
<AssetSelectControlBar
assets={assetInteraction.selectedAssets}
clearSelect={() => cancelMultiselect(assetInteraction)}
@@ -281,34 +176,29 @@
<AddToAlbum {onAddToAlbum} />
<AddToAlbum shared {onAddToAlbum} />
</ButtonContextMenu>
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(assetIds, isFavorite) => {
for (const assetId of assetIds) {
const asset = searchResultAssets.find((searchAsset) => searchAsset.id === assetId);
if (asset) {
asset.isFavorite = isFavorite;
}
}
}}
/>
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} manager={searchResultsManager} />
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
<ChangeDate menuItem />
<ChangeLocation menuItem />
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
<ArchiveAction
menuItem
removeOnArchive={true}
unarchive={assetInteraction.isAllArchived}
manager={searchResultsManager}
/>
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
<TagAction menuItem />
{/if}
<DeleteAssets menuItem {onAssetDelete} onUndoDelete={onSearchQueryUpdate} />
<DeleteAssets menuItem manager={searchResultsManager} />
<hr />
<AssetJobActions />
</ButtonContextMenu>
</AssetSelectControlBar>
</div>
{: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}>
<div class="absolute bg-light"></div>
<div class="w-full flex-1 ps-4">
@@ -319,92 +209,55 @@
{/if}
</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
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:clientWidth={viewport.width}
bind:this={searchResultsElement}
>
{#if searchResultAlbums.length > 0}
<section>
<div class="uppercase ms-6 text-4xl font-medium text-black/70 dark:text-white/80">{$t('albums')}</div>
<AlbumCardGroup albums={searchResultAlbums} showDateRange showItemCount />
<SearchResults {searchResultsManager} {assetInteraction} stylePaddingHorizontalPx={16}>
{#if terms}
<section
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">
{$t('photos_and_videos')}
</div>
</section>
{/if}
<section id="search-content">
{#if searchResultAssets.length > 0}
<GalleryViewer
assets={searchResultAssets}
{assetInteraction}
onIntersected={loadNextPage}
showArchiveIcon={true}
{viewport}
onReload={onSearchQueryUpdate}
slidingWindowOffset={searchResultsElement.offsetTop}
/>
{:else if !isLoading}
<div class="flex min-h-[calc(66vh-11rem)] w-full place-content-center items-center dark:text-white">
<div class="flex flex-col content-center items-center text-center">
<Icon icon={mdiImageOffOutline} size="3.5em" />
<p class="mt-5 text-3xl font-medium">{$t('no_results')}</p>
<p class="text-base font-normal">{$t('no_results_description')}</p>
</div>
</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}
{#if isLoading}
<div class="flex justify-center py-16 items-center">
<LoadingSpinner size="giant" />
</div>
{/if}
</section>
</SearchResults>
<section>
{#if assetInteraction.selectionActive}
<div class="fixed top-0 start-0 w-full">
<div class="fixed z-1 top-0 start-0 w-full">
<AssetSelectControlBar
assets={assetInteraction.selectedAssets}
clearSelect={() => cancelMultiselect(assetInteraction)}
@@ -422,38 +275,33 @@
<AddToAlbum {onAddToAlbum} />
<AddToAlbum shared {onAddToAlbum} />
</ButtonContextMenu>
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) => {
for (const id of ids) {
const asset = searchResultAssets.find((asset) => asset.id === id);
if (asset) {
asset.isFavorite = isFavorite;
}
}
}}
/>
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} manager={searchResultsManager} />
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
<ChangeDate menuItem />
<ChangeDescription menuItem />
<ChangeDescriptionAction menuItem />
<ChangeLocation menuItem />
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
<ArchiveAction
menuItem
removeOnArchive={true}
unarchive={assetInteraction.isAllArchived}
manager={searchResultsManager}
/>
{#if assetInteraction.isAllUserOwned}
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
{/if}
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
<TagAction menuItem />
{/if}
<DeleteAssets menuItem {onAssetDelete} onUndoDelete={onSearchQueryUpdate} />
<DeleteAssets menuItem manager={searchResultsManager} />
<hr />
<AssetJobActions />
</ButtonContextMenu>
</AssetSelectControlBar>
</div>
{: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}>
<div class="absolute bg-light"></div>
<div class="w-full flex-1 ps-4">