Compare commits

...

15 Commits

Author SHA1 Message Date
midzelis
575eecfd97 refactor(web): convert timeline layout options to derived state
- Replace createLayoutOptions() method with derived layoutOptions property for better reactivity. 
- Remove duplicate refreshLayout() implementation 
- Simplify setLayoutOptions() by removing unnecessary change tracking.
2025-10-07 01:29:52 +00:00
midzelis
674def4088 refactor: remove scroll compensation logic from photostream components 2025-10-07 01:29:52 +00:00
midzelis
8943242b7f fix: reactivity warnings in searchbox 2025-10-07 01:29:52 +00:00
midzelis
5ac5aa57a8 fix: padding for searchresults/photostream 2025-10-07 01:29:52 +00:00
midzelis
50d462b95f feat: search results keyboard actions 2025-10-07 01:29:52 +00:00
midzelis
0ebf75a96f feat: search results page (without keyboard actions)
fix: missing responsive calculation in UserPageLayout
2025-10-07 01:29:52 +00:00
midzelis
398755e65e feat: Extract common StreamWithViewer component
- Create new StreamWithViewer component to handle asset viewer lifecycle
  and navigation
  - Move beforeNavigate/afterNavigate hooks from Timeline to StreamWithViewer
  - Extract asset viewer Portal rendering and close handler to wrapper
  component
  - Move timeline segment loading logic for viewed assets to StreamWithViewer
  - Simplify Timeline component by removing ~76 lines of navigation/viewer
  code
  - Remove showSkeleton state management from Timeline (now handled by
  PhotostreamWithScrubber)

  This separation of concerns makes the Timeline component more focused on
  rendering while StreamWithViewer handles all viewer-related navigation and state
  management.The new component can be reused by other photostream-like components that
  need asset viewer functionality.
2025-10-07 00:56:15 +00:00
midzelis
79adb016e8 feat: photostream can have scrollbar, style options, standardize small/large layout sizes
- Add configurable header height props for responsive layouts
  (smallHeaderHeight/largeHeaderHeight)
  - Add style customization props: styleMarginContentHorizontal,
  styleMarginTop, alwaysShowScrollbar
  - Replace hardcoded layout values with configurable props
  - Change root element from <section> to custom <photostream> element for
  better semantic structure
  - Move viewport width binding to inner timeline element for more accurate
  measurements
  - Simplify HMR handler by removing file-specific checks
  - Add segment loading check to prevent rendering unloaded segments
  - Add spacing margin between month groups using layout options
  - Change scrollbar-width from 'auto' to 'thin' for consistency
  - Remove unused UpdatePayload type import
2025-10-07 00:56:15 +00:00
midzelis
1b60c9d32f feat: skeleton title is optional
feat: skeleton title optional
2025-10-07 00:56:15 +00:00
midzelis
113082dcaa fix: back/forward navigation won't reset scroll in timeline 2025-10-07 00:56:15 +00:00
midzelis
8d32ec1f18 refactor(web): extract timeline scrolling logic into common Photostream components
- Extract common timeline functionality into Photostream.svelte
  base component
- Create PhotostreamWithScrubber.svelte to handle scrubber
  integration
- Simplify Timeline.svelte by removing ~300 lines of
  scrolling/scrubber logic
- Add findMonthAtScrollPosition utility with binary search for
  better performance
- Maintain all existing functionality while improving code
  organization
2025-10-07 00:56:15 +00:00
midzelis
ed63399181 refactor(web): extract timeline selection logic into SelectableSegment and SelectableDay components
- Move asset selection, range selection, and keyboard interaction logic
  to SelectableSegment
  - Extract day group selection logic to SelectableDay component
  - Simplify Timeline component by removing selection-related state and
  handlers
  - Fix scroll compensation handling with dedicated while loop
  - Remove unused keyboard handlers from Scrubber component
2025-09-30 00:08:14 +00:00
midzelis
0168353c2d refactor(web): extract asset grid layout logic into AssetLayout component
- Extracts the asset grid rendering logic from `MonthSegment` into a
  dedicated `AssetLayout` component
- Simplifies `MonthSegment` by delegating layout responsibilities
  while maintaining all existing functionality
- Renames `customLayout` prop to `customThumbnailLayout` for clarity
  across Timeline components


## Changes
  - Created new `AssetLayout.svelte` component that handles:
    - Asset grid rendering with proper positioning
    - Animation transitions
    - Filtering of intersecting viewer assets
  - Updated `MonthSegment.svelte` to use `AssetLayout` via composition
  pattern
  - Renamed `customLayout` to `customThumbnailLayout` in Timeline and
  related components
  - Moved thumbnail click and selection logic to Timeline parent
  component using snippets
2025-09-30 00:08:14 +00:00
midzelis
c44b315117 refactor(web): consolidate asset operations in PhotostreamManager base class
Moves common asset operation methods (upsertAssets, removeAssets, 
updateAssetOperation) from TimelineManager into PhotostreamManager 
base class, making them available to all photostream implementations. 
Updates all consuming components to use the more accurate 'upsertAssets' 
naming instead of separate 'addAssets' and 'updateAssets' methods.

- Move asset operation methods to PhotostreamManager base class
- Replace addAssets/updateAssets calls with unified upsertAssets method
- Update type imports to use PhotostreamManager instead of TimelineManager
- Remove operations-support.svelte.ts (functionality moved to base class)
- Add abstract upsertAssetIntoSegment method for subclass customization
2025-09-30 00:00:50 +00:00
midzelis
98ab224791 refactor: adjust favorite, delete, and archive actions for timeline
- Pass TimelineManager instance to timeline action components
  instead of callbacks
- Move asset update logic (delete, archive, favorite) into
  action components
2025-09-29 01:26:59 +00:00
48 changed files with 2343 additions and 1534 deletions

View File

@@ -161,7 +161,24 @@ export class NotificationService extends BaseService {
const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([assetId]);
if (asset) {
this.eventRepository.clientSend('on_asset_update', userId, mapAsset(asset));
// need to specify authDto to this mapAsset request, because it tries to prevent
// leaking information PR#7580 which expects a userId in the auth options object
this.eventRepository.clientSend(
'on_asset_update',
userId,
mapAsset(asset, {
auth: {
user: {
id: userId,
isAdmin: false,
name: '',
email: '',
quotaUsageInBytes: 0,
quotaSizeInBytes: null,
},
},
}),
);
}
}

View File

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

View File

@@ -121,6 +121,7 @@
const onMouseLeave = () => {
mouseOver = false;
onMouseEvent?.({ isMouseOver: false, selectedGroupIndex: groupIndex });
};
let timer: ReturnType<typeof setTimeout> | null = null;

View File

@@ -49,7 +49,7 @@
<div
tabindex="-1"
class="relative z-0 grid grid-cols-[--spacing(0)_auto] overflow-hidden sidebar:grid-cols-[--spacing(64)_auto]
{hideNavbar ? 'h-dvh' : 'h-[calc(100dvh-var(--navbar-height))]'}
{hideNavbar ? 'h-dvh' : 'h-[calc(100dvh-var(--navbar-height))] max-md:h-[calc(100dvh-var(--navbar-height-md))]'}
{hideNavbar ? 'pt-(--navbar-height)' : ''}
{hideNavbar ? 'max-md:pt-(--navbar-height-md)' : ''}"
>

View File

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

View File

@@ -0,0 +1,96 @@
<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 = 0,
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} {stylePaddingHorizontalPx} />
{/snippet}
{#snippet segment({ segment })}
<SelectableSegment
timelineManager={searchResultsManager}
{assetInteraction}
isSelectionMode={false}
singleSelect={false}
>
{#snippet content({ onAssetOpen, onAssetSelect, onAssetHover })}
<AssetLayout
photostreamManager={searchResultsManager}
viewerAssets={segment.viewerAssets}
height={segment.height}
width={searchResultsManager.viewportWidth}
>
{#snippet thumbnail({ asset, position })}
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
{@const isAssetSelected = assetInteraction.hasSelectedAsset(asset.id)}
<Thumbnail
showStackedIcon={true}
showArchiveIcon={true}
{asset}
onClick={() => onAssetOpen(asset)}
onSelect={() => onAssetSelect(asset)}
onMouseEvent={() => onAssetHover(asset)}
selected={isAssetSelected}
selectionCandidate={isAssetSelectionCandidate}
thumbnailWidth={position.width}
thumbnailHeight={position.height}
/>
{/snippet}
</AssetLayout>
{/snippet}
</SelectableSegment>
{/snippet}
</Photostream>
</StreamWithViewer>

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import { goto } from '$app/navigation';
import type { Action } from '$lib/components/asset-viewer/actions/action';
import { AppRoute, AssetAction } from '$lib/constants';
import { SearchResultsManager } from '$lib/managers/searchresults-manager/SearchResultsManager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { navigate } from '$lib/utils/navigation';
let { asset: viewingAsset, setAssetId, preloadAssets } = assetViewingStore;
interface Props {
timelineManager: SearchResultsManager;
onViewerClose?: (asset: { id: string }) => Promise<void>;
}
let { timelineManager, onViewerClose = () => Promise.resolve(void 0) }: Props = $props();
const handleNext = async (): Promise<boolean> => {
const next = timelineManager.findNextAsset($viewingAsset.id);
if (next) {
await navigateToAsset(next);
return true;
}
return false;
};
const handleRandom = async (): Promise<{ id: string } | undefined> => {
const next = timelineManager.findRandomAsset();
if (next) {
await navigateToAsset(next);
return { id: next.id };
}
};
const handlePrevious = async (): Promise<boolean> => {
const next = timelineManager.findPreviousAsset($viewingAsset.id);
if (next) {
await navigateToAsset(next);
return true;
}
return false;
};
const navigateToAsset = async (asset?: { id: string }) => {
if (asset && asset.id !== $viewingAsset.id) {
await setAssetId(asset.id);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
}
};
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.ARCHIVE:
case AssetAction.DELETE:
case AssetAction.TRASH: {
timelineManager.removeAssets([action.asset.id]);
if (!(await handlePrevious())) {
await goto(AppRoute.PHOTOS);
}
break;
}
}
};
</script>
{#await import('../asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
asset={$viewingAsset}
preloadAssets={$preloadAssets}
onAction={handleAction}
onPrevious={handlePrevious}
onNext={handleNext}
onRandom={handleRandom}
onClose={onViewerClose}
/>
{/await}

View File

@@ -33,9 +33,13 @@
let isFocus = $state(false);
let close: (() => Promise<void>) | undefined;
let navigating = false;
const listboxId = generateId();
onDestroy(() => {
if (navigating) {
return;
}
searchStore.isSearchEnabled = false;
});
@@ -44,6 +48,7 @@
closeDropdown();
searchStore.isSearchEnabled = false;
navigating = true;
await goto(`${AppRoute.SEARCH}?${params}`);
};
@@ -73,6 +78,9 @@
};
const onFocusOut = () => {
if (navigating) {
return;
}
searchStore.isSearchEnabled = false;
};
@@ -161,6 +169,9 @@
};
const closeDropdown = () => {
if (navigating) {
return;
}
showSuggestions = false;
isFocus = false;
searchHistoryBox?.clearSelection();

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import { uploadAssetsStore } from '$lib/stores/upload';
import { flip } from 'svelte/animate';
import { scale } from 'svelte/transition';
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
import type { CommonPosition } from '$lib/utils/layout-utils';
import type { Snippet } from 'svelte';
let { isUploading } = uploadAssetsStore;
interface Props {
viewerAssets: ViewerAsset[];
width: number;
height: number;
photostreamManager: PhotostreamManager;
thumbnail: Snippet<
[
{
asset: TimelineAsset;
position: CommonPosition;
},
]
>;
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>;
}
let { viewerAssets, width, height, photostreamManager, thumbnail, customThumbnailLayout }: Props = $props();
const transitionDuration = $derived.by(() => (photostreamManager.suspendTransitions && !$isUploading ? 0 : 150));
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
function filterIntersecting<R extends { intersecting: boolean }>(intersectables: R[]) {
return intersectables.filter((intersectable) => intersectable.intersecting);
}
</script>
<!-- Image grid -->
<div data-image-grid class="relative overflow-clip" style:height={height + 'px'} style:width={width + 'px'}>
{#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!}
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
<div
data-asset-id={asset.id}
class="absolute"
style:top={position.top + 'px'}
style:left={position.left + 'px'}
style:width={position.width + 'px'}
style:height={position.height + 'px'}
out:scale|global={{ start: 0.1, duration: scaleDuration }}
animate:flip={{ duration: transitionDuration }}
>
{@render thumbnail({ asset, position })}
{@render customThumbnailLayout?.(asset)}
</div>
{/each}
</div>
<style>
[data-image-grid] {
user-select: none;
}
</style>

View File

@@ -1,140 +1,49 @@
<script lang="ts">
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetSnapshot, assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
import { navigate } from '$lib/utils/navigation';
import { Icon } from '@immich/ui';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import { Icon } from '@immich/ui';
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import type { CommonPosition } from '$lib/utils/layout-utils';
import type { Snippet } from 'svelte';
import { flip } from 'svelte/animate';
import { scale } from 'svelte/transition';
let { isUploading } = uploadAssetsStore;
interface Props {
isSelectionMode: boolean;
thumbnail: Snippet<[{ asset: TimelineAsset; position: CommonPosition; dayGroup: DayGroup; groupIndex: number }]>;
customThumbnailLayout?: Snippet<[TimelineAsset]>;
singleSelect: boolean;
withStacked: boolean;
showArchiveIcon: boolean;
assetInteraction: AssetInteraction;
monthGroup: MonthGroup;
timelineManager: TimelineManager;
assetInteraction: AssetInteraction;
customLayout?: Snippet<[TimelineAsset]>;
onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
onSelectAssets: (asset: TimelineAsset) => void;
onSelectAssetCandidates: (asset: TimelineAsset | null) => void;
onScrollCompensation: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
onThumbnailClick?: (
asset: TimelineAsset,
timelineManager: TimelineManager,
dayGroup: DayGroup,
onClick: (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => void,
) => void;
onDayGroupSelect: (daygroup: DayGroup, assets: TimelineAsset[]) => void;
}
let {
isSelectionMode,
thumbnail: thumbnailWithGroup,
customThumbnailLayout,
singleSelect,
withStacked,
showArchiveIcon,
monthGroup = $bindable(),
assetInteraction,
monthGroup,
timelineManager,
customLayout,
onSelect,
onSelectAssets,
onSelectAssetCandidates,
onScrollCompensation,
onThumbnailClick,
onDayGroupSelect,
}: Props = $props();
let isMouseOverGroup = $state(false);
let hoveredDayGroup = $state();
const transitionDuration = $derived.by(() =>
monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150,
);
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const _onClick = (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, assets, groupTitle);
return;
}
void navigate({ targetRoute: 'current', assetId: asset.id });
};
const handleSelectGroup = (title: string, assets: TimelineAsset[]) => onSelect({ title, assets });
const assetSelectHandler = (
timelineManager: TimelineManager,
asset: TimelineAsset,
assetsInDayGroup: TimelineAsset[],
groupTitle: string,
) => {
onSelectAssets(asset);
// Check if all assets are selected in a group to toggle the group selection's icon
let selectedAssetsInGroupCount = assetsInDayGroup.filter((asset) =>
assetInteraction.hasSelectedAsset(asset.id),
).length;
// if all assets are selected in a group, add the group to selected group
if (selectedAssetsInGroupCount == assetsInDayGroup.length) {
assetInteraction.addGroupToMultiselectGroup(groupTitle);
} else {
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
}
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
isSelectingAllAssets.set(true);
} else {
isSelectingAllAssets.set(false);
}
};
const assetMouseEventHandler = (groupTitle: string, asset: TimelineAsset | null) => {
// Show multi select icon on hover on date group
hoveredDayGroup = groupTitle;
if (assetInteraction.selectionActive) {
onSelectAssetCandidates(asset);
}
};
function filterIntersecting<R extends { intersecting: boolean }>(intersectable: R[]) {
return intersectable.filter((int) => int.intersecting);
function filterIntersecting<R extends { intersecting: boolean }>(intersectables: R[]) {
return intersectables.filter((intersectable) => intersectable.intersecting);
}
$effect.root(() => {
if (timelineManager.scrollCompensation.monthGroup === monthGroup) {
onScrollCompensation(timelineManager.scrollCompensation);
timelineManager.clearScrollCompensation();
}
});
</script>
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
{@const absoluteWidth = dayGroup.left}
{@const isDayGroupSelected = assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<section
class={[
@@ -146,11 +55,11 @@
style:transform={`translate3d(${absoluteWidth}px,${dayGroup.top}px,0)`}
onmouseenter={() => {
isMouseOverGroup = true;
assetMouseEventHandler(dayGroup.groupTitle, null);
hoveredDayGroup = dayGroup.groupTitle;
}}
onmouseleave={() => {
isMouseOverGroup = false;
assetMouseEventHandler(dayGroup.groupTitle, null);
hoveredDayGroup = null;
}}
>
<!-- Date group title -->
@@ -163,10 +72,10 @@
class="hover:cursor-pointer transition-all duration-200 ease-out overflow-hidden w-0"
class:w-8={(hoveredDayGroup === dayGroup.groupTitle && isMouseOverGroup) ||
assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
onclick={() => handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))}
onkeydown={() => handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))}
onclick={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))}
onkeydown={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))}
>
{#if assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
{#if isDayGroupSelected}
<Icon icon={mdiCheckCircle} size="24" class="text-primary" />
{:else}
<Icon icon={mdiCircleOutline} size="24" color="#757575" />
@@ -179,57 +88,17 @@
</span>
</div>
<!-- Image grid -->
<div
data-image-grid
class="relative overflow-clip"
style:height={dayGroup.height + 'px'}
style:width={dayGroup.width + 'px'}
<AssetLayout
photostreamManager={timelineManager}
viewerAssets={dayGroup.viewerAssets}
height={dayGroup.height}
width={dayGroup.width}
{customThumbnailLayout}
>
{#each filterIntersecting(dayGroup.viewerAssets) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!}
<!-- {#if viewerAsset.intersecting} -->
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
<div
data-asset-id={asset.id}
class="absolute"
style:top={position.top + 'px'}
style:left={position.left + 'px'}
style:width={position.width + 'px'}
style:height={position.height + 'px'}
out:scale|global={{ start: 0.1, duration: scaleDuration }}
animate:flip={{ duration: transitionDuration }}
>
<Thumbnail
showStackedIcon={withStacked}
{showArchiveIcon}
{asset}
{groupIndex}
onClick={(asset) => {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
} else {
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
}
}}
onSelect={(asset) => assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle)}
onMouseEvent={() => assetMouseEventHandler(dayGroup.groupTitle, assetSnapshot(asset))}
selected={assetInteraction.hasSelectedAsset(asset.id) ||
dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
disabled={dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)}
thumbnailWidth={position.width}
thumbnailHeight={position.height}
/>
{#if customLayout}
{@render customLayout(asset)}
{/if}
</div>
<!-- {/if} -->
{/each}
</div>
{#snippet thumbnail({ asset, position })}
{@render thumbnailWithGroup({ asset, position, dayGroup, groupIndex })}
{/snippet}
</AssetLayout>
</section>
{/each}
@@ -237,7 +106,4 @@
section {
contain: layout paint style;
}
[data-image-grid] {
user-select: none;
}
</style>

View File

@@ -0,0 +1,274 @@
<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;
},
]
>;
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();
};
export const scrollToAssetId = async (assetId: string) => {
const monthGroup = await timelineManager.findSegmentForAssetId(assetId);
if (!monthGroup) {
return false;
}
const height = monthGroup.findAssetAbsolutePosition(assetId);
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.layoutOptions.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,
})}
{/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>

View File

@@ -0,0 +1,197 @@
<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;
},
]
>;
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>

View File

@@ -0,0 +1,62 @@
<script lang="ts">
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { Snippet } from 'svelte';
interface Props {
content: Snippet<
[
{
onDayGroupSelect: (dayGroup: DayGroup, asset: TimelineAsset[]) => void;
onDayGroupAssetSelect: (dayGroup: DayGroup, asset: TimelineAsset) => void;
},
]
>;
onAssetSelect: (asset: TimelineAsset) => void;
assetInteraction: AssetInteraction;
}
let { content, assetInteraction, onAssetSelect }: Props = $props();
// called when clicking asset with shift key pressed or with mouse
const onDayGroupAssetSelect = (dayGroup: DayGroup, asset: TimelineAsset) => {
onAssetSelect(asset);
const assetsInDayGroup = dayGroup.getAssets();
const groupTitle = dayGroup.groupTitle;
// Check if all assets are selected in a group to toggle the group selection's icon
const selectedAssetsInGroupCount = assetsInDayGroup.filter((asset) =>
assetInteraction.hasSelectedAsset(asset.id),
).length;
// if all assets are selected in a group, add the group to selected group
if (selectedAssetsInGroupCount == assetsInDayGroup.length) {
assetInteraction.addGroupToMultiselectGroup(groupTitle);
} else {
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
}
};
const onDayGroupSelect = (dayGroup: DayGroup, assets: TimelineAsset[]) => {
const group = dayGroup.groupTitle;
if (assetInteraction.selectedGroup.has(group)) {
assetInteraction.removeGroupFromMultiselectGroup(group);
for (const asset of assets) {
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
}
} else {
assetInteraction.addGroupToMultiselectGroup(group);
for (const asset of assets) {
onAssetSelect(asset);
}
}
};
</script>
{@render content({
onDayGroupSelect,
onDayGroupAssetSelect,
})}

View File

@@ -0,0 +1,190 @@
<script lang="ts">
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { navigate } from '$lib/utils/navigation';
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import { searchStore } from '$lib/stores/search.svelte';
import type { Snippet } from 'svelte';
interface Props {
content: Snippet<
[
{
onAssetOpen: (asset: TimelineAsset) => void;
onAssetSelect: (asset: TimelineAsset) => void;
onAssetHover: (asset: TimelineAsset | null) => void;
},
]
>;
isSelectionMode: boolean;
singleSelect: boolean;
timelineManager: PhotostreamManager;
assetInteraction: AssetInteraction;
onAssetOpen?: (asset: TimelineAsset, defaultAssetOpen: () => void) => void;
onAssetSelect?: (asset: TimelineAsset) => void;
}
let { content, isSelectionMode, singleSelect, assetInteraction, timelineManager, onAssetOpen, onAssetSelect }: Props =
$props();
let shiftKeyIsDown = $state(false);
let isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
let lastMouseHoverAsset: TimelineAsset | null = $state(null);
$effect(() => {
if (shiftKeyIsDown && lastMouseHoverAsset) {
void selectAssetCandidates(lastMouseHoverAsset);
}
if (isEmpty) {
assetInteraction.clearMultiselect();
}
});
const defaultAssetOpen = (asset: TimelineAsset) => {
if (isSelectionMode || assetInteraction.selectionActive) {
handleAssetSelect(asset);
return;
}
void navigate({ targetRoute: 'current', assetId: asset.id });
};
const handleOnAssetOpen = (asset: TimelineAsset) => {
if (onAssetOpen) {
onAssetOpen(asset, () => defaultAssetOpen(asset));
return;
}
defaultAssetOpen(asset);
};
// called when clicking asset with shift key pressed or with mouse
const handleAssetSelect = (asset: TimelineAsset) => {
handleSelectAssets(asset);
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
isSelectingAllAssets.set(true);
} else {
isSelectingAllAssets.set(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (searchStore.isSearchEnabled) {
return;
}
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = true;
assetInteraction.clearAssetSelectionCandidates();
if (lastMouseHoverAsset) {
void selectAssetCandidates(lastMouseHoverAsset);
return;
}
if (!assetInteraction.assetSelectionStart) {
assetInteraction.setAssetSelectionStart(assetInteraction.selectedAssets.at(-1) ?? null);
}
}
};
const onKeyUp = (event: KeyboardEvent) => {
if (searchStore.isSearchEnabled) {
return;
}
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = false;
assetInteraction.clearAssetSelectionCandidates();
}
};
const handleOnHover = (asset: TimelineAsset | null) => {
if (asset) {
if (assetInteraction.selectionActive) {
void selectAssetCandidates(asset);
}
lastMouseHoverAsset = asset;
}
};
const handleSelectAssets = (asset: TimelineAsset) => {
if (!asset) {
return;
}
onAssetSelect?.(asset);
if (singleSelect) {
return;
}
const rangeSelection = assetInteraction.assetSelectionCandidates.length > 0;
const deselect = assetInteraction.hasSelectedAsset(asset.id);
// Select/deselect already loaded assets
if (deselect) {
for (const candidate of assetInteraction.assetSelectionCandidates) {
assetInteraction.removeAssetFromMultiselectGroup(candidate.id);
}
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
for (const candidate of assetInteraction.assetSelectionCandidates) {
handleSelectAsset(candidate);
}
handleSelectAsset(asset);
}
assetInteraction.clearAssetSelectionCandidates();
if (assetInteraction.assetSelectionStart && rangeSelection) {
const assets = timelineManager.retrieveLoadedRange(assetInteraction.assetSelectionStart, asset);
for (const asset of assets) {
if (deselect) {
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
handleSelectAsset(asset);
}
}
}
assetInteraction.setAssetSelectionStart(deselect ? null : asset);
return null;
};
const handleSelectAsset = (asset: TimelineAsset) => {
if ('albumAssets' in timelineManager) {
const tm = timelineManager as TimelineManager;
if (tm.albumAssets.has(asset.id)) {
return;
}
}
assetInteraction.selectAsset(asset);
};
const selectAssetCandidates = (endAsset: TimelineAsset) => {
if (!shiftKeyIsDown) {
return;
}
const startAsset = assetInteraction.assetSelectionStart;
if (!startAsset) {
return;
}
const assets = assetsSnapshot(timelineManager.retrieveLoadedRange(startAsset, endAsset));
assetInteraction.setAssetSelectionCandidates(assets);
};
</script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
{@render content({
onAssetOpen: handleOnAssetOpen,
onAssetSelect: (asset) => {
void handleSelectAssets(asset);
},
onAssetHover: handleOnHover,
})}

View File

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

View File

@@ -1,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 { DayGroup } from '$lib/managers/timeline-manager/day-group.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 { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
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;
@@ -53,22 +38,12 @@
album?: AlbumResponseDto | null;
person?: PersonResponseDto | null;
isShowDeleteConfirmation?: boolean;
onSelect?: (asset: TimelineAsset) => void;
onAssetOpen?: (asset: TimelineAsset, defaultAssetOpen: () => void) => void;
onAssetSelect?: (asset: TimelineAsset) => void;
onEscape?: () => void;
children?: Snippet;
empty?: Snippet;
customLayout?: Snippet<[TimelineAsset]>;
onThumbnailClick?: (
asset: TimelineAsset,
timelineManager: TimelineManager,
dayGroup: DayGroup,
onClick: (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => void,
) => void;
customThumbnailLayout?: Snippet<[TimelineAsset]>;
}
let {
@@ -84,627 +59,97 @@
album = null,
person = null,
isShowDeleteConfirmation = $bindable(false),
onSelect = () => {},
onAssetSelect,
onAssetOpen,
onEscape = () => {},
children,
empty,
customLayout,
onThumbnailClick,
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 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);
while (timelineManager.scrollCompensation.monthGroup) {
handleScrollCompensation(timelineManager.scrollCompensation);
timelineManager.clearScrollCompensation();
}
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 handleScrollCompensation = ({ heightDelta, scrollTop }: { heightDelta?: number; scrollTop?: number }) => {
if (heightDelta !== undefined) {
scrollBy(heightDelta);
} else if (scrollTop !== undefined) {
scrollTo(scrollTop);
}
// Yes, updateSlideWindow() is called by the onScroll event triggered as a result of
// the above calls. However, this delay is 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.
updateSlidingWindow();
};
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;
}
}
};
const handleSelectAsset = (asset: TimelineAsset) => {
if (!timelineManager.albumAssets.has(asset.id)) {
assetInteraction.selectAsset(asset);
}
};
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
let shiftKeyIsDown = $state(false);
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = true;
}
};
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = false;
}
};
const handleSelectAssetCandidates = (asset: TimelineAsset | null) => {
if (asset) {
void selectAssetCandidates(asset);
}
lastAssetMouseEvent = asset;
};
const handleGroupSelect = (timelineManager: TimelineManager, group: string, assets: TimelineAsset[]) => {
if (assetInteraction.selectedGroup.has(group)) {
assetInteraction.removeGroupFromMultiselectGroup(group);
for (const asset of assets) {
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
}
} else {
assetInteraction.addGroupToMultiselectGroup(group);
for (const asset of assets) {
handleSelectAsset(asset);
}
}
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
isSelectingAllAssets.set(true);
} else {
isSelectingAllAssets.set(false);
}
};
const handleSelectAssets = async (asset: TimelineAsset) => {
if (!asset) {
return;
}
onSelect(asset);
if (singleSelect) {
scrollTop(0);
return;
}
const rangeSelection = assetInteraction.assetSelectionCandidates.length > 0;
const deselect = assetInteraction.hasSelectedAsset(asset.id);
// Select/deselect already loaded assets
if (deselect) {
for (const candidate of assetInteraction.assetSelectionCandidates) {
assetInteraction.removeAssetFromMultiselectGroup(candidate.id);
}
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
for (const candidate of assetInteraction.assetSelectionCandidates) {
handleSelectAsset(candidate);
}
handleSelectAsset(asset);
}
assetInteraction.clearAssetSelectionCandidates();
if (assetInteraction.assetSelectionStart && rangeSelection) {
let startBucket = timelineManager.getMonthGroupByAssetId(assetInteraction.assetSelectionStart.id);
let endBucket = timelineManager.getMonthGroupByAssetId(asset.id);
if (startBucket === null || endBucket === null) {
return;
}
// Select/deselect assets in range (start,end)
let started = false;
for (const monthGroup of timelineManager.months) {
if (monthGroup === endBucket) {
break;
}
if (started) {
await timelineManager.loadSegment(monthGroup.identifier);
for (const asset of monthGroup.assetsIterator()) {
if (deselect) {
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
handleSelectAsset(asset);
}
}
}
if (monthGroup === startBucket) {
started = true;
}
}
// Update date group selection in range [start,end]
started = false;
for (const monthGroup of timelineManager.months) {
if (monthGroup === startBucket) {
started = true;
}
if (started) {
// Split month group into day groups and check each group
for (const dayGroup of monthGroup.dayGroups) {
const dayGroupTitle = dayGroup.groupTitle;
if (dayGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
assetInteraction.addGroupToMultiselectGroup(dayGroupTitle);
} else {
assetInteraction.removeGroupFromMultiselectGroup(dayGroupTitle);
}
}
}
if (monthGroup === endBucket) {
break;
}
}
}
assetInteraction.setAssetSelectionStart(deselect ? null : asset);
};
const selectAssetCandidates = async (endAsset: TimelineAsset) => {
if (!shiftKeyIsDown) {
return;
}
const startAsset = assetInteraction.assetSelectionStart;
if (!startAsset) {
return;
}
const assets = assetsSnapshot(await timelineManager.retrieveRange(startAsset, endAsset));
assetInteraction.setAssetSelectionCandidates(assets);
};
$effect(() => {
if (!lastAssetMouseEvent) {
assetInteraction.clearAssetSelectionCandidates();
}
});
$effect(() => {
if (!shiftKeyIsDown) {
assetInteraction.clearAssetSelectionCandidates();
}
});
$effect(() => {
if (shiftKeyIsDown && lastAssetMouseEvent) {
void selectAssetCandidates(lastAssetMouseEvent);
}
});
$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>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
<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
onScrubKeyDown={(evt) => {
evt.preventDefault();
let amount = 50;
if (shiftKeyIsDown) {
amount = 500;
}
if (evt.key === 'ArrowUp') {
amount = -amount;
if (shiftKeyIsDown) {
element?.scrollBy({ top: amount, behavior: 'smooth' });
}
} else if (evt.key === 'ArrowDown') {
element?.scrollBy({ top: amount, behavior: 'smooth' });
}
}}
{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%"
>
<MonthSegment
{withStacked}
{showArchiveIcon}
{assetInteraction}
{timelineManager}
{isSelectionMode}
{singleSelect}
{monthGroup}
onSelect={({ title, assets }) => handleGroupSelect(timelineManager, title, assets)}
onSelectAssetCandidates={handleSelectAssetCandidates}
onSelectAssets={handleSelectAssets}
onScrollCompensation={handleScrollCompensation}
{customLayout}
{onThumbnailClick}
/>
</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>
{#snippet skeleton({ segment })}
<Skeleton
height={segment.height - segment.timelineManager.headerHeight}
title={(segment as MonthGroup).monthGroupTitle}
/>
{/snippet}
{#snippet segment({ segment })}
<SelectableSegment
{timelineManager}
{assetInteraction}
{isSelectionMode}
{singleSelect}
{onAssetOpen}
{onAssetSelect}
>
{#snippet content({ onAssetOpen, onAssetSelect, onAssetHover })}
<SelectableDay {assetInteraction} {onAssetSelect}>
{#snippet content({ onDayGroupSelect, onDayGroupAssetSelect })}
<MonthSegment
{assetInteraction}
{customThumbnailLayout}
{singleSelect}
monthGroup={segment as MonthGroup}
{timelineManager}
{onDayGroupSelect}
>
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
{@const isAssetSelected =
assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)}
{@const isAssetDisabled = timelineManager.albumAssets.has(asset.id)}
<Thumbnail
showStackedIcon={withStacked}
{showArchiveIcon}
{asset}
{groupIndex}
onClick={() => onAssetOpen(asset)}
onSelect={() => onDayGroupAssetSelect(dayGroup, asset)}
onMouseEvent={(isMouseOver) => {
if (isMouseOver) {
onAssetHover(asset);
} else {
onAssetHover(null);
}
}}
selected={isAssetSelected}
selectionCandidate={isAssetSelectionCandidate}
disabled={isAssetDisabled}
thumbnailWidth={position.width}
thumbnailHeight={position.height}
/>
{/snippet}
</MonthSegment>
{/snippet}
</SelectableDay>
{/snippet}
</SelectableSegment>
{/snippet}
</PhotostreamWithScrubber>
</StreamWithViewer>

View File

@@ -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]);
@@ -111,12 +105,12 @@
case AssetAction.UNARCHIVE:
case AssetAction.FAVORITE:
case AssetAction.UNFAVORITE: {
timelineManager.updateAssets([action.asset]);
timelineManager.upsertAssets([action.asset]);
break;
}
case AssetAction.ADD: {
timelineManager.addAssets([action.asset]);
timelineManager.upsertAssets([action.asset]);
break;
}
@@ -125,7 +119,7 @@
break;
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
timelineManager.addAssets([toTimelineAsset(action.asset)]);
timelineManager.upsertAssets([toTimelineAsset(action.asset)]);
if (action.stack) {
//Have to unstack then restack assets in timeline in order to update the stack count in the timeline.
updateUnstackedAssetInTimeline(
@@ -172,6 +166,6 @@
onPrevious={handlePrevious}
onNext={handleNext}
onRandom={handleRandom}
onClose={handleClose}
onClose={onViewerClose}
/>
{/await}

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
import type { OnArchive } from '$lib/utils/actions';
import { archiveAssets } from '$lib/utils/asset-utils';
import { AssetVisibility } from '@immich/sdk';
@@ -12,9 +13,11 @@
onArchive?: OnArchive;
menuItem?: boolean;
unarchive?: boolean;
manager?: PhotostreamManager;
removeOnArchive?: boolean;
}
let { onArchive, menuItem = false, unarchive = false }: 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);
@@ -24,12 +27,18 @@
const { clearSelect, getOwnedAssets } = getAssetControlContext();
const handleArchive = async () => {
const isArchived = unarchive ? AssetVisibility.Timeline : AssetVisibility.Archive;
const assets = [...getOwnedAssets()].filter((asset) => asset.visibility !== isArchived);
const visibility = unarchive ? AssetVisibility.Timeline : AssetVisibility.Archive;
const assets = [...getOwnedAssets()].filter((asset) => asset.visibility !== visibility);
loading = true;
const ids = await archiveAssets(assets, isArchived as AssetVisibility);
const ids = await archiveAssets(assets, visibility as AssetVisibility);
if (ids) {
onArchive?.(ids, isArchived ? AssetVisibility.Archive : AssetVisibility.Timeline);
manager?.updateAssetOperation(ids, (asset) => {
asset.visibility = visibility;
return {
remove: removeOnArchive,
};
});
onArchive?.(ids, visibility ? AssetVisibility.Archive : AssetVisibility.Timeline);
clearSelect();
}
loading = false;

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { featureFlags } from '$lib/stores/server-config.store';
import { type OnDelete, type OnUndoDelete, deleteAssets } from '$lib/utils/actions';
import { IconButton } from '@immich/ui';
@@ -9,13 +11,14 @@
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
interface Props {
onAssetDelete: OnDelete;
onUndoDelete?: OnUndoDelete | undefined;
onAssetDelete?: OnDelete;
onUndoDelete?: OnUndoDelete;
menuItem?: boolean;
force?: boolean;
manager?: PhotostreamManager;
}
let { onAssetDelete, onUndoDelete = undefined, menuItem = false, force = !$featureFlags.trash }: Props = $props();
let { onAssetDelete, onUndoDelete, menuItem = false, force = !$featureFlags.trash, manager }: Props = $props();
const { clearSelect, getOwnedAssets } = getAssetControlContext();
@@ -36,7 +39,14 @@
const handleDelete = async () => {
loading = true;
const assets = [...getOwnedAssets()];
await deleteAssets(force, onAssetDelete, assets, onUndoDelete);
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;
loading = false;

View File

@@ -5,6 +5,7 @@
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
import type { OnFavorite } from '$lib/utils/actions';
import { handleError } from '$lib/utils/handle-error';
import { updateAssets } from '@immich/sdk';
@@ -16,9 +17,10 @@
onFavorite?: OnFavorite;
menuItem?: boolean;
removeFavorite: boolean;
manager?: PhotostreamManager;
}
let { onFavorite, menuItem = false, removeFavorite }: Props = $props();
let { onFavorite, menuItem = false, removeFavorite, manager }: Props = $props();
let text = $derived(removeFavorite ? $t('remove_from_favorites') : $t('to_favorite'));
let icon = $derived(removeFavorite ? mdiHeartMinusOutline : mdiHeartOutline);
@@ -39,11 +41,7 @@
if (ids.length > 0) {
await updateAssets({ assetBulkUpdateDto: { ids, isFavorite } });
}
for (const asset of assets) {
asset.isFavorite = isFavorite;
}
manager?.updateAssetOperation(ids, (asset) => ((asset.isFavorite = isFavorite), void 0));
onFavorite?.(ids, isFavorite);
notificationController.show({

View File

@@ -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 {
@@ -51,7 +53,7 @@
!(isTrashEnabled && !force),
(assetIds) => timelineManager.removeAssets(assetIds),
assetInteraction.selectedAssets,
!isTrashEnabled || force ? undefined : (assets) => timelineManager.addAssets(assets),
!isTrashEnabled || force ? undefined : (assets) => timelineManager.upsertAssets(assets),
);
assetInteraction.clearMultiselect();
};
@@ -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);
}
}
}}

View File

@@ -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()) {

View File

@@ -1,24 +1,29 @@
<script lang="ts">
interface Props {
height: number;
title: string;
title?: string;
stylePaddingHorizontalPx?: number;
}
let { height = 0, title }: Props = $props();
let { height = 0, title, stylePaddingHorizontalPx = 0 }: Props = $props();
</script>
<div class="overflow-clip" style:height={height + 'px'}>
<skeleton class="overflow-clip" style:height={height + 'px'}>
{#if title}
<div
class="flex pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-light dark:text-immich-dark-fg md:text-sm"
>
{title}
</div>
{/if}
<div
class="flex pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-light dark:text-immich-dark-fg md:text-sm"
>
{title}
</div>
<div
class="animate-pulse absolute h-full ms-[10px] me-[10px]"
style:width="calc(100% - 20px)"
class="animate-pulse absolute h-full"
style:width="100%"
style:padding-left={stylePaddingHorizontalPx + 'px'}
style:padding-right={stylePaddingHorizontalPx + 'px'}
data-skeleton="true"
></div>
</div>
</skeleton>
<style>
[data-skeleton] {

View File

@@ -1,36 +1,47 @@
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
import { CancellableTask } from '$lib/utils/cancellable-task';
import { clamp, debounce } from 'lodash-es';
import { debounce } from 'lodash-es';
import type {
import {
PhotostreamSegment,
SegmentIdentifier,
type SegmentIdentifier,
} from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
import { updateObject } from '$lib/managers/timeline-manager/internal/utils.svelte';
import type {
AssetDescriptor,
AssetOperation,
MoveAsset,
TimelineAsset,
TimelineManagerLayoutOptions,
Viewport,
} from '$lib/managers/timeline-manager/types';
import { setDifference } from '$lib/utils/timeline-util';
export abstract class PhotostreamManager {
isInitialized = $state(false);
topSectionHeight = $state(0);
bottomSectionHeight = $state(60);
abstract get months(): PhotostreamSegment[];
assetCount = $derived.by(() => this.months.reduce((accumulator, b) => accumulator + b.assetsCount, 0));
timelineHeight = $derived.by(
() => this.months.reduce((accumulator, b) => accumulator + b.height, 0) + this.topSectionHeight,
);
assetCount = $derived.by(() => this.months.reduce((accumulator, b) => accumulator + b.assetsCount, 0));
topIntersectingMonthGroup: PhotostreamSegment | undefined = $state();
visibleWindow = $derived.by(() => ({
top: this.#scrollTop,
bottom: this.#scrollTop + this.viewportHeight,
}));
layoutOptions = $derived({
spacing: 2,
heightTolerance: 0.15,
rowHeight: this.rowHeight,
rowWidth: Math.floor(this.viewportWidth),
});
protected initTask = new CancellableTask(
() => (this.isInitialized = true),
() => (this.isInitialized = false),
@@ -49,26 +60,14 @@ export abstract class PhotostreamManager {
#suspendTransitions = $state(false);
#resetScrolling = debounce(() => (this.#scrolling = false), 1000);
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
scrollCompensation: {
heightDelta: number | undefined;
scrollTop: number | undefined;
monthGroup: PhotostreamSegment | undefined;
} = $state({
heightDelta: 0,
scrollTop: 0,
monthGroup: undefined,
});
#updatingIntersections = false;
constructor() {}
abstract get months(): PhotostreamSegment[];
setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }: TimelineManagerLayoutOptions) {
let changed = false;
changed ||= this.#setHeaderHeight(headerHeight);
changed ||= this.#setGap(gap);
changed ||= this.#setRowHeight(rowHeight);
if (changed) {
this.refreshLayout();
}
this.#setHeaderHeight(headerHeight);
this.#setGap(gap);
this.#setRowHeight(rowHeight);
}
#setHeaderHeight(value: number) {
@@ -158,39 +157,17 @@ export abstract class PhotostreamManager {
}
}
clearScrollCompensation() {
this.scrollCompensation = {
heightDelta: undefined,
scrollTop: undefined,
monthGroup: undefined,
};
}
updateIntersections() {
if (!this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
if (this.#updatingIntersections || !this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
return;
}
let topIntersectingMonthGroup = undefined;
this.#updatingIntersections = true;
for (const month of this.months) {
updateIntersectionMonthGroup(this, month);
if (!topIntersectingMonthGroup && month.actuallyIntersecting) {
topIntersectingMonthGroup = month;
}
}
if (topIntersectingMonthGroup !== undefined && this.topIntersectingMonthGroup !== topIntersectingMonthGroup) {
this.topIntersectingMonthGroup = topIntersectingMonthGroup;
}
for (const month of this.months) {
if (month === this.topIntersectingMonthGroup) {
this.topIntersectingMonthGroup.percent = clamp(
(this.visibleWindow.top - this.topIntersectingMonthGroup.top) / this.topIntersectingMonthGroup.height,
0,
1,
);
} else {
month.percent = 0;
}
}
this.#updatingIntersections = false;
}
async init() {
@@ -234,17 +211,6 @@ export abstract class PhotostreamManager {
this.updateIntersections();
}
createLayoutOptions() {
const viewportWidth = this.viewportWidth;
return {
spacing: 2,
heightTolerance: 0.15,
rowHeight: this.#rowHeight,
rowWidth: Math.floor(viewportWidth),
};
}
async loadSegment(identifier: SegmentIdentifier, options?: { cancelable: boolean }): Promise<void> {
let cancelable = true;
if (options) {
@@ -269,20 +235,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);
}
}
}
refreshLayout() {
for (const month of this.months) {
updateGeometry(this, month, { invalidateHeight: true });
}
this.updateIntersections();
return Promise.resolve(void 0);
}
getMaxScrollPercent() {
@@ -294,11 +254,15 @@ export abstract class PhotostreamManager {
return this.topSectionHeight + this.bottomSectionHeight + (this.timelineHeight - this.viewportHeight);
}
retrieveRange(start: AssetDescriptor, end: AssetDescriptor): Promise<TimelineAsset[]> {
retrieveLoadedRange(start: AssetDescriptor, end: AssetDescriptor): TimelineAsset[] {
const range: TimelineAsset[] = [];
let collecting = false;
for (const month of this.months) {
if (collecting && !month.isLoaded) {
// if there are any unloaded months in the range, return empty []
return [];
}
for (const asset of month.assets) {
if (asset.id === start.id) {
collecting = true;
@@ -307,10 +271,126 @@ export abstract class PhotostreamManager {
range.push(asset);
}
if (asset.id === end.id) {
return Promise.resolve(range);
return range;
}
}
}
return Promise.resolve(range);
return range;
}
retrieveRange(start: AssetDescriptor, end: AssetDescriptor): Promise<TimelineAsset[]> {
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);
}
upsertAssets(assets: TimelineAsset[]) {
const notExcluded = assets.filter((asset) => !this.isExcluded(asset));
const notUpdated = this.#updateAssets(notExcluded);
this.addAssetsToSegments([...notUpdated]);
}
#updateAssets(assets: TimelineAsset[]) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const lookup = new Map<string, TimelineAsset>(assets.map((asset) => [asset.id, asset]));
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const { unprocessedIds } = this.#runAssetOperation(new Set(lookup.keys()), (asset) =>
updateObject(asset, lookup.get(asset.id)),
);
const result: TimelineAsset[] = [];
for (const id of unprocessedIds.values()) {
result.push(lookup.get(id)!);
}
return result;
}
removeAssets(ids: string[]) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const { unprocessedIds } = this.#runAssetOperation(new Set(ids), () => ({ remove: true }));
return [...unprocessedIds];
}
isExcluded(_: TimelineAsset) {
return false;
}
protected createUpsertContext(): unknown {
return;
}
protected abstract upsertAssetIntoSegment(asset: TimelineAsset, context: unknown): void;
protected postCreateSegments(): void {}
protected postUpsert(_: unknown): void {}
protected addAssetsToSegments(assets: TimelineAsset[]) {
if (assets.length === 0) {
return;
}
const context = this.createUpsertContext();
const monthCount = this.months.length;
for (const asset of assets) {
this.upsertAssetIntoSegment(asset, context);
}
if (this.months.length !== monthCount) {
this.postCreateSegments();
}
this.postUpsert(context);
this.updateIntersections();
}
#runAssetOperation(ids: Set<string>, operation: AssetOperation) {
if (ids.size === 0) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
return { processedIds: new Set(), unprocessedIds: ids, changedGeometry: false };
}
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const changedMonthGroups = new Set<PhotostreamSegment>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
let idsToProcess = new Set(ids);
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const idsProcessed = new Set<string>();
const combinedMoveAssets: MoveAsset[][] = [];
for (const month of this.months) {
if (idsToProcess.size > 0) {
const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation);
if (moveAssets.length > 0) {
combinedMoveAssets.push(moveAssets);
}
idsToProcess = setDifference(idsToProcess, processedIds);
for (const id of processedIds) {
idsProcessed.add(id);
}
if (changedGeometry) {
changedMonthGroups.add(month);
}
}
}
if (combinedMoveAssets.length > 0) {
this.addAssetsToSegments(combinedMoveAssets.flat().map((a) => a.asset));
}
const changedGeometry = changedMonthGroups.size > 0;
for (const month of changedMonthGroups) {
updateGeometry(this, month, { invalidateHeight: true });
}
if (changedGeometry) {
this.updateIntersections();
}
return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
}
/**
* Requests that the manager's content be explictly set to the given id ordering.
* A particular Photostream manager may ignore this request.
*/
order(_: string[]) {}
}

View File

@@ -4,7 +4,8 @@ import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { getTestHook } from '$lib/managers/photostream-manager/TestHooks.svelte';
import type { AssetOperation, MoveAsset, TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
export type SegmentIdentifier = {
@@ -20,7 +21,6 @@ export abstract class PhotostreamSegment {
#assets = $derived.by(() => this.viewerAssets.map((viewerAsset) => viewerAsset.asset));
initialCount = $state(0);
percent = $state(0);
assetsCount = $derived.by(() => (this.isLoaded ? this.viewerAssets.length : this.initialCount));
loader = new CancellableTask(
@@ -30,6 +30,10 @@ export abstract class PhotostreamSegment {
);
isHeightActual = $state(false);
constructor() {
getTestHook()?.hookSegment(this);
}
abstract get timelineManager(): PhotostreamManager;
abstract get identifier(): SegmentIdentifier;
@@ -66,13 +70,22 @@ export abstract class PhotostreamSegment {
}
async load(cancelable: boolean): Promise<'DONE' | 'WAITED' | 'CANCELED' | 'LOADED' | 'ERRORED'> {
return await this.loader?.execute(async (signal: AbortSignal) => {
const executionStatus = await this.loader.execute(async (signal: AbortSignal) => {
await this.fetch(signal);
}, cancelable);
if (executionStatus === 'LOADED') {
this.layout();
}
return executionStatus;
}
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;
}
@@ -83,42 +96,34 @@ export abstract class PhotostreamSegment {
if (this.#height === height) {
return;
}
const { timelineManager: store, percent } = this;
const index = store.months.indexOf(this);
let needsIntersectionUpdate = false;
const timelineManager = this.timelineManager;
const index = timelineManager.months.indexOf(this);
const heightDelta = height - this.#height;
this.#height = height;
const prevMonthGroup = store.months[index - 1];
const prevMonthGroup = timelineManager.months[index - 1];
if (prevMonthGroup) {
const newTop = prevMonthGroup.#top + prevMonthGroup.#height;
if (this.#top !== newTop) {
this.#top = newTop;
}
}
for (let cursor = index + 1; cursor < store.months.length; cursor++) {
const monthGroup = this.timelineManager.months[cursor];
if (heightDelta === 0) {
return;
}
for (let cursor = index + 1; cursor < timelineManager.months.length; cursor++) {
const monthGroup = timelineManager.months[cursor];
const newTop = monthGroup.#top + heightDelta;
if (monthGroup.#top !== newTop) {
monthGroup.#top = newTop;
needsIntersectionUpdate = true;
}
}
if (store.topIntersectingMonthGroup) {
const currentIndex = store.months.indexOf(store.topIntersectingMonthGroup);
if (currentIndex > 0) {
if (index < currentIndex) {
store.scrollCompensation = {
heightDelta,
scrollTop: undefined,
monthGroup: this,
};
} else if (percent > 0) {
const top = this.top + height * percent;
store.scrollCompensation = {
heightDelta: undefined,
scrollTop: top,
monthGroup: this,
};
}
}
if (needsIntersectionUpdate) {
timelineManager.updateIntersections();
}
}
@@ -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;
@@ -147,4 +152,49 @@ export abstract class PhotostreamSegment {
}
abstract findAssetAbsolutePosition(assetId: string): number;
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
if (ids.size === 0) {
return {
moveAssets: [] as MoveAsset[],
// eslint-disable-next-line svelte/prefer-svelte-reactivity
processedIds: new Set<string>(),
unprocessedIds: ids,
changedGeometry: false,
};
}
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const unprocessedIds = new Set<string>(ids);
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const processedIds = new Set<string>();
const moveAssets: MoveAsset[] = [];
let changedGeometry = false;
for (const assetId of unprocessedIds) {
const index = this.viewerAssets.findIndex((viewAsset) => viewAsset.id == assetId);
if (index === -1) {
continue;
}
const asset = this.viewerAssets[index].asset!;
const oldTime = { ...asset.localDateTime };
const opResult = operation(asset);
let remove = false;
if (opResult) {
remove = (opResult as { remove: boolean }).remove ?? false;
}
const newTime = asset.localDateTime;
if (oldTime.year !== newTime.year || oldTime.month !== newTime.month || oldTime.day !== newTime.day) {
const { year, month, day } = newTime;
remove = true;
moveAssets.push({ asset, date: { year, month, day } });
}
unprocessedIds.delete(assetId);
processedIds.add(assetId);
if (remove || this.timelineManager.isExcluded(asset)) {
this.viewerAssets.splice(index, 1);
changedGeometry = true;
}
}
return { moveAssets, processedIds, unprocessedIds, changedGeometry };
}
}

View File

@@ -0,0 +1,11 @@
import type { PhotostreamSegment } from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
let testHooks: { hookSegment: (segment: PhotostreamSegment) => void } | undefined = undefined;
export function setTestHook(hooks: { hookSegment: (segment: PhotostreamSegment) => void }) {
testHooks = hooks;
}
export function getTestHook() {
return testHooks;
}

View File

@@ -0,0 +1,119 @@
import { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
import { SearchResultsSegment } from '$lib/managers/searchresults-manager/SearchResultsSegment.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
export type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query' | 'queryAssetId'> & { isVisible: boolean };
export type Options = {
searchTerms: SearchTerms;
isSmartSearchEnabled: boolean;
};
export class SearchResultsManager extends PhotostreamManager {
#options: Options = {
searchTerms: {
isVisible: false,
},
isSmartSearchEnabled: false,
};
#months: SearchResultsSegment[] = $state([]);
get options() {
return this.#options;
}
async updateOptions(options: Options) {
if (isEqual(this.#options, options)) {
return;
}
await this.initTask.reset();
this.#options = options;
await this.init();
this.updateViewportGeometry(false);
}
async init() {
this.isInitialized = false;
await this.initTask.execute(() => {
this.#months.splice(0);
this.#months.push(new SearchResultsSegment(this, '1', { ...this.#options.searchTerms }));
return Promise.resolve();
}, true);
this.updateViewportGeometry(false);
}
get months(): SearchResultsSegment[] {
return this.#months;
}
findNextAsset(currentAssetId: string): { id: string } | undefined {
for (let segmentIndex = 0; segmentIndex < this.#months.length; segmentIndex++) {
const segment = this.#months[segmentIndex];
const assetIndex = segment.assets.findIndex((asset) => asset.id === currentAssetId);
if (assetIndex !== -1) {
// Found the current asset
if (assetIndex < segment.assets.length - 1) {
// Next asset is in the same segment
return segment.assets[assetIndex + 1];
} else if (segmentIndex < this.#months.length - 1) {
// Next asset is in the next segment
const nextSegment = this.#months[segmentIndex + 1];
if (nextSegment.assets.length > 0) {
return nextSegment.assets[0];
}
}
break;
}
}
return undefined;
}
findPreviousAsset(currentAssetId: string): { id: string } | undefined {
for (let segmentIndex = 0; segmentIndex < this.#months.length; segmentIndex++) {
const segment = this.#months[segmentIndex];
const assetIndex = segment.assets.findIndex((asset) => asset.id === currentAssetId);
if (assetIndex !== -1) {
// Found the current asset
if (assetIndex > 0) {
// Previous asset is in the same segment
return segment.assets[assetIndex - 1];
} else if (segmentIndex > 0) {
// Previous asset is in the previous segment
const previousSegment = this.#months[segmentIndex - 1];
if (previousSegment.assets.length > 0) {
return previousSegment.assets.at(-1);
}
}
break;
}
}
return undefined;
}
findRandomAsset(): { id: string } | undefined {
// Get all loaded assets across all segments
const allAssets = this.#months.flatMap((segment) => segment.assets);
if (allAssets.length === 0) {
return undefined;
}
// Return a random asset
const randomIndex = Math.floor(Math.random() * allAssets.length);
return allAssets[randomIndex];
}
protected upsertAssetIntoSegment(asset: TimelineAsset): void {
this.#months.at(-1)?.addTimelineAsset(asset);
}
order(ids: string[]) {
this.#months.at(-1)?.order(ids);
}
}

View File

@@ -0,0 +1,139 @@
import {
PhotostreamSegment,
type SegmentIdentifier,
} from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
import type {
SearchResultsManager,
SearchTerms,
} from '$lib/managers/searchresults-manager/SearchResultsManager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
import { getJustifiedLayoutFromAssets, getPosition } from '$lib/utils/layout-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { TUNABLES } from '$lib/utils/tunables';
import { searchAssets, searchSmart } from '@immich/sdk';
import { isEqual } from 'lodash-es';
const {
TIMELINE: { INTERSECTION_EXPAND_BOTTOM },
} = TUNABLES;
export class SearchResultsSegment extends PhotostreamSegment {
#manager: SearchResultsManager;
#identifier: SegmentIdentifier;
#id: string;
#searchTerms: SearchTerms;
#currentPage: string | null = null;
#nextPage: string | null = null;
#viewerAssets: ViewerAsset[] = $state([]);
constructor(manager: SearchResultsManager, currentPage: string, searchTerms: SearchTerms) {
super();
this.#currentPage = currentPage;
this.#manager = manager;
this.#searchTerms = searchTerms;
this.#id = JSON.stringify(searchTerms);
this.#identifier = {
matches(segment: SearchResultsSegment) {
return isEqual(segment.#searchTerms, searchTerms);
},
};
this.initialCount = searchTerms.size || 100;
}
get timelineManager(): SearchResultsManager {
return this.#manager;
}
get identifier(): SegmentIdentifier {
return this.#identifier;
}
get id(): string {
return this.#id;
}
async fetch(): Promise<void> {
if (this.#currentPage === null) {
return;
}
const searchDto: SearchTerms = {
...this.#searchTerms,
withExif: true,
isVisible: true,
page: +this.#currentPage,
};
const { assets } =
('query' in searchDto || 'queryAssetId' in searchDto) && this.#manager.options.isSmartSearchEnabled
? await searchSmart({ smartSearchDto: searchDto })
: await searchAssets({ metadataSearchDto: searchDto });
this.#nextPage = assets.nextPage;
this.#viewerAssets.push(...assets.items.map((asset) => new ViewerAsset(this, toTimelineAsset(asset))));
this.layout();
}
layout(): void {
const timelineAssets = this.#viewerAssets.map((viewerAsset) => viewerAsset.asset);
const layoutOptions = this.timelineManager.layoutOptions;
const geometry = getJustifiedLayoutFromAssets(timelineAssets, layoutOptions);
this.height = timelineAssets.length === 0 ? 0 : geometry.containerHeight + this.timelineManager.headerHeight;
for (let i = 0; i < this.#viewerAssets.length; i++) {
const position = getPosition(geometry, i);
this.#viewerAssets[i].position = position;
}
}
get viewerAssets(): ViewerAsset[] {
return this.#viewerAssets;
}
findAssetAbsolutePosition(assetId: string) {
const viewerAsset = this.#viewerAssets.find((viewAsset) => viewAsset.id === assetId);
if (viewerAsset) {
if (!viewerAsset.position) {
console.warn('No position for asset');
return -1;
}
return this.top + viewerAsset.position.top + this.timelineManager.headerHeight;
}
return -1;
}
loadNextPage() {
if (this.#nextPage !== null) {
if (this.#currentPage === this.#nextPage) {
// there's an active load
return;
}
this.#currentPage = this.#nextPage;
void this.reload(false);
}
}
updateIntersection({ intersecting, actuallyIntersecting }: { intersecting: boolean; actuallyIntersecting: boolean }) {
super.updateIntersection({ intersecting, actuallyIntersecting });
const loadNextPage =
this.timelineManager.timelineHeight - this.timelineManager.visibleWindow.bottom < INTERSECTION_EXPAND_BOTTOM;
if (loadNextPage) {
this.loadNextPage();
}
}
addTimelineAsset(asset: TimelineAsset) {
this.#viewerAssets.push(new ViewerAsset(this, asset));
this.layout();
}
order(ids: string[]) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const idMap = new Map(ids.map((id, index) => [id, index]));
this.#viewerAssets.sort((a, b) => {
const indexA = idMap.get(a.id) ?? Number.MAX_SAFE_INTEGER;
const indexB = idMap.get(b.id) ?? Number.MAX_SAFE_INTEGER;
return indexA - indexB;
});
this.layout();
}
}

View File

@@ -129,7 +129,11 @@ export class DayGroup {
const asset = this.viewerAssets[index].asset!;
const oldTime = { ...asset.localDateTime };
let { remove } = operation(asset);
const opResult = operation(asset);
let remove = false;
if (opResult) {
remove = (opResult as { remove: boolean }).remove ?? false;
}
const newTime = asset.localDateTime;
if (oldTime.year !== newTime.year || oldTime.month !== newTime.month || oldTime.day !== newTime.day) {
const { year, month, day } = newTime;

View File

@@ -51,7 +51,7 @@ export function calculateSegmentIntersecting(
}
/**
* Calculate intersection for viewer assets with additional parameters like header height and scroll compensation
* Calculate intersection for viewer assets with additional parameters like header height
*/
export function calculateViewerAssetIntersecting(
timelineManager: PhotostreamManager,
@@ -60,13 +60,8 @@ export function calculateViewerAssetIntersecting(
expandTop: number = INTERSECTION_EXPAND_TOP,
expandBottom: number = INTERSECTION_EXPAND_BOTTOM,
) {
const scrollCompensationHeightDelta = timelineManager.scrollCompensation?.heightDelta ?? 0;
const topWindow =
timelineManager.visibleWindow.top - timelineManager.headerHeight - expandTop + scrollCompensationHeightDelta;
const bottomWindow =
timelineManager.visibleWindow.bottom + timelineManager.headerHeight + expandBottom + scrollCompensationHeightDelta;
const topWindow = timelineManager.visibleWindow.top - timelineManager.headerHeight - expandTop;
const bottomWindow = timelineManager.visibleWindow.bottom + timelineManager.headerHeight + expandBottom;
const positionBottom = positionTop + positionHeight;
return isIntersecting(positionTop, positionBottom, topWindow, bottomWindow);

View File

@@ -34,7 +34,7 @@ export function layoutMonthGroup(timelineManager: TimelineManager, month: MonthG
let dayGroupRow = 0;
let dayGroupCol = 0;
const options = timelineManager.createLayoutOptions();
const options = timelineManager.layoutOptions;
for (const dayGroup of month.dayGroups) {
dayGroup.layout(options, noDefer);

View File

@@ -1,103 +0,0 @@
import { setDifference, type TimelineDate } from '$lib/utils/timeline-util';
import { AssetOrder } from '@immich/sdk';
import { SvelteSet } from 'svelte/reactivity';
import { GroupInsertionCache } from '../group-insertion-cache.svelte';
import { MonthGroup } from '../month-group.svelte';
import type { TimelineManager } from '../timeline-manager.svelte';
import type { AssetOperation, TimelineAsset } from '../types';
import { updateGeometry } from './layout-support.svelte';
import { getMonthGroupByDate } from './search-support.svelte';
export function addAssetsToMonthGroups(
timelineManager: TimelineManager,
assets: TimelineAsset[],
options: { order: AssetOrder },
) {
if (assets.length === 0) {
return;
}
const addContext = new GroupInsertionCache();
const updatedMonthGroups = new SvelteSet<MonthGroup>();
const monthCount = timelineManager.months.length;
for (const asset of assets) {
let month = getMonthGroupByDate(timelineManager, asset.localDateTime);
if (!month) {
month = new MonthGroup(timelineManager, asset.localDateTime, 1, true, options.order);
timelineManager.months.push(month);
}
month.addTimelineAsset(asset, addContext);
updatedMonthGroups.add(month);
}
if (timelineManager.months.length !== monthCount) {
timelineManager.months.sort((a, b) => {
return a.yearMonth.year === b.yearMonth.year
? b.yearMonth.month - a.yearMonth.month
: b.yearMonth.year - a.yearMonth.year;
});
}
for (const group of addContext.existingDayGroups) {
group.sortAssets(options.order);
}
for (const monthGroup of addContext.bucketsWithNewDayGroups) {
monthGroup.sortDayGroups();
}
for (const month of addContext.updatedBuckets) {
month.sortDayGroups();
updateGeometry(timelineManager, month, { invalidateHeight: true });
}
timelineManager.updateIntersections();
}
export function runAssetOperation(
timelineManager: TimelineManager,
ids: Set<string>,
operation: AssetOperation,
options: { order: AssetOrder },
) {
if (ids.size === 0) {
return { processedIds: new SvelteSet(), unprocessedIds: ids, changedGeometry: false };
}
const changedMonthGroups = new SvelteSet<MonthGroup>();
let idsToProcess = new SvelteSet(ids);
const idsProcessed = new SvelteSet<string>();
const combinedMoveAssets: { asset: TimelineAsset; date: TimelineDate }[][] = [];
for (const month of timelineManager.months) {
if (idsToProcess.size > 0) {
const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation);
if (moveAssets.length > 0) {
combinedMoveAssets.push(moveAssets);
}
idsToProcess = setDifference(idsToProcess, processedIds);
for (const id of processedIds) {
idsProcessed.add(id);
}
if (changedGeometry) {
changedMonthGroups.add(month);
}
}
}
if (combinedMoveAssets.length > 0) {
addAssetsToMonthGroups(
timelineManager,
combinedMoveAssets.flat().map((a) => a.asset),
options,
);
}
const changedGeometry = changedMonthGroups.size > 0;
for (const month of changedMonthGroups) {
updateGeometry(timelineManager, month, { invalidateHeight: true });
}
if (changedGeometry) {
timelineManager.updateIntersections();
}
return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
}

View File

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

View File

@@ -13,10 +13,10 @@ export class WebsocketSupport {
#processPendingChanges = throttle(() => {
const { add, update, remove } = this.#getPendingChangeBatches();
if (add.length > 0) {
this.#timelineManager.addAssets(add);
this.#timelineManager.upsertAssets(add);
}
if (update.length > 0) {
this.#timelineManager.updateAssets(update);
this.#timelineManager.upsertAssets(update);
}
if (remove.length > 0) {
this.#timelineManager.removeAssets(remove);

View File

@@ -73,6 +73,10 @@ export class MonthGroup extends PhotostreamSegment {
return loadFromTimeBuckets(this.timelineManager, this, this.timelineManager.options, signal);
}
layout(noDefer?: boolean) {
layoutMonthGroup(this.timelineManager, this, noDefer);
}
get lastDayGroup() {
return this.dayGroups.at(-1);
}
@@ -306,10 +310,6 @@ export class MonthGroup extends PhotostreamSegment {
this.loader?.cancel();
}
layout(noDefer: boolean) {
layoutMonthGroup(this.timelineManager, this, noDefer);
}
#clearDeferredLayout() {
const hasDeferred = this.dayGroups.some((group) => group.deferredLayout);
if (hasDeferred) {

View File

@@ -1,9 +1,15 @@
import { sdkMock } from '$lib/__mocks__/sdk.mock';
import { getMonthGroupByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
import type { PhotostreamSegment } from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
import { setTestHook } from '$lib/managers/photostream-manager/TestHooks.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';
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
import type { MockedFunction } from 'vitest';
import { TimelineManager } from './timeline-manager.svelte';
import type { TimelineAsset } from './types';
@@ -54,8 +60,16 @@ describe('TimelineManager', () => {
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
);
const spys: { segment: PhotostreamSegment; cancelSpy: MockedFunction<() => void> }[] = [];
beforeEach(async () => {
timelineManager = new TimelineManager();
setTestHook({
hookSegment: (segment) => {
spys.push({ segment, cancelSpy: vi.spyOn(segment, 'cancel') as MockedFunction<() => void> });
},
});
sdkMock.getTimeBuckets.mockResolvedValue([
{ count: 1, timeBucket: '2024-03-01' },
{ count: 100, timeBucket: '2024-02-01' },
@@ -68,7 +82,7 @@ describe('TimelineManager', () => {
it('should load months in viewport', () => {
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
expect(spys[2].cancelSpy).toHaveBeenCalled();
});
it('calculates month height', () => {
@@ -82,13 +96,13 @@ describe('TimelineManager', () => {
expect.arrayContaining([
expect.objectContaining({ year: 2024, month: 3, height: 165.5 }),
expect.objectContaining({ year: 2024, month: 2, height: 11_996 }),
expect.objectContaining({ year: 2024, month: 1, height: 286 }),
expect.objectContaining({ year: 2024, month: 1, height: 48 }),
]),
);
});
it('calculates timeline height', () => {
expect(timelineManager.timelineHeight).toBe(12_447.5);
expect(timelineManager.timelineHeight).toBe(12_209.5);
});
});
@@ -173,7 +187,7 @@ describe('TimelineManager', () => {
});
});
describe('addAssets', () => {
describe('upsertAssets', () => {
let timelineManager: TimelineManager;
beforeEach(async () => {
@@ -194,7 +208,7 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
}),
);
timelineManager.addAssets([asset]);
timelineManager.upsertAssets([asset]);
expect(timelineManager.months.length).toEqual(1);
expect(timelineManager.assetCount).toEqual(1);
@@ -210,8 +224,8 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
})
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
timelineManager.addAssets([assetOne]);
timelineManager.addAssets([assetTwo]);
timelineManager.upsertAssets([assetOne]);
timelineManager.upsertAssets([assetTwo]);
expect(timelineManager.months.length).toEqual(1);
expect(timelineManager.assetCount).toEqual(2);
@@ -236,7 +250,7 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-16T12:00:00.000Z'),
}),
);
timelineManager.addAssets([assetOne, assetTwo, assetThree]);
timelineManager.upsertAssets([assetOne, assetTwo, assetThree]);
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 });
expect(month).not.toBeNull();
@@ -262,7 +276,7 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2023-01-20T12:00:00.000Z'),
}),
);
timelineManager.addAssets([assetOne, assetTwo, assetThree]);
timelineManager.upsertAssets([assetOne, assetTwo, assetThree]);
expect(timelineManager.months.length).toEqual(3);
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
@@ -276,11 +290,11 @@ describe('TimelineManager', () => {
});
it('updates existing asset', () => {
const updateAssetsSpy = vi.spyOn(timelineManager, 'updateAssets');
const updateAssetsSpy = vi.spyOn(timelineManager, 'upsertAssets');
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build());
timelineManager.addAssets([asset]);
timelineManager.upsertAssets([asset]);
timelineManager.addAssets([asset]);
timelineManager.upsertAssets([asset]);
expect(updateAssetsSpy).toBeCalledWith([asset]);
expect(timelineManager.assetCount).toEqual(1);
});
@@ -292,7 +306,7 @@ describe('TimelineManager', () => {
const timelineManager = new TimelineManager();
await timelineManager.updateOptions({ isTrashed: true });
timelineManager.addAssets([asset, trashedAsset]);
timelineManager.upsertAssets([asset, trashedAsset]);
expect(await getAssets(timelineManager)).toEqual([trashedAsset]);
});
});
@@ -307,22 +321,15 @@ describe('TimelineManager', () => {
await timelineManager.updateViewport({ width: 1588, height: 1000 });
});
it('ignores non-existing assets', () => {
timelineManager.updateAssets([deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build())]);
expect(timelineManager.months.length).toEqual(0);
expect(timelineManager.assetCount).toEqual(0);
});
it('updates an asset', () => {
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isFavorite: false }));
const updatedAsset = { ...asset, isFavorite: true };
timelineManager.addAssets([asset]);
timelineManager.upsertAssets([asset]);
expect(timelineManager.assetCount).toEqual(1);
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(false);
timelineManager.updateAssets([updatedAsset]);
timelineManager.upsertAssets([updatedAsset]);
expect(timelineManager.assetCount).toEqual(1);
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(true);
});
@@ -338,12 +345,12 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-20T12:00:00.000Z'),
});
timelineManager.addAssets([asset]);
timelineManager.upsertAssets([asset]);
expect(timelineManager.months.length).toEqual(1);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(1);
timelineManager.updateAssets([updatedAsset]);
timelineManager.upsertAssets([updatedAsset]);
expect(timelineManager.months.length).toEqual(2);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(0);
@@ -363,7 +370,7 @@ describe('TimelineManager', () => {
});
it('ignores invalid IDs', () => {
timelineManager.addAssets(
timelineManager.upsertAssets(
timelineAssetFactory
.buildList(2, {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
@@ -383,7 +390,7 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
})
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
timelineManager.addAssets([assetOne, assetTwo]);
timelineManager.upsertAssets([assetOne, assetTwo]);
timelineManager.removeAssets([assetOne.id]);
expect(timelineManager.assetCount).toEqual(1);
@@ -397,7 +404,7 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
})
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
timelineManager.addAssets(assets);
timelineManager.upsertAssets(assets);
timelineManager.removeAssets(assets.map((asset) => asset.id));
expect(timelineManager.assetCount).toEqual(0);
@@ -429,7 +436,7 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'),
}),
);
timelineManager.addAssets([assetOne, assetTwo]);
timelineManager.upsertAssets([assetOne, assetTwo]);
expect(timelineManager.getFirstAsset()).toEqual(assetOne);
});
});
@@ -554,12 +561,12 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
}),
);
timelineManager.addAssets([assetOne, assetTwo]);
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', () => {
@@ -573,11 +580,11 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
}),
);
timelineManager.addAssets([assetOne, assetTwo]);
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);
});
});
});

View File

@@ -11,14 +11,11 @@ import {
} from '$lib/utils/timeline-util';
import { isEqual } from 'lodash-es';
import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity';
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
import { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.svelte';
import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
import {
addAssetsToMonthGroups,
runAssetOperation,
} from '$lib/managers/timeline-manager/internal/operations-support.svelte';
import {
findMonthGroupForAsset as findMonthGroupForAssetUtil,
findMonthGroupForDate,
@@ -28,16 +25,9 @@ import {
} from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte';
import { DayGroup } from './day-group.svelte';
import { isMismatched, updateObject } from './internal/utils.svelte';
import { isMismatched } from './internal/utils.svelte';
import { MonthGroup } from './month-group.svelte';
import type {
AssetDescriptor,
AssetOperation,
Direction,
ScrubberMonth,
TimelineAsset,
TimelineManagerOptions,
} from './types';
import type { AssetDescriptor, Direction, ScrubberMonth, TimelineAsset, TimelineManagerOptions } from './types';
export class TimelineManager extends PhotostreamManager {
albumAssets: Set<string> = new SvelteSet();
@@ -181,13 +171,7 @@ export class TimelineManager extends PhotostreamManager {
this.scrubberTimelineHeight = this.timelineHeight;
}
addAssets(assets: TimelineAsset[]) {
const assetsToUpdate = assets.filter((asset) => !this.isExcluded(asset));
const notUpdated = this.updateAssets(assetsToUpdate);
addAssetsToMonthGroups(this, [...notUpdated], { order: this.#options.order ?? AssetOrder.Desc });
}
async findMonthGroupForAsset(id: string) {
async findSegmentForAssetId(id: string) {
if (!this.isInitialized) {
await this.initTask.waitUntilCompletion();
}
@@ -218,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];
@@ -235,40 +214,6 @@ export class TimelineManager extends PhotostreamManager {
return month?.getRandomAsset();
}
updateAssetOperation(ids: string[], operation: AssetOperation) {
runAssetOperation(this, new SvelteSet(ids), operation, { order: this.#options.order ?? AssetOrder.Desc });
}
updateAssets(assets: TimelineAsset[]) {
const lookup = new SvelteMap<string, TimelineAsset>(assets.map((asset) => [asset.id, asset]));
const { unprocessedIds } = runAssetOperation(
this,
new SvelteSet(lookup.keys()),
(asset) => {
updateObject(asset, lookup.get(asset.id));
return { remove: false };
},
{ order: this.#options.order ?? AssetOrder.Desc },
);
const result: TimelineAsset[] = [];
for (const id of unprocessedIds.values()) {
result.push(lookup.get(id)!);
}
return result;
}
removeAssets(ids: string[]) {
const { unprocessedIds } = runAssetOperation(
this,
new SvelteSet(ids),
() => {
return { remove: true };
},
{ order: this.#options.order ?? AssetOrder.Desc },
);
return [...unprocessedIds];
}
refreshLayout() {
for (const month of this.months) {
updateGeometry(this, month, { invalidateHeight: true });
@@ -324,4 +269,42 @@ export class TimelineManager extends PhotostreamManager {
getAssetOrder() {
return this.#options.order ?? AssetOrder.Desc;
}
protected createUpsertContext(): unknown {
return new GroupInsertionCache();
}
protected upsertAssetIntoSegment(asset: TimelineAsset, context: GroupInsertionCache): void {
let month = getMonthGroupByDate(this, asset.localDateTime);
if (!month) {
month = new MonthGroup(this, asset.localDateTime, 1, true, this.#options.order);
this.months.push(month);
}
month.addTimelineAsset(asset, context);
}
protected postCreateSegments(): void {
this.months.sort((a, b) => {
return a.yearMonth.year === b.yearMonth.year
? b.yearMonth.month - a.yearMonth.month
: b.yearMonth.year - a.yearMonth.year;
});
}
protected postUpsert(context: GroupInsertionCache): void {
for (const group of context.existingDayGroups) {
group.sortAssets(this.#options.order);
}
for (const monthGroup of context.bucketsWithNewDayGroups) {
monthGroup.sortDayGroups();
}
for (const month of context.updatedBuckets) {
month.sortDayGroups();
updateGeometry(this, month, { invalidateHeight: true });
}
}
}

View File

@@ -35,7 +35,7 @@ export type TimelineAsset = {
longitude?: number | null;
};
export type AssetOperation = (asset: TimelineAsset) => { remove: boolean };
export type AssetOperation = (asset: TimelineAsset) => { remove: boolean } | unknown;
export type MoveAsset = { asset: TimelineAsset; date: TimelineDate };

View File

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

View File

@@ -21,15 +21,15 @@ export type OnSetVisibility = (ids: string[]) => void;
export const deleteAssets = async (
force: boolean,
onAssetDelete: OnDelete,
onAssetDelete: OnDelete | undefined,
assets: TimelineAsset[],
onUndoDelete: OnUndoDelete | undefined = undefined,
onUndoDelete: OnUndoDelete | undefined,
) => {
const $t = get(t);
try {
const ids = assets.map((a) => a.id);
await deleteBulk({ assetBulkDeleteDto: { ids, force } });
onAssetDelete(ids);
onAssetDelete?.(ids);
notificationController.show({
message: force
@@ -98,5 +98,5 @@ export function updateUnstackedAssetInTimeline(timelineManager: TimelineManager,
},
);
timelineManager.addAssets(assets);
timelineManager.upsertAssets(assets);
}

View File

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

View File

@@ -34,7 +34,6 @@
import { AlbumPageViewMode, AppRoute } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte';
import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte';
import AlbumUsersModal from '$lib/modals/AlbumUsersModal.svelte';
@@ -262,18 +261,15 @@
}
};
const handleSetVisibility = (assetIds: string[]) => {
timelineManager.removeAssets(assetIds);
const handleSetVisibility = () => {
assetInteraction.clearMultiselect();
};
const handleRemoveAssets = async (assetIds: string[]) => {
timelineManager.removeAssets(assetIds);
const handleRemoveAssets = async () => {
await refreshAlbum();
};
const handleUndoRemoveAssets = async (assets: TimelineAsset[]) => {
timelineManager.addAssets(assets);
const handleUndoRemoveAssets = async () => {
await refreshAlbum();
};
@@ -452,7 +448,7 @@
{isSelectionMode}
{singleSelect}
{showArchiveIcon}
{onSelect}
onAssetSelect={onSelect}
onEscape={handleEscape}
>
{#if viewMode !== AlbumPageViewMode.SELECT_ASSETS}
@@ -572,14 +568,7 @@
<AddToAlbum shared />
</ButtonContextMenu>
{#if assetInteraction.isAllUserOwned}
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.isFavorite = isFavorite;
return { remove: false };
})}
></FavoriteAction>
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} manager={timelineManager}></FavoriteAction>
{/if}
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')} offset={{ x: 175, y: 25 }}>
<DownloadAction menuItem filename="{album.albumName}.zip" />
@@ -594,7 +583,7 @@
onClick={() => updateThumbnailUsingCurrentSelection()}
/>
{/if}
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} manager={timelineManager} />
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
{/if}
@@ -606,7 +595,12 @@
<RemoveFromAlbum menuItem bind:album onRemove={handleRemoveAssets} />
{/if}
{#if assetInteraction.isAllUserOwned}
<DeleteAssets menuItem onAssetDelete={handleRemoveAssets} onUndoDelete={handleUndoRemoveAssets} />
<DeleteAssets
menuItem
onAssetDelete={handleRemoveAssets}
onUndoDelete={handleUndoRemoveAssets}
manager={timelineManager}
/>
{/if}
</ButtonContextMenu>
</AssetSelectControlBar>

View File

@@ -65,32 +65,18 @@
assets={assetInteraction.selectedAssets}
clearSelect={() => assetInteraction.clearMultiselect()}
>
<ArchiveAction
unarchive
onArchive={(ids, visibility) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.visibility = visibility;
return { remove: false };
})}
/>
<ArchiveAction unarchive manager={timelineManager} />
<CreateSharedLink />
<SelectAllAssets {timelineManager} {assetInteraction} />
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum />
<AddToAlbum shared />
</ButtonContextMenu>
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.isFavorite = isFavorite;
return { remove: false };
})}
/>
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} manager={timelineManager} />
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
<DeleteAssets menuItem onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)} />
<DeleteAssets menuItem manager={timelineManager} />
</ButtonContextMenu>
</AssetSelectControlBar>
{/if}

View File

@@ -71,7 +71,7 @@
assets={assetInteraction.selectedAssets}
clearSelect={() => assetInteraction.clearMultiselect()}
>
<FavoriteAction removeFavorite onFavorite={(assetIds) => timelineManager.removeAssets(assetIds)} />
<FavoriteAction removeFavorite manager={timelineManager} />
<CreateSharedLink />
<SelectAllAssets {timelineManager} {assetInteraction} />
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
@@ -83,20 +83,12 @@
<ChangeDate menuItem />
<ChangeDescription menuItem />
<ChangeLocation menuItem />
<ArchiveAction
menuItem
unarchive={assetInteraction.isAllArchived}
onArchive={(assetIds) => timelineManager.removeAssets(assetIds)}
/>
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} manager={timelineManager} />
{#if $preferences.tags.enabled}
<TagAction menuItem />
{/if}
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
<DeleteAssets
menuItem
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
onUndoDelete={(assets) => timelineManager.addAssets(assets)}
/>
<DeleteAssets menuItem manager={timelineManager} />
</ButtonContextMenu>
</AssetSelectControlBar>
{/if}

View File

@@ -349,15 +349,8 @@
}
};
const handleDeleteAssets = async (assetIds: string[]) => {
timelineManager.removeAssets(assetIds);
await updateAssetCount();
};
const handleUndoDeleteAssets = async (assets: TimelineAsset[]) => {
timelineManager.addAssets(assets);
await updateAssetCount();
};
const handleDeleteAssets = async () => await updateAssetCount();
const handleUndoDeleteAssets = async () => await updateAssetCount();
let person = $derived(data.person);
@@ -392,7 +385,7 @@
{assetInteraction}
isSelectionMode={viewMode === PersonPageViewMode.SELECT_PERSON}
singleSelect={viewMode === PersonPageViewMode.SELECT_PERSON}
onSelect={handleSelectFeaturePhoto}
onAssetSelect={handleSelectFeaturePhoto}
onEscape={handleEscape}
>
{#if viewMode === PersonPageViewMode.VIEW_ASSETS}
@@ -511,14 +504,7 @@
<AddToAlbum />
<AddToAlbum shared />
</ButtonContextMenu>
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.isFavorite = isFavorite;
return { remove: false };
})}
/>
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} manager={timelineManager} />
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem filename="{person.name || 'immich'}.zip" />
<MenuOption
@@ -529,19 +515,16 @@
<ChangeDate menuItem />
<ChangeDescription menuItem />
<ChangeLocation menuItem />
<ArchiveAction
menuItem
unarchive={assetInteraction.isAllArchived}
onArchive={(assetIds) => timelineManager.removeAssets(assetIds)}
/>
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} manager={timelineManager} />
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
<TagAction menuItem />
{/if}
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
<DeleteAssets
menuItem
onAssetDelete={(assetIds) => handleDeleteAssets(assetIds)}
onUndoDelete={(assets) => handleUndoDeleteAssets(assets)}
manager={timelineManager}
onAssetDelete={handleDeleteAssets}
onUndoDelete={handleUndoDeleteAssets}
/>
</ButtonContextMenu>
</AssetSelectControlBar>

View File

@@ -70,12 +70,12 @@
const handleLink: OnLink = ({ still, motion }) => {
timelineManager.removeAssets([motion.id]);
timelineManager.updateAssets([still]);
timelineManager.upsertAssets([still]);
};
const handleUnlink: OnUnlink = ({ still, motion }) => {
timelineManager.addAssets([motion]);
timelineManager.updateAssets([still]);
timelineManager.upsertAssets([motion]);
timelineManager.upsertAssets([still]);
};
const handleSetVisibility = (assetIds: string[]) => {
@@ -118,14 +118,7 @@
<AddToAlbum />
<AddToAlbum shared />
</ButtonContextMenu>
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.isFavorite = isFavorite;
return { remove: false };
})}
></FavoriteAction>
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} manager={timelineManager}></FavoriteAction>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
{#if assetInteraction.selectedAssets.length > 1 || isAssetStackSelected}
@@ -146,15 +139,11 @@
<ChangeDate menuItem />
<ChangeDescription menuItem />
<ChangeLocation menuItem />
<ArchiveAction menuItem onArchive={(assetIds) => timelineManager.removeAssets(assetIds)} />
<ArchiveAction menuItem manager={timelineManager} />
{#if $preferences.tags.enabled}
<TagAction menuItem />
{/if}
<DeleteAssets
menuItem
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
onUndoDelete={(assets) => timelineManager.addAssets(assets)}
/>
<DeleteAssets menuItem manager={timelineManager} />
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
<hr />
<AssetJobActions />

View File

@@ -1,17 +1,16 @@
<script lang="ts">
import { afterNavigate, goto } from '$app/navigation';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { shortcut } from '$lib/actions/shortcut';
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
import SearchResults from '$lib/components/search/SearchResults.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte';
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
import AssetJobActions from '$lib/components/timeline/actions/AssetJobActions.svelte';
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte';
import ChangeDescriptionAction from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte';
import ChangeLocation from '$lib/components/timeline/actions/ChangeLocationAction.svelte';
import CreateSharedLink from '$lib/components/timeline/actions/CreateSharedLinkAction.svelte';
import DeleteAssets from '$lib/components/timeline/actions/DeleteAssetsAction.svelte';
@@ -20,66 +19,39 @@
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import { SearchResultsManager } from '$lib/managers/searchresults-manager/SearchResultsManager.svelte';
import type { Viewport } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { lang, locale } from '$lib/stores/preferences.store';
import { locale } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { preferences } from '$lib/stores/user.store';
import { handlePromiseError } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { parseUtcDate } from '$lib/utils/date-time';
import { handleError } from '$lib/utils/handle-error';
import { isAlbumsRoute, isPeopleRoute } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
type AlbumResponseDto,
getPerson,
getTagById,
type MetadataSearchDto,
searchAssets,
searchSmart,
type SmartSearchDto,
} from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner } from '@immich/ui';
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
import { tick } from 'svelte';
import { getPerson, getTagById, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiArrowLeft, mdiDotsVertical, mdiPlus, mdiSelectAll } from '@mdi/js';
import { t } from 'svelte-i18n';
let { isViewing: showAssetViewer } = assetViewingStore;
const viewport: Viewport = $state({ width: 0, height: 0 });
let searchResultsElement: HTMLElement | undefined = $state();
// The GalleryViewer pushes it's own history state, which causes weird
// behavior for history.back(). To prevent that we store the previous page
// manually and navigate back to that.
let previousRoute = $state(AppRoute.EXPLORE as string);
let nextPage = $state(1);
let searchResultAlbums: AlbumResponseDto[] = $state([]);
let searchResultAssets: TimelineAsset[] = $state([]);
let isLoading = $state(true);
let scrollY = $state(0);
let scrollYHistory = 0;
const assetInteraction = new AssetInteraction();
type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query' | 'queryAssetId'>;
let searchQuery = $derived(page.url.searchParams.get(QueryParameter.QUERY));
let smartSearchEnabled = $derived($featureFlags.loaded && $featureFlags.smartSearch);
let isSmartSearchEnabled = $derived($featureFlags.loaded && $featureFlags.smartSearch);
let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {});
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
terms;
setTimeout(() => {
handlePromiseError(onSearchQueryUpdate());
});
});
const searchResultsManager = new SearchResultsManager();
let timelineManager = new TimelineManager();
$effect(() => {
void searchResultsManager.updateOptions({ isSmartSearchEnabled, searchTerms: terms });
});
const onEscape = () => {
if ($showAssetViewer) {
@@ -90,91 +62,16 @@
assetInteraction.selectedAssets = [];
return;
}
handlePromiseError(goto(previousRoute));
};
$effect(() => {
if (scrollY) {
scrollYHistory = scrollY;
}
});
afterNavigate(({ from }) => {
// Prevent setting previousRoute to the current page.
if (from?.url && from.route.id !== page.route.id) {
previousRoute = from.url.href;
}
const route = from?.route?.id;
if (isPeopleRoute(route)) {
previousRoute = AppRoute.PHOTOS;
}
if (isAlbumsRoute(route)) {
previousRoute = AppRoute.EXPLORE;
}
tick()
.then(() => {
window.scrollTo(0, scrollYHistory);
})
.catch(() => {
// do nothing
});
});
const onAssetDelete = (assetIds: string[]) => {
const assetIdSet = new Set(assetIds);
searchResultAssets = searchResultAssets.filter((asset: TimelineAsset) => !assetIdSet.has(asset.id));
};
const handleSetVisibility = (assetIds: string[]) => {
timelineManager.removeAssets(assetIds);
searchResultsManager.removeAssets(assetIds);
assetInteraction.clearMultiselect();
onAssetDelete(assetIds);
};
const handleSelectAll = () => {
assetInteraction.selectAssets(searchResultAssets);
};
async function onSearchQueryUpdate() {
nextPage = 1;
searchResultAssets = [];
searchResultAlbums = [];
await loadNextPage(true);
}
// eslint-disable-next-line svelte/valid-prop-names-in-kit-pages
export const loadNextPage = async (force?: boolean) => {
if (!nextPage || (isLoading && !force)) {
return;
}
isLoading = true;
const searchDto: SearchTerms = {
page: nextPage,
withExif: true,
isVisible: true,
language: $lang,
...terms,
};
try {
const { albums, assets } =
('query' in searchDto || 'queryAssetId' in searchDto) && smartSearchEnabled
? await searchSmart({ smartSearchDto: searchDto })
: await searchAssets({ metadataSearchDto: searchDto });
searchResultAlbums.push(...albums.items);
searchResultAssets.push(...assets.items.map((asset) => toTimelineAsset(asset)));
nextPage = Number(assets.nextPage) || 0;
} catch (error) {
handleError(error, $t('loading_search_results_failed'));
} finally {
isLoading = false;
}
const allAssets = searchResultsManager.months.flatMap((segment) => segment.assets);
assetInteraction.selectAssets(allAssets);
};
function getHumanReadableDate(dateString: string) {
@@ -248,8 +145,7 @@
cancelMultiselect(assetInteraction);
if (terms.isNotInAlbum.toString() == 'true') {
const assetIdSet = new Set(assetIds);
searchResultAssets = searchResultAssets.filter((asset) => !assetIdSet.has(asset.id));
searchResultsManager.removeAssets(assetIds);
}
};
@@ -258,12 +154,11 @@
}
</script>
<svelte:window bind:scrollY />
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onEscape }} />
<section>
{#if assetInteraction.selectionActive}
<div class="fixed top-0 start-0 w-full">
<div class="fixed z-1 top-0 start-0 w-full">
<AssetSelectControlBar
assets={assetInteraction.selectedAssets}
clearSelect={() => cancelMultiselect(assetInteraction)}
@@ -281,34 +176,29 @@
<AddToAlbum {onAddToAlbum} />
<AddToAlbum shared {onAddToAlbum} />
</ButtonContextMenu>
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(assetIds, isFavorite) => {
for (const assetId of assetIds) {
const asset = searchResultAssets.find((searchAsset) => searchAsset.id === assetId);
if (asset) {
asset.isFavorite = isFavorite;
}
}
}}
/>
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} manager={searchResultsManager} />
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
<ChangeDate menuItem />
<ChangeLocation menuItem />
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
<ArchiveAction
menuItem
removeOnArchive={true}
unarchive={assetInteraction.isAllArchived}
manager={searchResultsManager}
/>
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
<TagAction menuItem />
{/if}
<DeleteAssets menuItem {onAssetDelete} onUndoDelete={onSearchQueryUpdate} />
<DeleteAssets menuItem manager={searchResultsManager} />
<hr />
<AssetJobActions />
</ButtonContextMenu>
</AssetSelectControlBar>
</div>
{:else}
<div class="fixed top-0 start-0 w-full">
<div class="fixed z-1 top-0 start-0 w-full">
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
<div class="absolute bg-light"></div>
<div class="w-full flex-1 ps-4">
@@ -319,92 +209,55 @@
{/if}
</section>
{#if terms}
<section
id="search-chips"
class="mt-24 text-center w-full flex gap-5 place-content-center place-items-center flex-wrap px-24"
>
{#each getObjectKeys(terms) as searchKey (searchKey)}
{@const value = terms[searchKey]}
<div class="flex place-content-center place-items-center items-stretch text-xs">
<div
class="flex items-center justify-center bg-immich-primary py-2 px-4 text-white dark:text-black dark:bg-immich-dark-primary
{value === true ? 'rounded-full' : 'rounded-s-full'}"
>
{getHumanReadableSearchKey(searchKey as keyof SearchTerms)}
</div>
{#if value !== true}
<div class="bg-gray-300 py-2 px-4 dark:bg-gray-800 dark:text-white rounded-e-full">
{#if (searchKey === 'takenAfter' || searchKey === 'takenBefore') && typeof value === 'string'}
{getHumanReadableDate(value)}
{:else if searchKey === 'personIds' && Array.isArray(value)}
{#await getPersonName(value) then personName}
{personName}
{/await}
{:else if searchKey === 'tagIds' && (Array.isArray(value) || value === null)}
{#await getTagNames(value) then tagNames}
{tagNames}
{/await}
{:else if value === null || value === ''}
{$t('unknown')}
{:else}
{value}
{/if}
</div>
{/if}
</div>
{/each}
</section>
{/if}
<section
class="mb-12 bg-immich-bg dark:bg-immich-dark-bg m-4 max-h-screen"
class="absolute top-0 right-0 left-0 bottom-0 mb-0 bg-immich-bg dark:bg-immich-dark-bg max-h-screen"
bind:clientHeight={viewport.height}
bind:clientWidth={viewport.width}
bind:this={searchResultsElement}
>
{#if searchResultAlbums.length > 0}
<section>
<div class="uppercase ms-6 text-4xl font-medium text-black/70 dark:text-white/80">{$t('albums')}</div>
<AlbumCardGroup albums={searchResultAlbums} showDateRange showItemCount />
<SearchResults {searchResultsManager} {assetInteraction} stylePaddingHorizontalPx={16}>
{#if terms}
<section
id="search-chips"
class="md:mt-[94px] mt-[72px] mb-4 text-center w-full flex gap-5 place-content-center place-items-center flex-wrap"
>
{#each getObjectKeys(terms) as searchKey (searchKey)}
{@const value = terms[searchKey]}
<div class="flex place-content-center place-items-center items-stretch text-xs">
<div
class="flex items-center justify-center bg-immich-primary py-2 px-4 text-white dark:text-black dark:bg-immich-dark-primary
{value === true ? 'rounded-full' : 'rounded-s-full'}"
>
{getHumanReadableSearchKey(searchKey as keyof SearchTerms)}
</div>
<div class="uppercase m-6 text-4xl font-medium text-black/70 dark:text-white/80">
{$t('photos_and_videos')}
</div>
</section>
{/if}
<section id="search-content">
{#if searchResultAssets.length > 0}
<GalleryViewer
assets={searchResultAssets}
{assetInteraction}
onIntersected={loadNextPage}
showArchiveIcon={true}
{viewport}
onReload={onSearchQueryUpdate}
slidingWindowOffset={searchResultsElement.offsetTop}
/>
{:else if !isLoading}
<div class="flex min-h-[calc(66vh-11rem)] w-full place-content-center items-center dark:text-white">
<div class="flex flex-col content-center items-center text-center">
<Icon icon={mdiImageOffOutline} size="3.5em" />
<p class="mt-5 text-3xl font-medium">{$t('no_results')}</p>
<p class="text-base font-normal">{$t('no_results_description')}</p>
</div>
</div>
{#if value !== true}
<div class="bg-gray-300 py-2 px-4 dark:bg-gray-800 dark:text-white rounded-e-full">
{#if (searchKey === 'takenAfter' || searchKey === 'takenBefore') && typeof value === 'string'}
{getHumanReadableDate(value)}
{:else if searchKey === 'personIds' && Array.isArray(value)}
{#await getPersonName(value) then personName}
{personName}
{/await}
{:else if searchKey === 'tagIds' && (Array.isArray(value) || value === null)}
{#await getTagNames(value) then tagNames}
{tagNames}
{/await}
{:else if value === null || value === ''}
{$t('unknown')}
{:else}
{value}
{/if}
</div>
{/if}
</div>
{/each}
</section>
{/if}
{#if isLoading}
<div class="flex justify-center py-16 items-center">
<LoadingSpinner size="giant" />
</div>
{/if}
</section>
</SearchResults>
<section>
{#if assetInteraction.selectionActive}
<div class="fixed top-0 start-0 w-full">
<div class="fixed z-1 top-0 start-0 w-full">
<AssetSelectControlBar
assets={assetInteraction.selectedAssets}
clearSelect={() => cancelMultiselect(assetInteraction)}
@@ -422,38 +275,33 @@
<AddToAlbum {onAddToAlbum} />
<AddToAlbum shared {onAddToAlbum} />
</ButtonContextMenu>
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) => {
for (const id of ids) {
const asset = searchResultAssets.find((asset) => asset.id === id);
if (asset) {
asset.isFavorite = isFavorite;
}
}
}}
/>
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} manager={searchResultsManager} />
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
<ChangeDate menuItem />
<ChangeDescription menuItem />
<ChangeDescriptionAction menuItem />
<ChangeLocation menuItem />
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
<ArchiveAction
menuItem
removeOnArchive={true}
unarchive={assetInteraction.isAllArchived}
manager={searchResultsManager}
/>
{#if assetInteraction.isAllUserOwned}
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
{/if}
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
<TagAction menuItem />
{/if}
<DeleteAssets menuItem {onAssetDelete} onUndoDelete={onSearchQueryUpdate} />
<DeleteAssets menuItem manager={searchResultsManager} />
<hr />
<AssetJobActions />
</ButtonContextMenu>
</AssetSelectControlBar>
</div>
{:else}
<div class="fixed top-0 start-0 w-full">
<div class="fixed z-1 top-0 start-0 w-full">
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
<div class="absolute bg-light"></div>
<div class="w-full flex-1 ps-4">

View File

@@ -5,7 +5,6 @@
import Timeline from '$lib/components/timeline/Timeline.svelte';
import { AssetAction } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import GeolocationUpdateConfirmModal from '$lib/modals/GeolocationUpdateConfirmModal.svelte';
@@ -63,7 +62,7 @@
}),
);
timelineManager.updateAssets(updatedAssets);
timelineManager.upsertAssets(updatedAssets);
handleDeselectAll();
};
@@ -110,17 +109,7 @@
return !!asset.latitude && !!asset.longitude;
};
const handleThumbnailClick = (
asset: TimelineAsset,
timelineManager: TimelineManager,
dayGroup: DayGroup,
onClick: (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => void,
) => {
const handleAssetOpen = (asset: TimelineAsset, defaultAssetOpen: () => void) => {
if (hasGps(asset)) {
locationUpdated = true;
setTimeout(() => {
@@ -128,9 +117,9 @@
}, 1500);
location = { latitude: asset.latitude!, longitude: asset.longitude! };
void setQueryValue('at', asset.id);
} else {
onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
return;
}
defaultAssetOpen();
};
</script>
@@ -193,9 +182,9 @@
removeAction={AssetAction.ARCHIVE}
onEscape={handleEscape}
withStacked
onThumbnailClick={handleThumbnailClick}
onAssetOpen={handleAssetOpen}
>
{#snippet customLayout(asset: TimelineAsset)}
{#snippet customThumbnailLayout(asset: TimelineAsset)}
{#if hasGps(asset)}
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-success text-black">
{asset.city || $t('gps')}