mirror of
https://github.com/immich-app/immich.git
synced 2025-12-09 17:23:13 +03:00
Compare commits
7 Commits
refactor/t
...
feat/timel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50d462b95f | ||
|
|
0ebf75a96f | ||
|
|
398755e65e | ||
|
|
79adb016e8 | ||
|
|
1b60c9d32f | ||
|
|
113082dcaa | ||
|
|
8d32ec1f18 |
@@ -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) => {
|
||||
|
||||
@@ -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)' : ''}"
|
||||
>
|
||||
|
||||
237
web/src/lib/components/search/SearchKeyboardShortcuts.svelte
Normal file
237
web/src/lib/components/search/SearchKeyboardShortcuts.svelte
Normal 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}
|
||||
93
web/src/lib/components/search/SearchResults.svelte
Normal file
93
web/src/lib/components/search/SearchResults.svelte
Normal 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>
|
||||
@@ -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}
|
||||
307
web/src/lib/components/timeline/Photostream.svelte
Normal file
307
web/src/lib/components/timeline/Photostream.svelte
Normal file
@@ -0,0 +1,307 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
|
||||
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
|
||||
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
|
||||
import type { PhotostreamSegment } from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
|
||||
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
segment: Snippet<
|
||||
[
|
||||
{
|
||||
segment: PhotostreamSegment;
|
||||
scrollToFunction: (top: number) => void;
|
||||
onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
|
||||
},
|
||||
]
|
||||
>;
|
||||
skeleton: Snippet<
|
||||
[
|
||||
{
|
||||
segment: PhotostreamSegment;
|
||||
stylePaddingHorizontalPx: number;
|
||||
},
|
||||
]
|
||||
>;
|
||||
|
||||
showScrollbar?: boolean;
|
||||
/** `true` if this asset grid responds to navigation events; if `true`, then look at the
|
||||
`AssetViewingStore.gridScrollTarget` and load and scroll to the asset specified, and
|
||||
additionally, update the page location/url with the asset as the asset-grid is scrolled */
|
||||
enableRouting: boolean;
|
||||
timelineManager: PhotostreamManager;
|
||||
|
||||
alwaysShowScrollbar?: boolean;
|
||||
showSkeleton?: boolean;
|
||||
isShowDeleteConfirmation?: boolean;
|
||||
|
||||
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 {
|
||||
segment,
|
||||
|
||||
enableRouting,
|
||||
timelineManager = $bindable(),
|
||||
showSkeleton = $bindable(true),
|
||||
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;
|
||||
|
||||
let element: HTMLElement | undefined = $state();
|
||||
let timelineElement: HTMLElement | undefined = $state();
|
||||
|
||||
const maxMd = $derived(mobileDevice.maxMd);
|
||||
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
||||
|
||||
$effect(() => {
|
||||
const layoutOptions = maxMd ? smallHeaderHeight : largeHeaderHeight;
|
||||
timelineManager.setLayoutOptions(layoutOptions);
|
||||
});
|
||||
|
||||
const scrollTo = (top: number) => {
|
||||
if (element) {
|
||||
element.scrollTo({ top });
|
||||
}
|
||||
updateSlidingWindow();
|
||||
};
|
||||
|
||||
const scrollBy = (y: number) => {
|
||||
if (element) {
|
||||
element.scrollBy(0, y);
|
||||
}
|
||||
updateSlidingWindow();
|
||||
};
|
||||
|
||||
const handleTriggeredScrollCompensation = (compensation: { heightDelta?: number; scrollTop?: number }) => {
|
||||
const { heightDelta, scrollTop } = compensation;
|
||||
if (heightDelta !== undefined) {
|
||||
scrollBy(heightDelta);
|
||||
} else if (scrollTop !== undefined) {
|
||||
scrollTo(scrollTop);
|
||||
}
|
||||
timelineManager.clearScrollCompensation();
|
||||
};
|
||||
|
||||
const getAssetHeight = (assetId: string, monthGroup: PhotostreamSegment) => {
|
||||
// the following method may trigger any layouts, so need to
|
||||
// handle any scroll compensation that may have been set
|
||||
const height = monthGroup.findAssetAbsolutePosition(assetId);
|
||||
|
||||
// this is in a while loop, since scrollCompensations invoke scrolls
|
||||
// which may load months, triggering more scrollCompensations. Call
|
||||
// this in a loop, until no more layouts occur.
|
||||
while (timelineManager.scrollCompensation.monthGroup) {
|
||||
handleTriggeredScrollCompensation(timelineManager.scrollCompensation);
|
||||
}
|
||||
return height;
|
||||
};
|
||||
|
||||
export const scrollToAssetId = async (assetId: string) => {
|
||||
const monthGroup = await timelineManager.findSegmentForAssetId(assetId);
|
||||
if (!monthGroup) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const height = getAssetHeight(assetId, monthGroup);
|
||||
scrollTo(height);
|
||||
return true;
|
||||
};
|
||||
|
||||
export const completeAfterNavigate = async ({ scrollToAssetQueryParam }: { scrollToAssetQueryParam: boolean }) => {
|
||||
if (timelineManager.viewportHeight === 0 || timelineManager.viewportWidth === 0) {
|
||||
// this can happen if you do the following navigation order
|
||||
// /photos?at=<id>, /photos/<id>, http://example.com, browser back, browser back
|
||||
const rect = element?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
timelineManager.viewportHeight = rect.height;
|
||||
timelineManager.viewportWidth = rect.width;
|
||||
}
|
||||
}
|
||||
if (scrollToAssetQueryParam) {
|
||||
const scrollTarget = $gridScrollTarget?.at;
|
||||
let scrolled = false;
|
||||
if (scrollTarget) {
|
||||
scrolled = await scrollToAssetId(scrollTarget);
|
||||
}
|
||||
if (!scrolled) {
|
||||
// if the asset is not found, scroll to the top
|
||||
scrollTo(0);
|
||||
}
|
||||
}
|
||||
showSkeleton = false;
|
||||
};
|
||||
|
||||
const updateIsScrolling = () => (timelineManager.scrolling = true);
|
||||
// Yes, updateSlideWindow() is called by the onScroll event. However, if you also just scrolled
|
||||
// by explicitly invoking element.scrollX functions, there may be a delay with enough time to
|
||||
// set the intersecting property of the monthGroup to false, then true, which causes the DOM
|
||||
// nodes to be recreated, causing bad perf, and also, disrupting focus of those elements.
|
||||
// Also note: don't throttle, debounce, or otherwise do this function async - it causes flicker
|
||||
const updateSlidingWindow = () => timelineManager.updateSlidingWindow(element?.scrollTop || 0);
|
||||
|
||||
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
|
||||
|
||||
onMount(() => {
|
||||
if (!enableRouting) {
|
||||
showSkeleton = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<HotModuleReload
|
||||
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.
|
||||
// this handler will run the navigation/scroll-to-asset handler when hmr is performed,
|
||||
// preventing skeleton from showing after hmr
|
||||
const finishHmr = () => {
|
||||
const asset = page.url.searchParams.get('at');
|
||||
if (asset) {
|
||||
$gridScrollTarget = { at: asset };
|
||||
}
|
||||
void completeAfterNavigate({ scrollToAssetQueryParam: true });
|
||||
};
|
||||
|
||||
// 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 -->
|
||||
<photostream
|
||||
id="asset-grid"
|
||||
class={[
|
||||
'overflow-y-auto outline-none',
|
||||
{ 'scrollbar-hidden': !showScrollbar },
|
||||
{ 'overflow-y-scroll': alwaysShowScrollbar },
|
||||
{ 'm-0': isEmpty },
|
||||
{ 'ms-0': !isEmpty },
|
||||
]}
|
||||
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 - 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'}
|
||||
>
|
||||
<section
|
||||
use:resizeObserver={topSectionResizeObserver}
|
||||
class:invisible={showSkeleton}
|
||||
style:position="absolute"
|
||||
style:left="0"
|
||||
style:right="0"
|
||||
>
|
||||
{@render children?.()}
|
||||
{#if isEmpty}
|
||||
<!-- (optional) empty placeholder -->
|
||||
{@render empty?.()}
|
||||
{/if}
|
||||
</section>
|
||||
{#each timelineManager.months as monthGroup (monthGroup.id)}
|
||||
{@const shouldDisplay = monthGroup.intersecting && monthGroup.isLoaded}
|
||||
{@const absoluteHeight = monthGroup.top}
|
||||
|
||||
<div
|
||||
class="month-group"
|
||||
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, stylePaddingHorizontalPx })}
|
||||
{:else}
|
||||
{@render segment({
|
||||
segment: monthGroup,
|
||||
scrollToFunction: scrollTo,
|
||||
onScrollCompensationMonthInDOM: handleTriggeredScrollCompensation,
|
||||
})}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<!-- spacer for lead-out -->
|
||||
<div
|
||||
class="h-[60px]"
|
||||
style:position="absolute"
|
||||
style:left="0"
|
||||
style:right="0"
|
||||
style:transform={`translate3d(0,${timelineManager.timelineHeight}px,0)`}
|
||||
></div>
|
||||
</section>
|
||||
</photostream>
|
||||
|
||||
<style>
|
||||
photostream {
|
||||
display: block;
|
||||
}
|
||||
#asset-grid {
|
||||
contain: strict;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.month-group {
|
||||
contain: layout size paint;
|
||||
transform-style: flat;
|
||||
backface-visibility: hidden;
|
||||
transform-origin: center center;
|
||||
}
|
||||
</style>
|
||||
198
web/src/lib/components/timeline/PhotostreamWithScrubber.svelte
Normal file
198
web/src/lib/components/timeline/PhotostreamWithScrubber.svelte
Normal file
@@ -0,0 +1,198 @@
|
||||
<script lang="ts">
|
||||
import Photostream from '$lib/components/timeline/Photostream.svelte';
|
||||
import Scrubber from '$lib/components/timeline/Scrubber.svelte';
|
||||
import type { PhotostreamSegment } from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
|
||||
import { findMonthAtScrollPosition } from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||
|
||||
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { type ScrubberListenerWithScrollTo, type TimelineYearMonth } from '$lib/utils/timeline-util';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
/** `true` if this timeline responds to navigation events; if `true`, then look at the
|
||||
`AssetViewingStore.gridScrollTarget` and load and scroll to the asset specified, and
|
||||
additionally, update the page location/url with the asset as the asset-grid is scrolled */
|
||||
enableRouting: boolean;
|
||||
timelineManager: TimelineManager;
|
||||
|
||||
showSkeleton?: boolean;
|
||||
isShowDeleteConfirmation?: boolean;
|
||||
|
||||
segment: Snippet<
|
||||
[
|
||||
{
|
||||
segment: PhotostreamSegment;
|
||||
onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
|
||||
},
|
||||
]
|
||||
>;
|
||||
skeleton: Snippet<
|
||||
[
|
||||
{
|
||||
segment: PhotostreamSegment;
|
||||
},
|
||||
]
|
||||
>;
|
||||
children?: Snippet;
|
||||
empty?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
enableRouting,
|
||||
timelineManager = $bindable(),
|
||||
|
||||
showSkeleton = true,
|
||||
isShowDeleteConfirmation = $bindable(false),
|
||||
segment,
|
||||
skeleton,
|
||||
children,
|
||||
empty,
|
||||
}: Props = $props();
|
||||
|
||||
const VIEWPORT_MULTIPLIER = 2; // Used to determine if timeline is "small"
|
||||
|
||||
// The percentage of scroll through the month that is currently intersecting the top boundary of the viewport.
|
||||
// Note: There may be multiple months visible within the viewport at any given time.
|
||||
let viewportTopMonthScrollPercent = $state(0);
|
||||
// The timeline month intersecting the top position of the viewport
|
||||
let viewportTopMonth: TimelineYearMonth | undefined = $state(undefined);
|
||||
// Overall scroll percentage through the entire timeline (0-1)
|
||||
let timelineScrollPercent: number = $state(0);
|
||||
// Indicates whether the viewport is currently in the lead-out section (after all months)
|
||||
let isInLeadOutSection = $state(false);
|
||||
// Width of the scrubber component in pixels, used to adjust timeline margins
|
||||
let scrubberWidth: number = $state(0);
|
||||
|
||||
// note: don't throttle, debounce, or otherwise make this function async - it causes flicker
|
||||
// this function updates the scrubber position based on the current scroll position in the timeline
|
||||
const handleTimelineScroll = () => {
|
||||
isInLeadOutSection = false;
|
||||
|
||||
// Handle edge cases: small timeline (limited scroll) or lead-in area scrolling
|
||||
const top = timelineManager.visibleWindow.top;
|
||||
if (isSmallTimeline() || top < timelineManager.topSectionHeight) {
|
||||
calculateTimelineScrollPercent();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle normal month scrolling
|
||||
handleMonthScroll();
|
||||
};
|
||||
|
||||
const handleMonthScroll = () => {
|
||||
const scrollPosition = timelineManager.visibleWindow.top;
|
||||
const months = timelineManager.months;
|
||||
const maxScrollPercent = timelineManager.getMaxScrollPercent();
|
||||
|
||||
// Find the month at the current scroll position
|
||||
const searchResult = findMonthAtScrollPosition(months, scrollPosition, maxScrollPercent);
|
||||
|
||||
if (searchResult) {
|
||||
viewportTopMonth = searchResult.month;
|
||||
viewportTopMonthScrollPercent = searchResult.monthScrollPercent;
|
||||
isInLeadOutSection = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// We're in lead-out section
|
||||
isInLeadOutSection = true;
|
||||
timelineScrollPercent = 1;
|
||||
resetScrubberMonth();
|
||||
};
|
||||
|
||||
const resetScrubberMonth = () => {
|
||||
viewportTopMonth = undefined;
|
||||
viewportTopMonthScrollPercent = 0;
|
||||
};
|
||||
|
||||
const calculateTimelineScrollPercent = () => {
|
||||
const maxScroll = timelineManager.getMaxScroll();
|
||||
timelineScrollPercent = Math.min(1, timelineManager.visibleWindow.top / maxScroll);
|
||||
resetScrubberMonth();
|
||||
};
|
||||
|
||||
const handleOverallPercentScroll = (percent: number, scrollTo?: (offset: number) => void) => {
|
||||
const maxScroll = timelineManager.getMaxScroll();
|
||||
const offset = maxScroll * percent;
|
||||
scrollTo?.(offset);
|
||||
};
|
||||
|
||||
const findMonthGroup = (target: TimelineYearMonth) => {
|
||||
return timelineManager.months.find(
|
||||
({ yearMonth }) => yearMonth.year === target.year && yearMonth.month === target.month,
|
||||
);
|
||||
};
|
||||
|
||||
const isSmallTimeline = () => {
|
||||
return timelineManager.timelineHeight < timelineManager.viewportHeight * VIEWPORT_MULTIPLIER;
|
||||
};
|
||||
|
||||
// note: don't throttle, debounce, or otherwise make this function async - it causes flicker
|
||||
// this function scrolls the timeline to the specified month group and offset, based on scrubber interaction
|
||||
const onScrub: ScrubberListenerWithScrollTo = (scrubberData) => {
|
||||
const { scrubberMonth, overallScrollPercent, scrubberMonthScrollPercent, scrollToFunction } = scrubberData;
|
||||
|
||||
// Handle edge case or no month selected
|
||||
if (!scrubberMonth || isSmallTimeline()) {
|
||||
handleOverallPercentScroll(overallScrollPercent, scrollToFunction);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find and scroll to the selected month
|
||||
const monthGroup = findMonthGroup(scrubberMonth);
|
||||
if (monthGroup) {
|
||||
scrollToPositionWithinMonth(monthGroup, scrubberMonthScrollPercent, scrollToFunction);
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToPositionWithinMonth = (
|
||||
monthGroup: MonthGroup,
|
||||
monthGroupScrollPercent: number,
|
||||
handleScrollTop?: (top: number) => void,
|
||||
) => {
|
||||
const topOffset = monthGroup.top;
|
||||
const maxScrollPercent = timelineManager.getMaxScrollPercent();
|
||||
const delta = monthGroup.height * monthGroupScrollPercent;
|
||||
const scrollToTop = (topOffset + delta) * maxScrollPercent;
|
||||
|
||||
handleScrollTop?.(scrollToTop);
|
||||
};
|
||||
let baseTimelineViewer: Photostream | undefined = $state();
|
||||
export const scrollToAsset = async (asset: TimelineAsset) =>
|
||||
(await baseTimelineViewer?.scrollToAssetId(asset.id)) ?? false;
|
||||
export const completeAfterNavigate = (args: { scrollToAssetQueryParam: boolean }) =>
|
||||
baseTimelineViewer?.completeAfterNavigate(args);
|
||||
</script>
|
||||
|
||||
<Photostream
|
||||
bind:this={baseTimelineViewer}
|
||||
{enableRouting}
|
||||
{timelineManager}
|
||||
{showSkeleton}
|
||||
{isShowDeleteConfirmation}
|
||||
styleMarginRightPx={scrubberWidth}
|
||||
{handleTimelineScroll}
|
||||
{segment}
|
||||
{skeleton}
|
||||
{children}
|
||||
{empty}
|
||||
>
|
||||
{#snippet header(scrollToFunction)}
|
||||
{#if timelineManager.months.length > 0}
|
||||
<Scrubber
|
||||
{timelineManager}
|
||||
height={timelineManager.viewportHeight}
|
||||
timelineTopOffset={timelineManager.topSectionHeight}
|
||||
timelineBottomOffset={timelineManager.bottomSectionHeight}
|
||||
{isInLeadOutSection}
|
||||
{timelineScrollPercent}
|
||||
{viewportTopMonthScrollPercent}
|
||||
{viewportTopMonth}
|
||||
onScrub={(scrubberData) => onScrub({ ...scrubberData, scrollToFunction })}
|
||||
bind:scrubberWidth
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Photostream>
|
||||
76
web/src/lib/components/timeline/StreamWithViewer.svelte
Normal file
76
web/src/lib/components/timeline/StreamWithViewer.svelte
Normal 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>
|
||||
@@ -1,35 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate, beforeNavigate } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
|
||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||
import MonthSegment from '$lib/components/timeline/MonthSegment.svelte';
|
||||
import Scrubber from '$lib/components/timeline/Scrubber.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 HotModuleReload from '$lib/elements/HotModuleReload.svelte';
|
||||
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 { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import {
|
||||
getSegmentIdentifier,
|
||||
getTimes,
|
||||
type ScrubberListener,
|
||||
type TimelineYearMonth,
|
||||
} from '$lib/utils/timeline-util';
|
||||
import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
import type { UpdatePayload } from 'vite';
|
||||
import { type Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
isSelectionMode?: boolean;
|
||||
@@ -83,472 +68,90 @@
|
||||
customThumbnailLayout,
|
||||
}: Props = $props();
|
||||
|
||||
let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore;
|
||||
let viewer: PhotostreamWithScrubber | undefined = $state(undefined);
|
||||
|
||||
let element: HTMLElement | undefined = $state();
|
||||
|
||||
let timelineElement: HTMLElement | undefined = $state();
|
||||
let showSkeleton = $state(true);
|
||||
// The percentage of scroll through the month that is currently intersecting the top boundary of the viewport.
|
||||
// Note: There may be multiple months visible within the viewport at any given time.
|
||||
let viewportTopMonthScrollPercent = $state(0);
|
||||
// The timeline month intersecting the top position of the viewport
|
||||
let viewportTopMonth: { year: number; month: number } | undefined = $state(undefined);
|
||||
// Overall scroll percentage through the entire timeline (0-1)
|
||||
let timelineScrollPercent: number = $state(0);
|
||||
let scrubberWidth = $state(0);
|
||||
|
||||
// 60 is the bottom spacer element at 60px
|
||||
let bottomSectionHeight = 60;
|
||||
// Indicates whether the viewport is currently in the lead-out section (after all months)
|
||||
let isInLeadOutSection = $state(false);
|
||||
|
||||
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
||||
const maxMd = $derived(mobileDevice.maxMd);
|
||||
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
|
||||
|
||||
$effect(() => {
|
||||
const layoutOptions = maxMd
|
||||
? {
|
||||
rowHeight: 100,
|
||||
headerHeight: 32,
|
||||
}
|
||||
: {
|
||||
rowHeight: 235,
|
||||
headerHeight: 48,
|
||||
};
|
||||
timelineManager.setLayoutOptions(layoutOptions);
|
||||
});
|
||||
|
||||
const scrollTo = (top: number) => {
|
||||
if (element) {
|
||||
element.scrollTo({ top });
|
||||
}
|
||||
};
|
||||
const scrollTop = (top: number) => {
|
||||
if (element) {
|
||||
element.scrollTop = top;
|
||||
}
|
||||
};
|
||||
const scrollBy = (y: number) => {
|
||||
if (element) {
|
||||
element.scrollBy(0, y);
|
||||
}
|
||||
};
|
||||
const scrollToTop = () => {
|
||||
scrollTo(0);
|
||||
};
|
||||
|
||||
const handleTriggeredScrollCompensation = (compensation: { heightDelta?: number; scrollTop?: number }) => {
|
||||
const { heightDelta, scrollTop } = compensation;
|
||||
if (heightDelta !== undefined) {
|
||||
scrollBy(heightDelta);
|
||||
} else if (scrollTop !== undefined) {
|
||||
scrollTo(scrollTop);
|
||||
}
|
||||
timelineManager.clearScrollCompensation();
|
||||
};
|
||||
|
||||
const getAssetHeight = (assetId: string, monthGroup: MonthGroup) => {
|
||||
// the following method may trigger any layouts, so need to
|
||||
// handle any scroll compensation that may have been set
|
||||
const height = monthGroup!.findAssetAbsolutePosition(assetId);
|
||||
|
||||
// this is in a while loop, since scrollCompensations invoke scrolls
|
||||
// which may load months, triggering more scrollCompensations. Call
|
||||
// this in a loop, until no more layouts occur.
|
||||
while (timelineManager.scrollCompensation.monthGroup) {
|
||||
handleTriggeredScrollCompensation(timelineManager.scrollCompensation);
|
||||
}
|
||||
return height;
|
||||
};
|
||||
|
||||
const assetIsVisible = (assetTop: number): boolean => {
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { clientHeight, scrollTop } = element;
|
||||
return assetTop >= scrollTop && assetTop < scrollTop + clientHeight;
|
||||
};
|
||||
|
||||
const scrollToAssetId = async (assetId: string) => {
|
||||
const monthGroup = await timelineManager.findMonthGroupForAsset(assetId);
|
||||
if (!monthGroup) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const height = getAssetHeight(assetId, monthGroup);
|
||||
|
||||
// If the asset is already visible, then don't scroll.
|
||||
if (assetIsVisible(height)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
scrollTo(height);
|
||||
updateSlidingWindow();
|
||||
return true;
|
||||
};
|
||||
|
||||
const scrollToAsset = (asset: TimelineAsset) => {
|
||||
const monthGroup = timelineManager.getMonthGroupByAssetId(asset.id);
|
||||
if (!monthGroup) {
|
||||
return false;
|
||||
}
|
||||
const height = getAssetHeight(asset.id, monthGroup);
|
||||
scrollTo(height);
|
||||
updateSlidingWindow();
|
||||
return true;
|
||||
};
|
||||
|
||||
const completeNav = async () => {
|
||||
const scrollTarget = $gridScrollTarget?.at;
|
||||
let scrolled = false;
|
||||
if (scrollTarget) {
|
||||
scrolled = await scrollToAssetId(scrollTarget);
|
||||
}
|
||||
if (!scrolled) {
|
||||
// if the asset is not found, scroll to the top
|
||||
scrollToTop();
|
||||
}
|
||||
showSkeleton = false;
|
||||
};
|
||||
|
||||
beforeNavigate(() => (timelineManager.suspendTransitions = true));
|
||||
|
||||
afterNavigate((nav) => {
|
||||
const { complete } = nav;
|
||||
complete.then(completeNav, completeNav);
|
||||
});
|
||||
|
||||
const handleAfterUpdate = (payload: UpdatePayload) => {
|
||||
const timelineUpdate = payload.updates.some(
|
||||
(update) => update.path.endsWith('Timeline.svelte') || update.path.endsWith('assets-store.ts'),
|
||||
);
|
||||
|
||||
if (timelineUpdate) {
|
||||
setTimeout(() => {
|
||||
const asset = $page.url.searchParams.get('at');
|
||||
if (asset) {
|
||||
$gridScrollTarget = { at: asset };
|
||||
void navigate(
|
||||
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
|
||||
{ replaceState: true, forceNavigate: true },
|
||||
);
|
||||
} else {
|
||||
scrollToTop();
|
||||
}
|
||||
showSkeleton = false;
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBeforeUpdate = (payload: UpdatePayload) => {
|
||||
const timelineUpdate = payload.updates.some((update) => update.path.endsWith('Timeline.svelte'));
|
||||
if (timelineUpdate) {
|
||||
timelineManager.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
const updateIsScrolling = () => (timelineManager.scrolling = true);
|
||||
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
|
||||
const updateSlidingWindow = () => timelineManager.updateSlidingWindow(element?.scrollTop || 0);
|
||||
|
||||
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
|
||||
|
||||
onMount(() => {
|
||||
if (!enableRouting) {
|
||||
showSkeleton = false;
|
||||
}
|
||||
});
|
||||
|
||||
const getMaxScrollPercent = () => {
|
||||
const totalHeight = timelineManager.timelineHeight + bottomSectionHeight + timelineManager.topSectionHeight;
|
||||
return (totalHeight - timelineManager.viewportHeight) / totalHeight;
|
||||
};
|
||||
|
||||
const getMaxScroll = () => {
|
||||
if (!element || !timelineElement) {
|
||||
return 0;
|
||||
}
|
||||
return (
|
||||
timelineManager.topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight)
|
||||
);
|
||||
};
|
||||
|
||||
const scrollToMonthGroupAndOffset = (monthGroup: MonthGroup, monthGroupScrollPercent: number) => {
|
||||
const topOffset = monthGroup.top;
|
||||
const maxScrollPercent = getMaxScrollPercent();
|
||||
const delta = monthGroup.height * monthGroupScrollPercent;
|
||||
const scrollToTop = (topOffset + delta) * maxScrollPercent;
|
||||
|
||||
scrollTop(scrollToTop);
|
||||
};
|
||||
|
||||
// note: don't throttle, debounce, or otherwise make this function async - it causes flicker
|
||||
// this function scrolls the timeline to the specified month group and offset, based on scrubber interaction
|
||||
const onScrub: ScrubberListener = (scrubberData) => {
|
||||
const { scrubberMonth, overallScrollPercent, scrubberMonthScrollPercent } = scrubberData;
|
||||
|
||||
if (!scrubberMonth || timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
|
||||
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
|
||||
const maxScroll = getMaxScroll();
|
||||
const offset = maxScroll * overallScrollPercent;
|
||||
scrollTop(offset);
|
||||
} else {
|
||||
const monthGroup = timelineManager.months.find(
|
||||
({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month,
|
||||
);
|
||||
if (!monthGroup) {
|
||||
return;
|
||||
}
|
||||
scrollToMonthGroupAndOffset(monthGroup, scrubberMonthScrollPercent);
|
||||
}
|
||||
};
|
||||
|
||||
// note: don't throttle, debounch, or otherwise make this function async - it causes flicker
|
||||
const handleTimelineScroll = () => {
|
||||
isInLeadOutSection = false;
|
||||
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
|
||||
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead
|
||||
const maxScroll = getMaxScroll();
|
||||
timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll);
|
||||
|
||||
viewportTopMonth = undefined;
|
||||
viewportTopMonthScrollPercent = 0;
|
||||
} else {
|
||||
let top = element.scrollTop;
|
||||
if (top < timelineManager.topSectionHeight) {
|
||||
// in the lead-in area
|
||||
viewportTopMonth = undefined;
|
||||
viewportTopMonthScrollPercent = 0;
|
||||
const maxScroll = getMaxScroll();
|
||||
|
||||
timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll);
|
||||
return;
|
||||
}
|
||||
|
||||
let maxScrollPercent = getMaxScrollPercent();
|
||||
let found = false;
|
||||
|
||||
const monthsLength = timelineManager.months.length;
|
||||
for (let i = -1; i < monthsLength + 1; i++) {
|
||||
let monthGroup: TimelineYearMonth | undefined;
|
||||
let monthGroupHeight = 0;
|
||||
if (i === -1) {
|
||||
// lead-in
|
||||
monthGroupHeight = timelineManager.topSectionHeight;
|
||||
} else if (i === monthsLength) {
|
||||
// lead-out
|
||||
monthGroupHeight = bottomSectionHeight;
|
||||
} else {
|
||||
monthGroup = timelineManager.months[i].yearMonth;
|
||||
monthGroupHeight = timelineManager.months[i].height;
|
||||
}
|
||||
|
||||
let next = top - monthGroupHeight * maxScrollPercent;
|
||||
// instead of checking for < 0, add a little wiggle room for subpixel resolution
|
||||
if (next < -1 && monthGroup) {
|
||||
viewportTopMonth = monthGroup;
|
||||
|
||||
// allowing next to be at least 1 may cause percent to go negative, so ensure positive percentage
|
||||
viewportTopMonthScrollPercent = Math.max(0, top / (monthGroupHeight * maxScrollPercent));
|
||||
|
||||
// compensate for lost precision/rounding errors advance to the next bucket, if present
|
||||
if (viewportTopMonthScrollPercent > 0.9999 && i + 1 < monthsLength - 1) {
|
||||
viewportTopMonth = timelineManager.months[i + 1].yearMonth;
|
||||
viewportTopMonthScrollPercent = 0;
|
||||
}
|
||||
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
top = next;
|
||||
}
|
||||
if (!found) {
|
||||
isInLeadOutSection = true;
|
||||
viewportTopMonth = undefined;
|
||||
viewportTopMonthScrollPercent = 0;
|
||||
timelineScrollPercent = 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$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>
|
||||
|
||||
<HotModuleReload onAfterUpdate={handleAfterUpdate} onBeforeUpdate={handleBeforeUpdate} />
|
||||
|
||||
<TimelineKeyboardActions
|
||||
scrollToAsset={(asset) => scrollToAsset(asset) ?? false}
|
||||
{timelineManager}
|
||||
{assetInteraction}
|
||||
bind:isShowDeleteConfirmation
|
||||
{onEscape}
|
||||
/>
|
||||
|
||||
{#if timelineManager.months.length > 0}
|
||||
<Scrubber
|
||||
<StreamWithViewer {timelineManager} {onAfterNavigateComplete}>
|
||||
{#snippet assetViewer({ onViewerClose })}
|
||||
<TimelineAssetViewer {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} {onViewerClose} />
|
||||
{/snippet}
|
||||
<TimelineKeyboardActions
|
||||
scrollToAsset={async (asset) => (await viewer?.scrollToAsset(asset)) ?? Promise.resolve(false)}
|
||||
{timelineManager}
|
||||
height={timelineManager.viewportHeight}
|
||||
timelineTopOffset={timelineManager.topSectionHeight}
|
||||
timelineBottomOffset={bottomSectionHeight}
|
||||
{isInLeadOutSection}
|
||||
{timelineScrollPercent}
|
||||
{viewportTopMonthScrollPercent}
|
||||
{viewportTopMonth}
|
||||
{onScrub}
|
||||
bind:scrubberWidth
|
||||
{assetInteraction}
|
||||
bind:isShowDeleteConfirmation
|
||||
{onEscape}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Right margin MUST be equal to the width of scrubber -->
|
||||
<section
|
||||
id="asset-grid"
|
||||
class={['scrollbar-hidden h-full overflow-y-auto outline-none', { 'm-0': isEmpty }, { 'ms-0': !isEmpty }]}
|
||||
style:margin-right={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
|
||||
tabindex="-1"
|
||||
bind:clientHeight={timelineManager.viewportHeight}
|
||||
bind:clientWidth={null, (v: number) => ((timelineManager.viewportWidth = v), updateSlidingWindow())}
|
||||
bind:this={element}
|
||||
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
|
||||
>
|
||||
<section
|
||||
bind:this={timelineElement}
|
||||
id="virtual-timeline"
|
||||
class:invisible={showSkeleton}
|
||||
style:height={timelineManager.timelineHeight + 'px'}
|
||||
<PhotostreamWithScrubber
|
||||
bind:this={viewer}
|
||||
{enableRouting}
|
||||
{timelineManager}
|
||||
{isShowDeleteConfirmation}
|
||||
{children}
|
||||
{empty}
|
||||
>
|
||||
<section
|
||||
use:resizeObserver={topSectionResizeObserver}
|
||||
class:invisible={showSkeleton}
|
||||
style:position="absolute"
|
||||
style:left="0"
|
||||
style:right="0"
|
||||
>
|
||||
{@render children?.()}
|
||||
{#if isEmpty}
|
||||
<!-- (optional) empty placeholder -->
|
||||
{@render empty?.()}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#each timelineManager.months as monthGroup (monthGroup.viewId)}
|
||||
{@const display = monthGroup.intersecting}
|
||||
{@const absoluteHeight = monthGroup.top}
|
||||
|
||||
{#if !monthGroup.isLoaded}
|
||||
<div
|
||||
style:height={monthGroup.height + 'px'}
|
||||
style:position="absolute"
|
||||
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
||||
style:width="100%"
|
||||
>
|
||||
<Skeleton
|
||||
height={monthGroup.height - monthGroup.timelineManager.headerHeight}
|
||||
title={monthGroup.monthGroupTitle}
|
||||
/>
|
||||
</div>
|
||||
{:else if display}
|
||||
<div
|
||||
class="month-group"
|
||||
style:height={monthGroup.height + 'px'}
|
||||
style:position="absolute"
|
||||
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
||||
style:width="100%"
|
||||
>
|
||||
<SelectableSegment
|
||||
segment={monthGroup}
|
||||
onScrollCompensationMonthInDOM={handleTriggeredScrollCompensation}
|
||||
{timelineManager}
|
||||
{assetInteraction}
|
||||
{isSelectionMode}
|
||||
{singleSelect}
|
||||
{onAssetOpen}
|
||||
{onAssetSelect}
|
||||
>
|
||||
{#snippet content({ onAssetOpen, onAssetSelect, onAssetHover })}
|
||||
<SelectableDay {assetInteraction} {onAssetSelect}>
|
||||
{#snippet content({ onDayGroupSelect, onDayGroupAssetSelect })}
|
||||
<MonthSegment
|
||||
{assetInteraction}
|
||||
{customThumbnailLayout}
|
||||
{singleSelect}
|
||||
{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 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}
|
||||
</SelectableDay>
|
||||
</MonthSegment>
|
||||
{/snippet}
|
||||
</SelectableSegment>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
<!-- spacer for leadout -->
|
||||
<div
|
||||
class="h-[60px]"
|
||||
style:position="absolute"
|
||||
style:left="0"
|
||||
style:right="0"
|
||||
style:transform={`translate3d(0,${timelineManager.timelineHeight}px,0)`}
|
||||
></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<Portal target="body">
|
||||
{#if $showAssetViewer}
|
||||
<TimelineAssetViewer bind:showSkeleton {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} />
|
||||
{/if}
|
||||
</Portal>
|
||||
|
||||
<style>
|
||||
#asset-grid {
|
||||
contain: strict;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.month-group {
|
||||
contain: layout size paint;
|
||||
transform-style: flat;
|
||||
backface-visibility: hidden;
|
||||
transform-origin: center center;
|
||||
}
|
||||
</style>
|
||||
</SelectableDay>
|
||||
{/snippet}
|
||||
</SelectableSegment>
|
||||
{/snippet}
|
||||
</PhotostreamWithScrubber>
|
||||
</StreamWithViewer>
|
||||
|
||||
@@ -9,15 +9,16 @@
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||
|
||||
let { asset: viewingAsset, gridScrollTarget, mutex, preloadAssets } = assetViewingStore;
|
||||
let { asset: viewingAsset, mutex, preloadAssets } = assetViewingStore;
|
||||
|
||||
interface Props {
|
||||
timelineManager: TimelineManager;
|
||||
showSkeleton: boolean;
|
||||
|
||||
withStacked?: boolean;
|
||||
isShared?: boolean;
|
||||
album?: AlbumResponseDto | null;
|
||||
person?: PersonResponseDto | null;
|
||||
onViewerClose?: (asset: { id: string }) => Promise<void>;
|
||||
|
||||
removeAction?:
|
||||
| AssetAction.UNARCHIVE
|
||||
@@ -30,12 +31,12 @@
|
||||
|
||||
let {
|
||||
timelineManager,
|
||||
showSkeleton = $bindable(false),
|
||||
removeAction,
|
||||
withStacked = false,
|
||||
isShared = false,
|
||||
album = null,
|
||||
person = null,
|
||||
onViewerClose = () => Promise.resolve(void 0),
|
||||
}: Props = $props();
|
||||
|
||||
const handlePrevious = async () => {
|
||||
@@ -79,13 +80,6 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = async (asset: { id: string }) => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
showSkeleton = true;
|
||||
$gridScrollTarget = { at: asset.id };
|
||||
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
|
||||
};
|
||||
|
||||
const handlePreAction = async (action: Action) => {
|
||||
switch (action.type) {
|
||||
case removeAction:
|
||||
@@ -97,7 +91,7 @@
|
||||
case AssetAction.SET_VISIBILITY_TIMELINE: {
|
||||
// find the next asset to show or close the viewer
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
(await handleNext()) || (await handlePrevious()) || (await handleClose(action.asset));
|
||||
(await handleNext()) || (await handlePrevious()) || (await onViewerClose?.(action.asset));
|
||||
|
||||
// delete after find the next one
|
||||
timelineManager.removeAssets([action.asset.id]);
|
||||
@@ -172,6 +166,6 @@
|
||||
onPrevious={handlePrevious}
|
||||
onNext={handleNext}
|
||||
onRandom={handleRandom}
|
||||
onClose={handleClose}
|
||||
onClose={onViewerClose}
|
||||
/>
|
||||
{/await}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
type RelativeResult,
|
||||
} from '$lib/components/shared-components/change-date.svelte';
|
||||
import {
|
||||
setFocusToAsset as setFocusAssetInit,
|
||||
setFocusTo as setFocusToInit,
|
||||
setFocusToAsset as setFocusAssetUtil,
|
||||
setFocusTo as setFocusToUtil,
|
||||
type FocusDirection,
|
||||
type FocusInterval,
|
||||
} from '$lib/components/timeline/actions/focus-actions';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
@@ -32,7 +34,7 @@
|
||||
assetInteraction: AssetInteraction;
|
||||
isShowDeleteConfirmation: boolean;
|
||||
onEscape?: () => void;
|
||||
scrollToAsset: (asset: TimelineAsset) => boolean;
|
||||
scrollToAsset: (asset: TimelineAsset) => Promise<boolean>;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -147,8 +149,10 @@
|
||||
}
|
||||
});
|
||||
|
||||
const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, timelineManager);
|
||||
const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset);
|
||||
const setFocusTo = (direction: FocusDirection, interval: FocusInterval) =>
|
||||
setFocusToUtil(scrollToAsset, timelineManager, direction, interval);
|
||||
|
||||
const setFocusAsset = (asset: TimelineAsset) => setFocusAssetUtil(scrollToAsset, asset);
|
||||
|
||||
let shortcutList = $derived(
|
||||
(() => {
|
||||
@@ -212,7 +216,7 @@
|
||||
(DateTime.fromISO(dateString.date) as DateTime<true>).toObject(),
|
||||
);
|
||||
if (asset) {
|
||||
setFocusAsset(asset);
|
||||
void setFocusAsset(asset);
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -21,19 +21,26 @@ export const focusPreviousAsset = () =>
|
||||
|
||||
const queryHTMLElement = (query: string) => document.querySelector(query) as HTMLElement;
|
||||
|
||||
export const setFocusToAsset = (scrollToAsset: (asset: TimelineAsset) => boolean, asset: TimelineAsset) => {
|
||||
const scrolled = scrollToAsset(asset);
|
||||
export const setFocusToAsset = async (
|
||||
scrollToAsset: (asset: TimelineAsset) => Promise<boolean>,
|
||||
asset: TimelineAsset,
|
||||
) => {
|
||||
const scrolled = await scrollToAsset(asset);
|
||||
if (scrolled) {
|
||||
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
|
||||
element?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
export type FocusDirection = 'earlier' | 'later';
|
||||
|
||||
export type FocusInterval = 'day' | 'month' | 'year' | 'asset';
|
||||
|
||||
export const setFocusTo = async (
|
||||
scrollToAsset: (asset: TimelineAsset) => boolean,
|
||||
scrollToAsset: (asset: TimelineAsset) => Promise<boolean>,
|
||||
store: TimelineManager,
|
||||
direction: 'earlier' | 'later',
|
||||
interval: 'day' | 'month' | 'year' | 'asset',
|
||||
direction: FocusDirection,
|
||||
interval: FocusInterval,
|
||||
) => {
|
||||
if (tracker.isActive()) {
|
||||
// there are unfinished running invocations, so return early
|
||||
@@ -65,7 +72,10 @@ export const setFocusTo = async (
|
||||
return;
|
||||
}
|
||||
|
||||
const scrolled = scrollToAsset(asset);
|
||||
const scrolled = await scrollToAsset(asset);
|
||||
if (!invocation.isStillValid()) {
|
||||
return;
|
||||
}
|
||||
if (scrolled) {
|
||||
await tick();
|
||||
if (!invocation.isStillValid()) {
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -274,13 +274,14 @@ export abstract class PhotostreamManager {
|
||||
return this.months.find((segment) => identifier.matches(segment));
|
||||
}
|
||||
|
||||
getSegmentForAssetId(assetId: string) {
|
||||
findSegmentForAssetId(assetId: string): Promise<PhotostreamSegment | undefined> {
|
||||
for (const month of this.months) {
|
||||
const asset = month.assets.find((asset) => asset.id === assetId);
|
||||
if (asset) {
|
||||
return month;
|
||||
return Promise.resolve(month);
|
||||
}
|
||||
}
|
||||
return Promise.resolve(void 0);
|
||||
}
|
||||
|
||||
refreshLayout() {
|
||||
@@ -327,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);
|
||||
@@ -428,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[]) {}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -143,3 +143,79 @@ export function findMonthGroupForDate(timelineManager: TimelineManager, targetYe
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface MonthGroupForSearch {
|
||||
yearMonth: TimelineYearMonth;
|
||||
top: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface BinarySearchResult {
|
||||
month: TimelineYearMonth;
|
||||
monthScrollPercent: number;
|
||||
}
|
||||
|
||||
export function findMonthAtScrollPosition(
|
||||
months: MonthGroupForSearch[],
|
||||
scrollPosition: number,
|
||||
maxScrollPercent: number,
|
||||
): BinarySearchResult | null {
|
||||
const SUBPIXEL_TOLERANCE = -1; // Tolerance for scroll position checks
|
||||
const NEAR_END_THRESHOLD = 0.9999; // Threshold for detecting near-end of month
|
||||
|
||||
if (months.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if we're before the first month
|
||||
const firstMonthTop = months[0].top * maxScrollPercent;
|
||||
if (scrollPosition < firstMonthTop - SUBPIXEL_TOLERANCE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if we're after the last month
|
||||
const lastMonth = months.at(-1)!;
|
||||
const lastMonthBottom = (lastMonth.top + lastMonth.height) * maxScrollPercent;
|
||||
if (scrollPosition >= lastMonthBottom - SUBPIXEL_TOLERANCE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Binary search to find the month containing the scroll position
|
||||
let left = 0;
|
||||
let right = months.length - 1;
|
||||
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
const month = months[mid];
|
||||
const monthTop = month.top * maxScrollPercent;
|
||||
const monthBottom = monthTop + month.height * maxScrollPercent;
|
||||
|
||||
if (scrollPosition >= monthTop - SUBPIXEL_TOLERANCE && scrollPosition < monthBottom - SUBPIXEL_TOLERANCE) {
|
||||
// Found the month containing the scroll position
|
||||
const distanceIntoMonth = scrollPosition - monthTop;
|
||||
const monthScrollPercent = Math.max(0, distanceIntoMonth / (month.height * maxScrollPercent));
|
||||
|
||||
// Handle month boundary edge case
|
||||
if (monthScrollPercent > NEAR_END_THRESHOLD && mid < months.length - 1) {
|
||||
return {
|
||||
month: months[mid + 1].yearMonth,
|
||||
monthScrollPercent: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
month: month.yearMonth,
|
||||
monthScrollPercent,
|
||||
};
|
||||
}
|
||||
|
||||
if (scrollPosition < monthTop) {
|
||||
right = mid - 1;
|
||||
} else {
|
||||
left = mid + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Shouldn't reach here, but return null if we do
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@ export class MonthGroup extends PhotostreamSegment {
|
||||
this.loader?.cancel();
|
||||
}
|
||||
|
||||
layout(noDefer: boolean) {
|
||||
layout(noDefer?: boolean) {
|
||||
layoutMonthGroup(this.timelineManager, this, noDefer);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
||||
import { getMonthGroupByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
||||
import {
|
||||
findMonthGroupForAsset,
|
||||
getMonthGroupByDate,
|
||||
} from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
||||
import { AbortError } from '$lib/utils';
|
||||
import { fromISODateTimeUTCToObject, getSegmentIdentifier } from '$lib/utils/timeline-util';
|
||||
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||
@@ -549,10 +552,10 @@ describe('TimelineManager', () => {
|
||||
);
|
||||
timelineManager.upsertAssets([assetOne, assetTwo]);
|
||||
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.month).toEqual(2);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
|
||||
expect(findMonthGroupForAsset(timelineManager, assetTwo.id)?.monthGroup.yearMonth.year).toEqual(2024);
|
||||
expect(findMonthGroupForAsset(timelineManager, assetTwo.id)?.monthGroup.yearMonth.month).toEqual(2);
|
||||
expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.year).toEqual(2024);
|
||||
expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.month).toEqual(1);
|
||||
});
|
||||
|
||||
it('ignores removed months', () => {
|
||||
@@ -569,8 +572,8 @@ describe('TimelineManager', () => {
|
||||
timelineManager.upsertAssets([assetOne, assetTwo]);
|
||||
|
||||
timelineManager.removeAssets([assetTwo.id]);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
|
||||
expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.year).toEqual(2024);
|
||||
expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.month).toEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,7 +171,7 @@ export class TimelineManager extends PhotostreamManager {
|
||||
this.scrubberTimelineHeight = this.timelineHeight;
|
||||
}
|
||||
|
||||
async findMonthGroupForAsset(id: string) {
|
||||
async findSegmentForAssetId(id: string) {
|
||||
if (!this.isInitialized) {
|
||||
await this.initTask.waitUntilCompletion();
|
||||
}
|
||||
@@ -202,11 +202,6 @@ export class TimelineManager extends PhotostreamManager {
|
||||
return getMonthGroupByDate(this, yearMonth);
|
||||
}
|
||||
|
||||
getMonthGroupByAssetId(assetId: string) {
|
||||
const monthGroupInfo = findMonthGroupForAssetUtil(this, assetId);
|
||||
return monthGroupInfo?.monthGroup;
|
||||
}
|
||||
|
||||
async getRandomMonthGroup() {
|
||||
const random = Math.floor(Math.random() * this.months.length);
|
||||
const month = this.months[random];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -24,11 +24,19 @@ export type TimelineDateTime = TimelineDate & {
|
||||
millisecond: number;
|
||||
};
|
||||
|
||||
export type ScrubberListener = (scrubberData: {
|
||||
export type ScrubberData = {
|
||||
scrubberMonth: { year: number; month: number };
|
||||
overallScrollPercent: number;
|
||||
scrubberMonthScrollPercent: number;
|
||||
}) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export type ScrubberListener = (scrubberData: ScrubberData) => void | Promise<void>;
|
||||
|
||||
export type ScrubberDataWithScrollTo = ScrubberData & {
|
||||
scrollToFunction: (top: number) => void;
|
||||
};
|
||||
|
||||
export type ScrubberListenerWithScrollTo = (scrubberData: ScrubberDataWithScrollTo) => void | Promise<void>;
|
||||
|
||||
// used for AssetResponseDto.dateTimeOriginal, amongst others
|
||||
export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime<true> =>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user