mirror of
https://github.com/immich-app/immich.git
synced 2025-07-08 02:34:12 -04:00
refactor: timeline manager renames (#19007)
* refactor: timeline manager renames * refactor(web): improve timeline manager naming consistency - Rename AddContext → GroupInsertionCache for clearer purpose - Rename TimelineDay → DayGroup for better clarity - Rename TimelineMonth → MonthGroup for better clarity - Replace all "bucket" references with "monthGroup" terminology - Update all component props, method names, and variable references - Maintain consistent naming patterns across TypeScript and Svelte files * refactor(web): rename buckets to months in timeline manager - Rename TimelineManager.buckets property to months - Update all store.buckets references to store.months - Use 'month' shorthand for monthGroup arguments (not method names) - Update component templates and test files for consistency - Maintain API-related 'bucket' terminology (bucketHeight, getTimeBucket) * refactor(web): rename assetStore to timelineManager and update types - Rename assetStore variables to timelineManager in all .svelte files - Update parameter names in actions.ts and asset-utils.ts functions - Rename AssetStoreLayoutOptions to TimelineManagerLayoutOptions - Rename AssetStoreOptions to TimelineManagerOptions - Move assets-store.spec.ts to timeline-manager.spec.ts * refactor(web): rename intersectingAssets to viewerAssets and fix property references - Rename intersectingAssets to viewerAssets in DayGroup and MonthGroup classes - Update arrow function parameters to use viewerAsset/viewAsset shorthand - Rename topIntersectingBucket to topIntersectingMonthGroup - Fix dateGroups references to dayGroups in asset-utils.ts and album page - Update template loops and variable names in Svelte components * refactor(web): rename #initializeTimeBuckets to #initializeMonthGroups and bucketDateFormatted to monthGroupTitle * refactor(web): rename monthGroupHeight to height * refactor(web): rename bucketCount to assetsCount, bucketsIterator to monthGroupIterator, and related properties * refactor(web): rename count to assetCount in TimelineManager * refactor(web): rename LiteBucket to ScrubberMonth and update scrubber variables - Rename LiteBucket type to ScrubberMonth - Rename bucketDateFormattted to title in ScrubberMonth type - Rename bucketPercentY to monthGroupPercentY in scrubber component - Rename scrubBucket to scrubberMonth and scrubBucketPercent to scrubberMonthPercent * fix remaining refs to bucket * reset submodule to correct commit * reset submodule to correct commit * refactor(web): extract TimelineManager internals into separate modules - Move search-related functions to internal/search-support.svelte.ts - Extract websocket event handling into WebsocketSupport class - Move utility functions (updateObject, isMismatched) to internal/utils.svelte.ts - Update imports in tests to use new module structure * refactor(web): extract intersection logic from TimelineManager - Create intersection-support.svelte.ts with updateIntersection and calculateIntersecting functions - Remove private intersection methods from TimelineManager - Export findMonthGroupForAsset from search-support for reuse - Update TimelineManager to use the extracted intersection functions * refactor(web): rename a few methods in intersecting * refactor(web): rename a few methods in intersecting * refactor(web): extract layout logic from TimelineManager - Create layout-support.svelte.ts with updateGeometry and layoutMonthGroup functions - Remove private layout methods from TimelineManager - Update TimelineManager to use the extracted layout functions - Remove unused UpdateGeometryOptions import * refactor(web): extract asset operations from TimelineManager - Create operations-support.svelte.ts with addAssetsToMonthGroups and runAssetOperation functions - Remove private asset operation methods from TimelineManager - Update TimelineManager to use extracted operation functions with proper AssetOrder handling - Rename getMonthGroupIndexByAssetId to getMonthGroupByAssetId for consistency - Move utility functions from utils.svelte.ts to internal/utils.svelte.ts - Fix method name references in asset-grid.svelte and tests * refactor(web): extract loading logic from TimelineManager - Create load-support.svelte.ts with loadFromTimeBuckets function - Extract time bucket loading, album asset handling, and error logging - Simplify TimelineManager's loadMonthGroup method to use extracted function * refresh timeline after archive keyboard shortcut * remove debugger * rename * Review comments - remove shadowed var * reduce indents - early return * review comment * refactor: simplify asset filtering in addAssets method Replace for loop with filter operation for better readability * fix: bad merge * refactor(web): simplify timeline layout algorithm - Replace rowSpaceRemaining array with direct cumulative width tracking - Invert logic from tracking remaining space to tracking used space - Fix spelling: cummulative to cumulative - Rename lastRowHeight to currentRowHeight for clarity - Remove confusing lastRow variable and simplify final height calculation - Add explanatory comments for clarity - Rename loop variable assetGroup to dayGroup for consistency * simplify assetsIterator usage * merge/lint --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
6499057b4c
commit
4b4ee5abf3
@ -4,15 +4,16 @@
|
|||||||
import AlbumMap from '$lib/components/album-page/album-map.svelte';
|
import AlbumMap from '$lib/components/album-page/album-map.svelte';
|
||||||
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
|
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
|
||||||
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
|
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
|
||||||
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
|
|
||||||
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
|
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
|
||||||
import { featureFlags } from '$lib/stores/server-config.store';
|
import { featureFlags } from '$lib/stores/server-config.store';
|
||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
import { cancelMultiselect, downloadAlbum } from '$lib/utils/asset-utils';
|
import { cancelMultiselect, downloadAlbum } from '$lib/utils/asset-utils';
|
||||||
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
|
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||||
import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk';
|
import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk';
|
||||||
|
import { IconButton } from '@immich/ui';
|
||||||
import { mdiFileImagePlusOutline, mdiFolderDownloadOutline } from '@mdi/js';
|
import { mdiFileImagePlusOutline, mdiFolderDownloadOutline } from '@mdi/js';
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
@ -22,7 +23,6 @@
|
|||||||
import ImmichLogoSmallLink from '../shared-components/immich-logo-small-link.svelte';
|
import ImmichLogoSmallLink from '../shared-components/immich-logo-small-link.svelte';
|
||||||
import ThemeButton from '../shared-components/theme-button.svelte';
|
import ThemeButton from '../shared-components/theme-button.svelte';
|
||||||
import AlbumSummary from './album-summary.svelte';
|
import AlbumSummary from './album-summary.svelte';
|
||||||
import { IconButton } from '@immich/ui';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sharedLink: SharedLinkResponseDto;
|
sharedLink: SharedLinkResponseDto;
|
||||||
@ -35,9 +35,9 @@
|
|||||||
|
|
||||||
let { isViewing: showAssetViewer } = assetViewingStore;
|
let { isViewing: showAssetViewer } = assetViewingStore;
|
||||||
|
|
||||||
const assetStore = new AssetStore();
|
const timelineManager = new TimelineManager();
|
||||||
$effect(() => void assetStore.updateOptions({ albumId: album.id, order: album.order }));
|
$effect(() => void timelineManager.updateOptions({ albumId: album.id, order: album.order }));
|
||||||
onDestroy(() => assetStore.destroy());
|
onDestroy(() => timelineManager.destroy());
|
||||||
|
|
||||||
const assetInteraction = new AssetInteraction();
|
const assetInteraction = new AssetInteraction();
|
||||||
|
|
||||||
@ -61,7 +61,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<main class="relative h-dvh overflow-hidden px-2 md:px-6 max-md:pt-(--navbar-height-md) pt-(--navbar-height)">
|
<main class="relative h-dvh overflow-hidden px-2 md:px-6 max-md:pt-(--navbar-height-md) pt-(--navbar-height)">
|
||||||
<AssetGrid enableRouting={true} {album} {assetStore} {assetInteraction}>
|
<AssetGrid enableRouting={true} {album} {timelineManager} {assetInteraction}>
|
||||||
<section class="pt-8 md:pt-24 px-2 md:px-0">
|
<section class="pt-8 md:pt-24 px-2 md:px-0">
|
||||||
<!-- ALBUM TITLE -->
|
<!-- ALBUM TITLE -->
|
||||||
<h1
|
<h1
|
||||||
@ -93,7 +93,7 @@
|
|||||||
assets={assetInteraction.selectedAssets}
|
assets={assetInteraction.selectedAssets}
|
||||||
clearSelect={() => assetInteraction.clearMultiselect()}
|
clearSelect={() => assetInteraction.clearMultiselect()}
|
||||||
>
|
>
|
||||||
<SelectAllAssets {assetStore} {assetInteraction} />
|
<SelectAllAssets {timelineManager} {assetInteraction} />
|
||||||
{#if sharedLink.allowDownload}
|
{#if sharedLink.allowDownload}
|
||||||
<DownloadAction filename="{album.albumName}.zip" />
|
<DownloadAction filename="{album.albumName}.zip" />
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -2,11 +2,11 @@
|
|||||||
import type { OnArchive } from '$lib/utils/actions';
|
import type { OnArchive } from '$lib/utils/actions';
|
||||||
import { archiveAssets } from '$lib/utils/asset-utils';
|
import { archiveAssets } from '$lib/utils/asset-utils';
|
||||||
import { AssetVisibility } from '@immich/sdk';
|
import { AssetVisibility } from '@immich/sdk';
|
||||||
|
import { IconButton } from '@immich/ui';
|
||||||
import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline, mdiTimerSand } from '@mdi/js';
|
import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline, mdiTimerSand } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||||
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
||||||
import { IconButton } from '@immich/ui';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onArchive?: OnArchive;
|
onArchive?: OnArchive;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
import { moveFocus } from '$lib/utils/focus-util';
|
import { moveFocus } from '$lib/utils/focus-util';
|
||||||
import { InvocationTracker } from '$lib/utils/invocationTracker';
|
import { InvocationTracker } from '$lib/utils/invocationTracker';
|
||||||
@ -31,7 +31,7 @@ export const setFocusToAsset = (scrollToAsset: (asset: TimelineAsset) => boolean
|
|||||||
|
|
||||||
export const setFocusTo = async (
|
export const setFocusTo = async (
|
||||||
scrollToAsset: (asset: TimelineAsset) => boolean,
|
scrollToAsset: (asset: TimelineAsset) => boolean,
|
||||||
store: AssetStore,
|
store: TimelineManager,
|
||||||
direction: 'earlier' | 'later',
|
direction: 'earlier' | 'later',
|
||||||
interval: 'day' | 'month' | 'year' | 'asset',
|
interval: 'day' | 'month' | 'year' | 'asset',
|
||||||
) => {
|
) => {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
|
|
||||||
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||||
import { cancelMultiselect, selectAllAssets } from '$lib/utils/asset-utils';
|
import { cancelMultiselect, selectAllAssets } from '$lib/utils/asset-utils';
|
||||||
import { Button, IconButton } from '@immich/ui';
|
import { Button, IconButton } from '@immich/ui';
|
||||||
@ -8,15 +8,15 @@
|
|||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
assetStore: AssetStore;
|
timelineManager: TimelineManager;
|
||||||
assetInteraction: AssetInteraction;
|
assetInteraction: AssetInteraction;
|
||||||
withText?: boolean;
|
withText?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { assetStore, assetInteraction, withText = false }: Props = $props();
|
let { timelineManager, assetInteraction, withText = false }: Props = $props();
|
||||||
|
|
||||||
const handleSelectAll = async () => {
|
const handleSelectAll = async () => {
|
||||||
await selectAllAssets(assetStore, assetInteraction);
|
await selectAllAssets(timelineManager, assetInteraction);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
|
@ -1,19 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||||
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import type { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
|
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
import { assetSnapshot, assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
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 { uploadAssetsStore } from '$lib/stores/upload';
|
||||||
import { navigate } from '$lib/utils/navigation';
|
import { navigate } from '$lib/utils/navigation';
|
||||||
|
|
||||||
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
||||||
import { fly, scale } from 'svelte/transition';
|
|
||||||
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
|
||||||
|
|
||||||
import type { AssetBucket } from '$lib/managers/timeline-manager/asset-bucket.svelte';
|
|
||||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
|
||||||
import { flip } from 'svelte/animate';
|
import { flip } from 'svelte/animate';
|
||||||
|
import { fly, scale } from 'svelte/transition';
|
||||||
|
|
||||||
let { isUploading } = uploadAssetsStore;
|
let { isUploading } = uploadAssetsStore;
|
||||||
|
|
||||||
@ -22,8 +22,8 @@
|
|||||||
singleSelect: boolean;
|
singleSelect: boolean;
|
||||||
withStacked: boolean;
|
withStacked: boolean;
|
||||||
showArchiveIcon: boolean;
|
showArchiveIcon: boolean;
|
||||||
bucket: AssetBucket;
|
monthGroup: MonthGroup;
|
||||||
assetStore: AssetStore;
|
timelineManager: TimelineManager;
|
||||||
assetInteraction: AssetInteraction;
|
assetInteraction: AssetInteraction;
|
||||||
|
|
||||||
onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
|
onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
|
||||||
@ -37,9 +37,9 @@
|
|||||||
singleSelect,
|
singleSelect,
|
||||||
withStacked,
|
withStacked,
|
||||||
showArchiveIcon,
|
showArchiveIcon,
|
||||||
bucket = $bindable(),
|
monthGroup = $bindable(),
|
||||||
assetInteraction,
|
assetInteraction,
|
||||||
assetStore,
|
timelineManager,
|
||||||
onSelect,
|
onSelect,
|
||||||
onSelectAssets,
|
onSelectAssets,
|
||||||
onSelectAssetCandidates,
|
onSelectAssetCandidates,
|
||||||
@ -47,13 +47,20 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let isMouseOverGroup = $state(false);
|
let isMouseOverGroup = $state(false);
|
||||||
let hoveredDateGroup = $state();
|
let hoveredDayGroup = $state();
|
||||||
|
|
||||||
const transitionDuration = $derived.by(() => (bucket.store.suspendTransitions && !$isUploading ? 0 : 150));
|
const transitionDuration = $derived.by(() =>
|
||||||
|
monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150,
|
||||||
|
);
|
||||||
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
||||||
const onClick = (assetStore: AssetStore, assets: TimelineAsset[], groupTitle: string, asset: TimelineAsset) => {
|
const onClick = (
|
||||||
|
timelineManager: TimelineManager,
|
||||||
|
assets: TimelineAsset[],
|
||||||
|
groupTitle: string,
|
||||||
|
asset: TimelineAsset,
|
||||||
|
) => {
|
||||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||||
assetSelectHandler(assetStore, asset, assets, groupTitle);
|
assetSelectHandler(timelineManager, asset, assets, groupTitle);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void navigate({ targetRoute: 'current', assetId: asset.id });
|
void navigate({ targetRoute: 'current', assetId: asset.id });
|
||||||
@ -62,26 +69,26 @@
|
|||||||
const handleSelectGroup = (title: string, assets: TimelineAsset[]) => onSelect({ title, assets });
|
const handleSelectGroup = (title: string, assets: TimelineAsset[]) => onSelect({ title, assets });
|
||||||
|
|
||||||
const assetSelectHandler = (
|
const assetSelectHandler = (
|
||||||
assetStore: AssetStore,
|
timelineManager: TimelineManager,
|
||||||
asset: TimelineAsset,
|
asset: TimelineAsset,
|
||||||
assetsInDateGroup: TimelineAsset[],
|
assetsInDayGroup: TimelineAsset[],
|
||||||
groupTitle: string,
|
groupTitle: string,
|
||||||
) => {
|
) => {
|
||||||
onSelectAssets(asset);
|
onSelectAssets(asset);
|
||||||
|
|
||||||
// Check if all assets are selected in a group to toggle the group selection's icon
|
// Check if all assets are selected in a group to toggle the group selection's icon
|
||||||
let selectedAssetsInGroupCount = assetsInDateGroup.filter((asset) =>
|
let selectedAssetsInGroupCount = assetsInDayGroup.filter((asset) =>
|
||||||
assetInteraction.hasSelectedAsset(asset.id),
|
assetInteraction.hasSelectedAsset(asset.id),
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
// if all assets are selected in a group, add the group to selected group
|
// if all assets are selected in a group, add the group to selected group
|
||||||
if (selectedAssetsInGroupCount == assetsInDateGroup.length) {
|
if (selectedAssetsInGroupCount == assetsInDayGroup.length) {
|
||||||
assetInteraction.addGroupToMultiselectGroup(groupTitle);
|
assetInteraction.addGroupToMultiselectGroup(groupTitle);
|
||||||
} else {
|
} else {
|
||||||
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
|
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (assetStore.count == assetInteraction.selectedAssets.length) {
|
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
|
||||||
isSelectingAllAssets.set(true);
|
isSelectingAllAssets.set(true);
|
||||||
} else {
|
} else {
|
||||||
isSelectingAllAssets.set(false);
|
isSelectingAllAssets.set(false);
|
||||||
@ -90,7 +97,7 @@
|
|||||||
|
|
||||||
const assetMouseEventHandler = (groupTitle: string, asset: TimelineAsset | null) => {
|
const assetMouseEventHandler = (groupTitle: string, asset: TimelineAsset | null) => {
|
||||||
// Show multi select icon on hover on date group
|
// Show multi select icon on hover on date group
|
||||||
hoveredDateGroup = groupTitle;
|
hoveredDayGroup = groupTitle;
|
||||||
|
|
||||||
if (assetInteraction.selectionActive) {
|
if (assetInteraction.selectionActive) {
|
||||||
onSelectAssetCandidates(asset);
|
onSelectAssetCandidates(asset);
|
||||||
@ -102,47 +109,47 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$effect.root(() => {
|
$effect.root(() => {
|
||||||
if (assetStore.scrollCompensation.bucket === bucket) {
|
if (timelineManager.scrollCompensation.monthGroup === monthGroup) {
|
||||||
onScrollCompensation(assetStore.scrollCompensation);
|
onScrollCompensation(timelineManager.scrollCompensation);
|
||||||
assetStore.clearScrollCompensation();
|
timelineManager.clearScrollCompensation();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#each filterIntersecting(bucket.dateGroups) as dateGroup, groupIndex (dateGroup.day)}
|
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
|
||||||
{@const absoluteWidth = dateGroup.left}
|
{@const absoluteWidth = dayGroup.left}
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<section
|
<section
|
||||||
class={[
|
class={[
|
||||||
{ 'transition-all': !bucket.store.suspendTransitions },
|
{ 'transition-all': !monthGroup.timelineManager.suspendTransitions },
|
||||||
!bucket.store.suspendTransitions && `delay-${transitionDuration}`,
|
!monthGroup.timelineManager.suspendTransitions && `delay-${transitionDuration}`,
|
||||||
]}
|
]}
|
||||||
data-group
|
data-group
|
||||||
style:position="absolute"
|
style:position="absolute"
|
||||||
style:transform={`translate3d(${absoluteWidth}px,${dateGroup.top}px,0)`}
|
style:transform={`translate3d(${absoluteWidth}px,${dayGroup.top}px,0)`}
|
||||||
onmouseenter={() => {
|
onmouseenter={() => {
|
||||||
isMouseOverGroup = true;
|
isMouseOverGroup = true;
|
||||||
assetMouseEventHandler(dateGroup.groupTitle, null);
|
assetMouseEventHandler(dayGroup.groupTitle, null);
|
||||||
}}
|
}}
|
||||||
onmouseleave={() => {
|
onmouseleave={() => {
|
||||||
isMouseOverGroup = false;
|
isMouseOverGroup = false;
|
||||||
assetMouseEventHandler(dateGroup.groupTitle, null);
|
assetMouseEventHandler(dayGroup.groupTitle, null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<!-- Date group title -->
|
<!-- Date group title -->
|
||||||
<div
|
<div
|
||||||
class="flex pt-7 pb-5 max-md:pt-5 max-md:pb-3 h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm"
|
class="flex pt-7 pb-5 max-md:pt-5 max-md:pb-3 h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm"
|
||||||
style:width={dateGroup.width + 'px'}
|
style:width={dayGroup.width + 'px'}
|
||||||
>
|
>
|
||||||
{#if !singleSelect && ((hoveredDateGroup === dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dateGroup.groupTitle))}
|
{#if !singleSelect && ((hoveredDayGroup === dayGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dayGroup.groupTitle))}
|
||||||
<div
|
<div
|
||||||
transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
|
transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
|
||||||
class="inline-block px-2 hover:cursor-pointer"
|
class="inline-block px-2 hover:cursor-pointer"
|
||||||
onclick={() => handleSelectGroup(dateGroup.groupTitle, assetsSnapshot(dateGroup.getAssets()))}
|
onclick={() => handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))}
|
||||||
onkeydown={() => handleSelectGroup(dateGroup.groupTitle, assetsSnapshot(dateGroup.getAssets()))}
|
onkeydown={() => handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))}
|
||||||
>
|
>
|
||||||
{#if assetInteraction.selectedGroup.has(dateGroup.groupTitle)}
|
{#if assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
|
||||||
<Icon path={mdiCheckCircle} size="24" color="#4250af" />
|
<Icon path={mdiCheckCircle} size="24" color="#4250af" />
|
||||||
{:else}
|
{:else}
|
||||||
<Icon path={mdiCircleOutline} size="24" color="#757575" />
|
<Icon path={mdiCircleOutline} size="24" color="#757575" />
|
||||||
@ -150,8 +157,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<span class="w-full truncate first-letter:capitalize ms-2.5" title={dateGroup.groupTitle}>
|
<span class="w-full truncate first-letter:capitalize ms-2.5" title={dayGroup.groupTitle}>
|
||||||
{dateGroup.groupTitle}
|
{dayGroup.groupTitle}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -159,14 +166,14 @@
|
|||||||
<div
|
<div
|
||||||
data-image-grid
|
data-image-grid
|
||||||
class="relative overflow-clip"
|
class="relative overflow-clip"
|
||||||
style:height={dateGroup.height + 'px'}
|
style:height={dayGroup.height + 'px'}
|
||||||
style:width={dateGroup.width + 'px'}
|
style:width={dayGroup.width + 'px'}
|
||||||
>
|
>
|
||||||
{#each filterIntersecting(dateGroup.intersectingAssets) as intersectingAsset (intersectingAsset.id)}
|
{#each filterIntersecting(dayGroup.viewerAssets) as viewerAsset (viewerAsset.id)}
|
||||||
{@const position = intersectingAsset.position!}
|
{@const position = viewerAsset.position!}
|
||||||
{@const asset = intersectingAsset.asset!}
|
{@const asset = viewerAsset.asset!}
|
||||||
|
|
||||||
<!-- {#if intersectingAsset.intersecting} -->
|
<!-- {#if viewerAsset.intersecting} -->
|
||||||
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
|
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
|
||||||
<div
|
<div
|
||||||
data-asset-id={asset.id}
|
data-asset-id={asset.id}
|
||||||
@ -183,12 +190,13 @@
|
|||||||
{showArchiveIcon}
|
{showArchiveIcon}
|
||||||
{asset}
|
{asset}
|
||||||
{groupIndex}
|
{groupIndex}
|
||||||
onClick={(asset) => onClick(assetStore, dateGroup.getAssets(), dateGroup.groupTitle, asset)}
|
onClick={(asset) => onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset)}
|
||||||
onSelect={(asset) => assetSelectHandler(assetStore, asset, dateGroup.getAssets(), dateGroup.groupTitle)}
|
onSelect={(asset) => assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle)}
|
||||||
onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, assetSnapshot(asset))}
|
onMouseEvent={() => assetMouseEventHandler(dayGroup.groupTitle, assetSnapshot(asset))}
|
||||||
selected={assetInteraction.hasSelectedAsset(asset.id) || dateGroup.bucket.store.albumAssets.has(asset.id)}
|
selected={assetInteraction.hasSelectedAsset(asset.id) ||
|
||||||
|
dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)}
|
||||||
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
|
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
|
||||||
disabled={dateGroup.bucket.store.albumAssets.has(asset.id)}
|
disabled={dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)}
|
||||||
thumbnailWidth={position.width}
|
thumbnailWidth={position.width}
|
||||||
thumbnailHeight={position.height}
|
thumbnailHeight={position.height}
|
||||||
/>
|
/>
|
||||||
|
@ -15,18 +15,18 @@
|
|||||||
import { albumMapViewManager } from '$lib/managers/album-view-map.manager.svelte';
|
import { albumMapViewManager } from '$lib/managers/album-view-map.manager.svelte';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
import { modalManager } from '$lib/managers/modal-manager.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 ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
||||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||||
import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
|
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
|
||||||
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
|
||||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||||
import { showDeleteModal } from '$lib/stores/preferences.store';
|
import { showDeleteModal } from '$lib/stores/preferences.store';
|
||||||
import { searchStore } from '$lib/stores/search.svelte';
|
import { searchStore } from '$lib/stores/search.svelte';
|
||||||
import { featureFlags } from '$lib/stores/server-config.store';
|
import { featureFlags } from '$lib/stores/server-config.store';
|
||||||
import type { AssetBucket } from '$lib/managers/timeline-manager/asset-bucket.svelte';
|
|
||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
|
import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
|
||||||
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
|
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
|
||||||
@ -47,7 +47,7 @@
|
|||||||
`AssetViewingStore.gridScrollTarget` and load and scroll to the asset specified, and
|
`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 */
|
additionally, update the page location/url with the asset as the asset-grid is scrolled */
|
||||||
enableRouting: boolean;
|
enableRouting: boolean;
|
||||||
assetStore: AssetStore;
|
timelineManager: TimelineManager;
|
||||||
assetInteraction: AssetInteraction;
|
assetInteraction: AssetInteraction;
|
||||||
removeAction?:
|
removeAction?:
|
||||||
| AssetAction.UNARCHIVE
|
| AssetAction.UNARCHIVE
|
||||||
@ -72,7 +72,7 @@
|
|||||||
isSelectionMode = false,
|
isSelectionMode = false,
|
||||||
singleSelect = false,
|
singleSelect = false,
|
||||||
enableRouting,
|
enableRouting,
|
||||||
assetStore = $bindable(),
|
timelineManager = $bindable(),
|
||||||
assetInteraction,
|
assetInteraction,
|
||||||
removeAction = null,
|
removeAction = null,
|
||||||
withStacked = false,
|
withStacked = false,
|
||||||
@ -94,8 +94,8 @@
|
|||||||
let timelineElement: HTMLElement | undefined = $state();
|
let timelineElement: HTMLElement | undefined = $state();
|
||||||
let showSkeleton = $state(true);
|
let showSkeleton = $state(true);
|
||||||
let isShowSelectDate = $state(false);
|
let isShowSelectDate = $state(false);
|
||||||
let scrubBucketPercent = $state(0);
|
let scrubberMonthPercent = $state(0);
|
||||||
let scrubBucket: TimelinePlainYearMonth | undefined = $state();
|
let scrubberMonth: { year: number; month: number } | undefined = $state(undefined);
|
||||||
let scrubOverallPercent: number = $state(0);
|
let scrubOverallPercent: number = $state(0);
|
||||||
let scrubberWidth = $state(0);
|
let scrubberWidth = $state(0);
|
||||||
|
|
||||||
@ -116,7 +116,7 @@
|
|||||||
rowHeight: 235,
|
rowHeight: 235,
|
||||||
headerHeight: 48,
|
headerHeight: 48,
|
||||||
};
|
};
|
||||||
assetStore.setLayoutOptions(layoutOptions);
|
timelineManager.setLayoutOptions(layoutOptions);
|
||||||
});
|
});
|
||||||
|
|
||||||
const scrollTo = (top: number) => {
|
const scrollTo = (top: number) => {
|
||||||
@ -138,35 +138,35 @@
|
|||||||
scrollTo(0);
|
scrollTo(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAssetHeight = (assetId: string, bucket: AssetBucket) => {
|
const getAssetHeight = (assetId: string, monthGroup: MonthGroup) => {
|
||||||
// the following method may trigger any layouts, so need to
|
// the following method may trigger any layouts, so need to
|
||||||
// handle any scroll compensation that may have been set
|
// handle any scroll compensation that may have been set
|
||||||
const height = bucket!.findAssetAbsolutePosition(assetId);
|
const height = monthGroup!.findAssetAbsolutePosition(assetId);
|
||||||
|
|
||||||
while (assetStore.scrollCompensation.bucket) {
|
while (timelineManager.scrollCompensation.monthGroup) {
|
||||||
handleScrollCompensation(assetStore.scrollCompensation);
|
handleScrollCompensation(timelineManager.scrollCompensation);
|
||||||
assetStore.clearScrollCompensation();
|
timelineManager.clearScrollCompensation();
|
||||||
}
|
}
|
||||||
return height;
|
return height;
|
||||||
};
|
};
|
||||||
|
|
||||||
const scrollToAssetId = async (assetId: string) => {
|
const scrollToAssetId = async (assetId: string) => {
|
||||||
const bucket = await assetStore.findBucketForAsset(assetId);
|
const monthGroup = await timelineManager.findMonthGroupForAsset(assetId);
|
||||||
if (!bucket) {
|
if (!monthGroup) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const height = getAssetHeight(assetId, bucket);
|
const height = getAssetHeight(assetId, monthGroup);
|
||||||
scrollTo(height);
|
scrollTo(height);
|
||||||
updateSlidingWindow();
|
updateSlidingWindow();
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const scrollToAsset = (asset: TimelineAsset) => {
|
const scrollToAsset = (asset: TimelineAsset) => {
|
||||||
const bucket = assetStore.getBucketIndexByAssetId(asset.id);
|
const monthGroup = timelineManager.getMonthGroupByAssetId(asset.id);
|
||||||
if (!bucket) {
|
if (!monthGroup) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const height = getAssetHeight(asset.id, bucket);
|
const height = getAssetHeight(asset.id, monthGroup);
|
||||||
scrollTo(height);
|
scrollTo(height);
|
||||||
updateSlidingWindow();
|
updateSlidingWindow();
|
||||||
return true;
|
return true;
|
||||||
@ -185,7 +185,7 @@
|
|||||||
showSkeleton = false;
|
showSkeleton = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeNavigate(() => (assetStore.suspendTransitions = true));
|
beforeNavigate(() => (timelineManager.suspendTransitions = true));
|
||||||
|
|
||||||
afterNavigate((nav) => {
|
afterNavigate((nav) => {
|
||||||
const { complete } = nav;
|
const { complete } = nav;
|
||||||
@ -224,7 +224,7 @@
|
|||||||
import.meta.hot?.on('vite:beforeUpdate', (payload) => {
|
import.meta.hot?.on('vite:beforeUpdate', (payload) => {
|
||||||
const assetGridUpdate = payload.updates.some((update) => update.path.endsWith('asset-grid.svelte'));
|
const assetGridUpdate = payload.updates.some((update) => update.path.endsWith('asset-grid.svelte'));
|
||||||
if (assetGridUpdate) {
|
if (assetGridUpdate) {
|
||||||
assetStore.destroy();
|
timelineManager.destroy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -233,9 +233,9 @@
|
|||||||
return () => void 0;
|
return () => void 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateIsScrolling = () => (assetStore.scrolling = true);
|
const updateIsScrolling = () => (timelineManager.scrolling = true);
|
||||||
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
|
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
|
||||||
const updateSlidingWindow = () => assetStore.updateSlidingWindow(element?.scrollTop || 0);
|
const updateSlidingWindow = () => timelineManager.updateSlidingWindow(element?.scrollTop || 0);
|
||||||
|
|
||||||
const handleScrollCompensation = ({ heightDelta, scrollTop }: { heightDelta?: number; scrollTop?: number }) => {
|
const handleScrollCompensation = ({ heightDelta, scrollTop }: { heightDelta?: number; scrollTop?: number }) => {
|
||||||
if (heightDelta !== undefined) {
|
if (heightDelta !== undefined) {
|
||||||
@ -245,12 +245,12 @@
|
|||||||
}
|
}
|
||||||
// Yes, updateSlideWindow() is called by the onScroll event triggered as a result of
|
// 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
|
// the above calls. However, this delay is enough time to set the intersecting property
|
||||||
// of the bucket to false, then true, which causes the DOM nodes to be recreated,
|
// 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.
|
// causing bad perf, and also, disrupting focus of those elements.
|
||||||
updateSlidingWindow();
|
updateSlidingWindow();
|
||||||
};
|
};
|
||||||
|
|
||||||
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (assetStore.topSectionHeight = height);
|
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!enableRouting) {
|
if (!enableRouting) {
|
||||||
@ -263,21 +263,23 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const getMaxScrollPercent = () => {
|
const getMaxScrollPercent = () => {
|
||||||
const totalHeight = assetStore.timelineHeight + bottomSectionHeight + assetStore.topSectionHeight;
|
const totalHeight = timelineManager.timelineHeight + bottomSectionHeight + timelineManager.topSectionHeight;
|
||||||
return (totalHeight - assetStore.viewportHeight) / totalHeight;
|
return (totalHeight - timelineManager.viewportHeight) / totalHeight;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMaxScroll = () => {
|
const getMaxScroll = () => {
|
||||||
if (!element || !timelineElement) {
|
if (!element || !timelineElement) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
return assetStore.topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight);
|
return (
|
||||||
|
timelineManager.topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const scrollToBucketAndOffset = (bucket: AssetBucket, bucketScrollPercent: number) => {
|
const scrollToMonthGroupAndOffset = (monthGroup: MonthGroup, monthGroupScrollPercent: number) => {
|
||||||
const topOffset = bucket.top;
|
const topOffset = monthGroup.top;
|
||||||
const maxScrollPercent = getMaxScrollPercent();
|
const maxScrollPercent = getMaxScrollPercent();
|
||||||
const delta = bucket.bucketHeight * bucketScrollPercent;
|
const delta = monthGroup.height * monthGroupScrollPercent;
|
||||||
const scrollToTop = (topOffset + delta) * maxScrollPercent;
|
const scrollToTop = (topOffset + delta) * maxScrollPercent;
|
||||||
|
|
||||||
scrollTop(scrollToTop);
|
scrollTop(scrollToTop);
|
||||||
@ -285,23 +287,23 @@
|
|||||||
|
|
||||||
// note: don't throttle, debounch, or otherwise make this function async - it causes flicker
|
// note: don't throttle, debounch, or otherwise make this function async - it causes flicker
|
||||||
const onScrub: ScrubberListener = (
|
const onScrub: ScrubberListener = (
|
||||||
bucketDate: { year: number; month: number } | undefined,
|
scrubMonth: { year: number; month: number },
|
||||||
scrollPercent: number,
|
overallScrollPercent: number,
|
||||||
bucketScrollPercent: number,
|
scrubberMonthScrollPercent: number,
|
||||||
) => {
|
) => {
|
||||||
if (!bucketDate || assetStore.timelineHeight < assetStore.viewportHeight * 2) {
|
if (!scrubMonth || timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
|
||||||
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
|
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
|
||||||
const maxScroll = getMaxScroll();
|
const maxScroll = getMaxScroll();
|
||||||
const offset = maxScroll * scrollPercent;
|
const offset = maxScroll * overallScrollPercent;
|
||||||
scrollTop(offset);
|
scrollTop(offset);
|
||||||
} else {
|
} else {
|
||||||
const bucket = assetStore.buckets.find(
|
const monthGroup = timelineManager.months.find(
|
||||||
(bucket) => bucket.yearMonth.year === bucketDate.year && bucket.yearMonth.month === bucketDate.month,
|
({ yearMonth: { year, month } }) => year === scrubMonth.year && month === scrubMonth.month,
|
||||||
);
|
);
|
||||||
if (!bucket) {
|
if (!monthGroup) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
scrollToBucketAndOffset(bucket, bucketScrollPercent);
|
scrollToMonthGroupAndOffset(monthGroup, scrubberMonthScrollPercent);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -313,19 +315,19 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (assetStore.timelineHeight < assetStore.viewportHeight * 2) {
|
if (timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
|
||||||
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead
|
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead
|
||||||
const maxScroll = getMaxScroll();
|
const maxScroll = getMaxScroll();
|
||||||
scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll);
|
scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll);
|
||||||
|
|
||||||
scrubBucket = undefined;
|
scrubberMonth = undefined;
|
||||||
scrubBucketPercent = 0;
|
scrubberMonthPercent = 0;
|
||||||
} else {
|
} else {
|
||||||
let top = element.scrollTop;
|
let top = element.scrollTop;
|
||||||
if (top < assetStore.topSectionHeight) {
|
if (top < timelineManager.topSectionHeight) {
|
||||||
// in the lead-in area
|
// in the lead-in area
|
||||||
scrubBucket = undefined;
|
scrubberMonth = undefined;
|
||||||
scrubBucketPercent = 0;
|
scrubberMonthPercent = 0;
|
||||||
const maxScroll = getMaxScroll();
|
const maxScroll = getMaxScroll();
|
||||||
|
|
||||||
scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll);
|
scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll);
|
||||||
@ -335,33 +337,33 @@
|
|||||||
let maxScrollPercent = getMaxScrollPercent();
|
let maxScrollPercent = getMaxScrollPercent();
|
||||||
let found = false;
|
let found = false;
|
||||||
|
|
||||||
const bucketsLength = assetStore.buckets.length;
|
const monthsLength = timelineManager.months.length;
|
||||||
for (let i = -1; i < bucketsLength + 1; i++) {
|
for (let i = -1; i < monthsLength + 1; i++) {
|
||||||
let bucket: TimelinePlainYearMonth | undefined;
|
let monthGroup: TimelinePlainYearMonth | undefined;
|
||||||
let bucketHeight = 0;
|
let monthGroupHeight = 0;
|
||||||
if (i === -1) {
|
if (i === -1) {
|
||||||
// lead-in
|
// lead-in
|
||||||
bucketHeight = assetStore.topSectionHeight;
|
monthGroupHeight = timelineManager.topSectionHeight;
|
||||||
} else if (i === bucketsLength) {
|
} else if (i === monthsLength) {
|
||||||
// lead-out
|
// lead-out
|
||||||
bucketHeight = bottomSectionHeight;
|
monthGroupHeight = bottomSectionHeight;
|
||||||
} else {
|
} else {
|
||||||
bucket = assetStore.buckets[i].yearMonth;
|
monthGroup = timelineManager.months[i].yearMonth;
|
||||||
bucketHeight = assetStore.buckets[i].bucketHeight;
|
monthGroupHeight = timelineManager.months[i].height;
|
||||||
}
|
}
|
||||||
|
|
||||||
let next = top - bucketHeight * maxScrollPercent;
|
let next = top - monthGroupHeight * maxScrollPercent;
|
||||||
// instead of checking for < 0, add a little wiggle room for subpixel resolution
|
// instead of checking for < 0, add a little wiggle room for subpixel resolution
|
||||||
if (next < -1 && bucket) {
|
if (next < -1 && monthGroup) {
|
||||||
scrubBucket = bucket;
|
scrubberMonth = monthGroup;
|
||||||
|
|
||||||
// allowing next to be at least 1 may cause percent to go negative, so ensure positive percentage
|
// allowing next to be at least 1 may cause percent to go negative, so ensure positive percentage
|
||||||
scrubBucketPercent = Math.max(0, top / (bucketHeight * maxScrollPercent));
|
scrubberMonthPercent = Math.max(0, top / (monthGroupHeight * maxScrollPercent));
|
||||||
|
|
||||||
// compensate for lost precision/rounding errors advance to the next bucket, if present
|
// compensate for lost precision/rounding errors advance to the next bucket, if present
|
||||||
if (scrubBucketPercent > 0.9999 && i + 1 < bucketsLength - 1) {
|
if (scrubberMonthPercent > 0.9999 && i + 1 < monthsLength - 1) {
|
||||||
scrubBucket = assetStore.buckets[i + 1].yearMonth;
|
scrubberMonth = timelineManager.months[i + 1].yearMonth;
|
||||||
scrubBucketPercent = 0;
|
scrubberMonthPercent = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
found = true;
|
found = true;
|
||||||
@ -371,8 +373,8 @@
|
|||||||
}
|
}
|
||||||
if (!found) {
|
if (!found) {
|
||||||
leadout = true;
|
leadout = true;
|
||||||
scrubBucket = undefined;
|
scrubberMonth = undefined;
|
||||||
scrubBucketPercent = 0;
|
scrubberMonthPercent = 0;
|
||||||
scrubOverallPercent = 1;
|
scrubOverallPercent = 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -382,9 +384,9 @@
|
|||||||
isShowDeleteConfirmation = false;
|
isShowDeleteConfirmation = false;
|
||||||
await deleteAssets(
|
await deleteAssets(
|
||||||
!(isTrashEnabled && !force),
|
!(isTrashEnabled && !force),
|
||||||
(assetIds) => assetStore.removeAssets(assetIds),
|
(assetIds) => timelineManager.removeAssets(assetIds),
|
||||||
assetInteraction.selectedAssets,
|
assetInteraction.selectedAssets,
|
||||||
!isTrashEnabled || force ? undefined : (assets) => assetStore.addAssets(assets),
|
!isTrashEnabled || force ? undefined : (assets) => timelineManager.addAssets(assets),
|
||||||
);
|
);
|
||||||
assetInteraction.clearMultiselect();
|
assetInteraction.clearMultiselect();
|
||||||
};
|
};
|
||||||
@ -410,31 +412,32 @@
|
|||||||
const onStackAssets = async () => {
|
const onStackAssets = async () => {
|
||||||
const result = await stackAssets(assetInteraction.selectedAssets);
|
const result = await stackAssets(assetInteraction.selectedAssets);
|
||||||
|
|
||||||
updateStackedAssetInTimeline(assetStore, result);
|
updateStackedAssetInTimeline(timelineManager, result);
|
||||||
|
|
||||||
onEscape();
|
onEscape();
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleArchive = async () => {
|
const toggleArchive = async () => {
|
||||||
await archiveAssets(
|
const visibility = assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive;
|
||||||
assetInteraction.selectedAssets,
|
const ids = await archiveAssets(assetInteraction.selectedAssets, visibility);
|
||||||
assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive,
|
timelineManager.updateAssetOperation(ids, (asset) => {
|
||||||
);
|
asset.visibility = visibility;
|
||||||
assetStore.updateAssets(assetInteraction.selectedAssets);
|
return { remove: false };
|
||||||
|
});
|
||||||
deselectAllAssets();
|
deselectAllAssets();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectAsset = (asset: TimelineAsset) => {
|
const handleSelectAsset = (asset: TimelineAsset) => {
|
||||||
if (!assetStore.albumAssets.has(asset.id)) {
|
if (!timelineManager.albumAssets.has(asset.id)) {
|
||||||
assetInteraction.selectAsset(asset);
|
assetInteraction.selectAsset(asset);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePrevious = async () => {
|
const handlePrevious = async () => {
|
||||||
const laterAsset = await assetStore.getLaterAsset($viewingAsset);
|
const laterAsset = await timelineManager.getLaterAsset($viewingAsset);
|
||||||
|
|
||||||
if (laterAsset) {
|
if (laterAsset) {
|
||||||
const preloadAsset = await assetStore.getLaterAsset(laterAsset);
|
const preloadAsset = await timelineManager.getLaterAsset(laterAsset);
|
||||||
const asset = await getAssetInfo({ id: laterAsset.id, key: authManager.key });
|
const asset = await getAssetInfo({ id: laterAsset.id, key: authManager.key });
|
||||||
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
|
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
|
||||||
await navigate({ targetRoute: 'current', assetId: laterAsset.id });
|
await navigate({ targetRoute: 'current', assetId: laterAsset.id });
|
||||||
@ -444,9 +447,9 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleNext = async () => {
|
const handleNext = async () => {
|
||||||
const earlierAsset = await assetStore.getEarlierAsset($viewingAsset);
|
const earlierAsset = await timelineManager.getEarlierAsset($viewingAsset);
|
||||||
if (earlierAsset) {
|
if (earlierAsset) {
|
||||||
const preloadAsset = await assetStore.getEarlierAsset(earlierAsset);
|
const preloadAsset = await timelineManager.getEarlierAsset(earlierAsset);
|
||||||
const asset = await getAssetInfo({ id: earlierAsset.id, key: authManager.key });
|
const asset = await getAssetInfo({ id: earlierAsset.id, key: authManager.key });
|
||||||
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
|
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
|
||||||
await navigate({ targetRoute: 'current', assetId: earlierAsset.id });
|
await navigate({ targetRoute: 'current', assetId: earlierAsset.id });
|
||||||
@ -456,7 +459,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRandom = async () => {
|
const handleRandom = async () => {
|
||||||
const randomAsset = await assetStore.getRandomAsset();
|
const randomAsset = await timelineManager.getRandomAsset();
|
||||||
|
|
||||||
if (randomAsset) {
|
if (randomAsset) {
|
||||||
const asset = await getAssetInfo({ id: randomAsset.id, key: authManager.key });
|
const asset = await getAssetInfo({ id: randomAsset.id, key: authManager.key });
|
||||||
@ -487,7 +490,7 @@
|
|||||||
(await handleNext()) || (await handlePrevious()) || (await handleClose(action.asset));
|
(await handleNext()) || (await handlePrevious()) || (await handleClose(action.asset));
|
||||||
|
|
||||||
// delete after find the next one
|
// delete after find the next one
|
||||||
assetStore.removeAssets([action.asset.id]);
|
timelineManager.removeAssets([action.asset.id]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -498,26 +501,26 @@
|
|||||||
case AssetAction.UNARCHIVE:
|
case AssetAction.UNARCHIVE:
|
||||||
case AssetAction.FAVORITE:
|
case AssetAction.FAVORITE:
|
||||||
case AssetAction.UNFAVORITE: {
|
case AssetAction.UNFAVORITE: {
|
||||||
assetStore.updateAssets([action.asset]);
|
timelineManager.updateAssets([action.asset]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case AssetAction.ADD: {
|
case AssetAction.ADD: {
|
||||||
assetStore.addAssets([action.asset]);
|
timelineManager.addAssets([action.asset]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case AssetAction.UNSTACK: {
|
case AssetAction.UNSTACK: {
|
||||||
updateUnstackedAssetInTimeline(assetStore, action.assets);
|
updateUnstackedAssetInTimeline(timelineManager, action.assets);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case AssetAction.SET_STACK_PRIMARY_ASSET: {
|
case AssetAction.SET_STACK_PRIMARY_ASSET: {
|
||||||
//Have to unstack then restack assets in timeline in order for the currently removed new primary asset to be made visible.
|
//Have to unstack then restack assets in timeline in order for the currently removed new primary asset to be made visible.
|
||||||
updateUnstackedAssetInTimeline(
|
updateUnstackedAssetInTimeline(
|
||||||
assetStore,
|
timelineManager,
|
||||||
action.stack.assets.map((asset) => toTimelineAsset(asset)),
|
action.stack.assets.map((asset) => toTimelineAsset(asset)),
|
||||||
);
|
);
|
||||||
updateStackedAssetInTimeline(assetStore, {
|
updateStackedAssetInTimeline(timelineManager, {
|
||||||
stack: action.stack,
|
stack: action.stack,
|
||||||
toDeleteIds: action.stack.assets
|
toDeleteIds: action.stack.assets
|
||||||
.filter((asset) => asset.id !== action.stack.primaryAssetId)
|
.filter((asset) => asset.id !== action.stack.primaryAssetId)
|
||||||
@ -565,7 +568,7 @@
|
|||||||
lastAssetMouseEvent = asset;
|
lastAssetMouseEvent = asset;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGroupSelect = (assetStore: AssetStore, group: string, assets: TimelineAsset[]) => {
|
const handleGroupSelect = (timelineManager: TimelineManager, group: string, assets: TimelineAsset[]) => {
|
||||||
if (assetInteraction.selectedGroup.has(group)) {
|
if (assetInteraction.selectedGroup.has(group)) {
|
||||||
assetInteraction.removeGroupFromMultiselectGroup(group);
|
assetInteraction.removeGroupFromMultiselectGroup(group);
|
||||||
for (const asset of assets) {
|
for (const asset of assets) {
|
||||||
@ -578,7 +581,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (assetStore.count == assetInteraction.selectedAssets.length) {
|
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
|
||||||
isSelectingAllAssets.set(true);
|
isSelectingAllAssets.set(true);
|
||||||
} else {
|
} else {
|
||||||
isSelectingAllAssets.set(false);
|
isSelectingAllAssets.set(false);
|
||||||
@ -615,8 +618,8 @@
|
|||||||
assetInteraction.clearAssetSelectionCandidates();
|
assetInteraction.clearAssetSelectionCandidates();
|
||||||
|
|
||||||
if (assetInteraction.assetSelectionStart && rangeSelection) {
|
if (assetInteraction.assetSelectionStart && rangeSelection) {
|
||||||
let startBucket = assetStore.getBucketIndexByAssetId(assetInteraction.assetSelectionStart.id);
|
let startBucket = timelineManager.getMonthGroupByAssetId(assetInteraction.assetSelectionStart.id);
|
||||||
let endBucket = assetStore.getBucketIndexByAssetId(asset.id);
|
let endBucket = timelineManager.getMonthGroupByAssetId(asset.id);
|
||||||
|
|
||||||
if (startBucket === null || endBucket === null) {
|
if (startBucket === null || endBucket === null) {
|
||||||
return;
|
return;
|
||||||
@ -624,13 +627,13 @@
|
|||||||
|
|
||||||
// Select/deselect assets in range (start,end)
|
// Select/deselect assets in range (start,end)
|
||||||
let started = false;
|
let started = false;
|
||||||
for (const bucket of assetStore.buckets) {
|
for (const monthGroup of timelineManager.months) {
|
||||||
if (bucket === endBucket) {
|
if (monthGroup === endBucket) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (started) {
|
if (started) {
|
||||||
await assetStore.loadBucket(bucket.yearMonth);
|
await timelineManager.loadMonthGroup(monthGroup.yearMonth);
|
||||||
for (const asset of bucket.assetsIterator()) {
|
for (const asset of monthGroup.assetsIterator()) {
|
||||||
if (deselect) {
|
if (deselect) {
|
||||||
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
|
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
|
||||||
} else {
|
} else {
|
||||||
@ -638,29 +641,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (bucket === startBucket) {
|
if (monthGroup === startBucket) {
|
||||||
started = true;
|
started = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update date group selection in range [start,end]
|
// Update date group selection in range [start,end]
|
||||||
started = false;
|
started = false;
|
||||||
for (const bucket of assetStore.buckets) {
|
for (const monthGroup of timelineManager.months) {
|
||||||
if (bucket === startBucket) {
|
if (monthGroup === startBucket) {
|
||||||
started = true;
|
started = true;
|
||||||
}
|
}
|
||||||
if (started) {
|
if (started) {
|
||||||
// Split bucket into date groups and check each group
|
// Split month group into day groups and check each group
|
||||||
for (const dateGroup of bucket.dateGroups) {
|
for (const dayGroup of monthGroup.dayGroups) {
|
||||||
const dateGroupTitle = dateGroup.groupTitle;
|
const dayGroupTitle = dayGroup.groupTitle;
|
||||||
if (dateGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
|
if (dayGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
|
||||||
assetInteraction.addGroupToMultiselectGroup(dateGroupTitle);
|
assetInteraction.addGroupToMultiselectGroup(dayGroupTitle);
|
||||||
} else {
|
} else {
|
||||||
assetInteraction.removeGroupFromMultiselectGroup(dateGroupTitle);
|
assetInteraction.removeGroupFromMultiselectGroup(dayGroupTitle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (bucket === endBucket) {
|
if (monthGroup === endBucket) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -679,7 +682,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const assets = assetsSnapshot(await assetStore.retrieveRange(startAsset, endAsset));
|
const assets = assetsSnapshot(await timelineManager.retrieveRange(startAsset, endAsset));
|
||||||
assetInteraction.setAssetSelectionCandidates(assets);
|
assetInteraction.setAssetSelectionCandidates(assets);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -690,7 +693,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
|
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
|
||||||
let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0);
|
let isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
||||||
let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
|
let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
|
||||||
let isShortcutModalOpen = false;
|
let isShortcutModalOpen = false;
|
||||||
|
|
||||||
@ -710,7 +713,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, assetStore);
|
const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, timelineManager);
|
||||||
const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset);
|
const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset);
|
||||||
|
|
||||||
let shortcutList = $derived(
|
let shortcutList = $derived(
|
||||||
@ -723,7 +726,7 @@
|
|||||||
{ shortcut: { key: 'Escape' }, onShortcut: onEscape },
|
{ shortcut: { key: 'Escape' }, onShortcut: onEscape },
|
||||||
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
|
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
|
||||||
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
|
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
|
||||||
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteraction) },
|
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(timelineManager, assetInteraction) },
|
||||||
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => setFocusTo('earlier', 'asset') },
|
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => setFocusTo('earlier', 'asset') },
|
||||||
{ shortcut: { key: 'ArrowLeft' }, onShortcut: () => setFocusTo('later', 'asset') },
|
{ shortcut: { key: 'ArrowLeft' }, onShortcut: () => setFocusTo('later', 'asset') },
|
||||||
{ shortcut: { key: 'D' }, onShortcut: () => setFocusTo('earlier', 'day') },
|
{ shortcut: { key: 'D' }, onShortcut: () => setFocusTo('earlier', 'day') },
|
||||||
@ -785,7 +788,9 @@
|
|||||||
timezoneInput={false}
|
timezoneInput={false}
|
||||||
onConfirm={async (dateString: string) => {
|
onConfirm={async (dateString: string) => {
|
||||||
isShowSelectDate = false;
|
isShowSelectDate = false;
|
||||||
const asset = await assetStore.getClosestAssetToDate((DateTime.fromISO(dateString) as DateTime<true>).toObject());
|
const asset = await timelineManager.getClosestAssetToDate(
|
||||||
|
(DateTime.fromISO(dateString) as DateTime<true>).toObject(),
|
||||||
|
);
|
||||||
if (asset) {
|
if (asset) {
|
||||||
setFocusAsset(asset);
|
setFocusAsset(asset);
|
||||||
}
|
}
|
||||||
@ -794,16 +799,16 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if assetStore.buckets.length > 0}
|
{#if timelineManager.months.length > 0}
|
||||||
<Scrubber
|
<Scrubber
|
||||||
{assetStore}
|
{timelineManager}
|
||||||
height={assetStore.viewportHeight}
|
height={timelineManager.viewportHeight}
|
||||||
timelineTopOffset={assetStore.topSectionHeight}
|
timelineTopOffset={timelineManager.topSectionHeight}
|
||||||
timelineBottomOffset={bottomSectionHeight}
|
timelineBottomOffset={bottomSectionHeight}
|
||||||
{leadout}
|
{leadout}
|
||||||
{scrubOverallPercent}
|
{scrubOverallPercent}
|
||||||
{scrubBucketPercent}
|
{scrubberMonthPercent}
|
||||||
{scrubBucket}
|
{scrubberMonth}
|
||||||
{onScrub}
|
{onScrub}
|
||||||
bind:scrubberWidth
|
bind:scrubberWidth
|
||||||
onScrubKeyDown={(evt) => {
|
onScrubKeyDown={(evt) => {
|
||||||
@ -824,14 +829,14 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Right margin MUST be equal to the width of immich-scrubbable-scrollbar -->
|
<!-- Right margin MUST be equal to the width of scrubber -->
|
||||||
<section
|
<section
|
||||||
id="asset-grid"
|
id="asset-grid"
|
||||||
class={['scrollbar-hidden h-full overflow-y-auto outline-none', { 'm-0': isEmpty }, { 'ms-0': !isEmpty }]}
|
class={['scrollbar-hidden h-full overflow-y-auto outline-none', { 'm-0': isEmpty }, { 'ms-0': !isEmpty }]}
|
||||||
style:margin-right={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
|
style:margin-right={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
bind:clientHeight={assetStore.viewportHeight}
|
bind:clientHeight={timelineManager.viewportHeight}
|
||||||
bind:clientWidth={null, (v) => ((assetStore.viewportWidth = v), updateSlidingWindow())}
|
bind:clientWidth={null, (v) => ((timelineManager.viewportWidth = v), updateSlidingWindow())}
|
||||||
bind:this={element}
|
bind:this={element}
|
||||||
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
|
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
|
||||||
>
|
>
|
||||||
@ -839,7 +844,7 @@
|
|||||||
bind:this={timelineElement}
|
bind:this={timelineElement}
|
||||||
id="virtual-timeline"
|
id="virtual-timeline"
|
||||||
class:invisible={showSkeleton}
|
class:invisible={showSkeleton}
|
||||||
style:height={assetStore.timelineHeight + 'px'}
|
style:height={timelineManager.timelineHeight + 'px'}
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
use:resizeObserver={topSectionResizeObserver}
|
use:resizeObserver={topSectionResizeObserver}
|
||||||
@ -855,23 +860,26 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#each assetStore.buckets as bucket (bucket.viewId)}
|
{#each timelineManager.months as monthGroup (monthGroup.viewId)}
|
||||||
{@const display = bucket.intersecting}
|
{@const display = monthGroup.intersecting}
|
||||||
{@const absoluteHeight = bucket.top}
|
{@const absoluteHeight = monthGroup.top}
|
||||||
|
|
||||||
{#if !bucket.isLoaded}
|
{#if !monthGroup.isLoaded}
|
||||||
<div
|
<div
|
||||||
style:height={bucket.bucketHeight + 'px'}
|
style:height={monthGroup.height + 'px'}
|
||||||
style:position="absolute"
|
style:position="absolute"
|
||||||
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
||||||
style:width="100%"
|
style:width="100%"
|
||||||
>
|
>
|
||||||
<Skeleton height={bucket.bucketHeight - bucket.store.headerHeight} title={bucket.bucketDateFormatted} />
|
<Skeleton
|
||||||
|
height={monthGroup.height - monthGroup.timelineManager.headerHeight}
|
||||||
|
title={monthGroup.monthGroupTitle}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else if display}
|
{:else if display}
|
||||||
<div
|
<div
|
||||||
class="bucket"
|
class="month-group"
|
||||||
style:height={bucket.bucketHeight + 'px'}
|
style:height={monthGroup.height + 'px'}
|
||||||
style:position="absolute"
|
style:position="absolute"
|
||||||
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
||||||
style:width="100%"
|
style:width="100%"
|
||||||
@ -880,11 +888,11 @@
|
|||||||
{withStacked}
|
{withStacked}
|
||||||
{showArchiveIcon}
|
{showArchiveIcon}
|
||||||
{assetInteraction}
|
{assetInteraction}
|
||||||
{assetStore}
|
{timelineManager}
|
||||||
{isSelectionMode}
|
{isSelectionMode}
|
||||||
{singleSelect}
|
{singleSelect}
|
||||||
{bucket}
|
{monthGroup}
|
||||||
onSelect={({ title, assets }) => handleGroupSelect(assetStore, title, assets)}
|
onSelect={({ title, assets }) => handleGroupSelect(timelineManager, title, assets)}
|
||||||
onSelectAssetCandidates={handleSelectAssetCandidates}
|
onSelectAssetCandidates={handleSelectAssetCandidates}
|
||||||
onSelectAssets={handleSelectAssets}
|
onSelectAssets={handleSelectAssets}
|
||||||
onScrollCompensation={handleScrollCompensation}
|
onScrollCompensation={handleScrollCompensation}
|
||||||
@ -898,7 +906,7 @@
|
|||||||
style:position="absolute"
|
style:position="absolute"
|
||||||
style:left="0"
|
style:left="0"
|
||||||
style:right="0"
|
style:right="0"
|
||||||
style:transform={`translate3d(0,${assetStore.timelineHeight}px,0)`}
|
style:transform={`translate3d(0,${timelineManager.timelineHeight}px,0)`}
|
||||||
></div>
|
></div>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
@ -932,7 +940,7 @@
|
|||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bucket {
|
.month-group {
|
||||||
contain: layout size paint;
|
contain: layout size paint;
|
||||||
transform-style: flat;
|
transform-style: flat;
|
||||||
backface-visibility: hidden;
|
backface-visibility: hidden;
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import type { LiteBucket } from '$lib/managers/timeline-manager/types';
|
import type { ScrubberMonth } from '$lib/managers/timeline-manager/types';
|
||||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||||
import { getTabbable } from '$lib/utils/focus-util';
|
import { getTabbable } from '$lib/utils/focus-util';
|
||||||
import { type ScrubberListener } from '$lib/utils/timeline-util';
|
import { type ScrubberListener } from '$lib/utils/timeline-util';
|
||||||
@ -14,10 +14,10 @@
|
|||||||
timelineTopOffset?: number;
|
timelineTopOffset?: number;
|
||||||
timelineBottomOffset?: number;
|
timelineBottomOffset?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
assetStore: AssetStore;
|
timelineManager: TimelineManager;
|
||||||
scrubOverallPercent?: number;
|
scrubOverallPercent?: number;
|
||||||
scrubBucketPercent?: number;
|
scrubberMonthPercent?: number;
|
||||||
scrubBucket?: { year: number; month: number };
|
scrubberMonth?: { year: number; month: number };
|
||||||
leadout?: boolean;
|
leadout?: boolean;
|
||||||
scrubberWidth?: number;
|
scrubberWidth?: number;
|
||||||
onScrub?: ScrubberListener;
|
onScrub?: ScrubberListener;
|
||||||
@ -30,10 +30,10 @@
|
|||||||
timelineTopOffset = 0,
|
timelineTopOffset = 0,
|
||||||
timelineBottomOffset = 0,
|
timelineBottomOffset = 0,
|
||||||
height = 0,
|
height = 0,
|
||||||
assetStore,
|
timelineManager,
|
||||||
scrubOverallPercent = 0,
|
scrubOverallPercent = 0,
|
||||||
scrubBucketPercent = 0,
|
scrubberMonthPercent = 0,
|
||||||
scrubBucket = undefined,
|
scrubberMonth = undefined,
|
||||||
leadout = false,
|
leadout = false,
|
||||||
onScrub = undefined,
|
onScrub = undefined,
|
||||||
onScrubKeyDown = undefined,
|
onScrubKeyDown = undefined,
|
||||||
@ -69,7 +69,7 @@
|
|||||||
return '100vw';
|
return '100vw';
|
||||||
}
|
}
|
||||||
if (usingMobileDevice) {
|
if (usingMobileDevice) {
|
||||||
if (assetStore.scrolling) {
|
if (timelineManager.scrolling) {
|
||||||
return MOBILE_WIDTH + 'px';
|
return MOBILE_WIDTH + 'px';
|
||||||
}
|
}
|
||||||
return '0px';
|
return '0px';
|
||||||
@ -80,24 +80,24 @@
|
|||||||
scrubberWidth = usingMobileDevice ? MOBILE_WIDTH : DESKTOP_WIDTH;
|
scrubberWidth = usingMobileDevice ? MOBILE_WIDTH : DESKTOP_WIDTH;
|
||||||
});
|
});
|
||||||
|
|
||||||
const toScrollFromBucketPercentage = (
|
const toScrollFromMonthGroupPercentage = (
|
||||||
scrubBucket: { year: number; month: number } | undefined,
|
scrubberMonth: { year: number; month: number } | undefined,
|
||||||
scrubBucketPercent: number,
|
scrubberMonthPercent: number,
|
||||||
scrubOverallPercent: number,
|
scrubOverallPercent: number,
|
||||||
) => {
|
) => {
|
||||||
if (scrubBucket) {
|
if (scrubberMonth) {
|
||||||
let offset = relativeTopOffset;
|
let offset = relativeTopOffset;
|
||||||
let match = false;
|
let match = false;
|
||||||
for (const segment of segments) {
|
for (const segment of segments) {
|
||||||
if (segment.month === scrubBucket.month && segment.year === scrubBucket.year) {
|
if (segment.month === scrubberMonth.month && segment.year === scrubberMonth.year) {
|
||||||
offset += scrubBucketPercent * segment.height;
|
offset += scrubberMonthPercent * segment.height;
|
||||||
match = true;
|
match = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
offset += segment.height;
|
offset += segment.height;
|
||||||
}
|
}
|
||||||
if (!match) {
|
if (!match) {
|
||||||
offset += scrubBucketPercent * relativeBottomOffset;
|
offset += scrubberMonthPercent * relativeBottomOffset;
|
||||||
}
|
}
|
||||||
return offset;
|
return offset;
|
||||||
} else if (leadout) {
|
} else if (leadout) {
|
||||||
@ -111,8 +111,8 @@
|
|||||||
return scrubOverallPercent * (height - (PADDING_TOP + PADDING_BOTTOM));
|
return scrubOverallPercent * (height - (PADDING_TOP + PADDING_BOTTOM));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let scrollY = $derived(toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent));
|
let scrollY = $derived(toScrollFromMonthGroupPercentage(scrubberMonth, scrubberMonthPercent, scrubOverallPercent));
|
||||||
let timelineFullHeight = $derived(assetStore.scrubberTimelineHeight + timelineTopOffset + timelineBottomOffset);
|
let timelineFullHeight = $derived(timelineManager.scrubberTimelineHeight + timelineTopOffset + timelineBottomOffset);
|
||||||
let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
|
let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
|
||||||
let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));
|
let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));
|
||||||
|
|
||||||
@ -126,7 +126,7 @@
|
|||||||
hasDot: boolean;
|
hasDot: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateSegments = (buckets: LiteBucket[]) => {
|
const calculateSegments = (months: ScrubberMonth[]) => {
|
||||||
let height = 0;
|
let height = 0;
|
||||||
let dotHeight = 0;
|
let dotHeight = 0;
|
||||||
|
|
||||||
@ -134,16 +134,16 @@
|
|||||||
let previousLabeledSegment: Segment | undefined;
|
let previousLabeledSegment: Segment | undefined;
|
||||||
|
|
||||||
let top = 0;
|
let top = 0;
|
||||||
for (const [i, bucket] of buckets.entries()) {
|
for (const [i, scrubMonth] of months.entries()) {
|
||||||
const scrollBarPercentage = bucket.bucketHeight / timelineFullHeight;
|
const scrollBarPercentage = scrubMonth.height / timelineFullHeight;
|
||||||
|
|
||||||
const segment = {
|
const segment = {
|
||||||
top,
|
top,
|
||||||
count: bucket.assetCount,
|
count: scrubMonth.assetCount,
|
||||||
height: toScrollY(scrollBarPercentage),
|
height: toScrollY(scrollBarPercentage),
|
||||||
dateFormatted: bucket.bucketDateFormattted,
|
dateFormatted: scrubMonth.title,
|
||||||
year: bucket.year,
|
year: scrubMonth.year,
|
||||||
month: bucket.month,
|
month: scrubMonth.month,
|
||||||
hasLabel: false,
|
hasLabel: false,
|
||||||
hasDot: false,
|
hasDot: false,
|
||||||
};
|
};
|
||||||
@ -172,7 +172,7 @@
|
|||||||
return segments;
|
return segments;
|
||||||
};
|
};
|
||||||
let activeSegment: HTMLElement | undefined = $state();
|
let activeSegment: HTMLElement | undefined = $state();
|
||||||
const segments = $derived(calculateSegments(assetStore.scrubberBuckets));
|
const segments = $derived(calculateSegments(timelineManager.scrubberMonths));
|
||||||
const hoverLabel = $derived.by(() => {
|
const hoverLabel = $derived.by(() => {
|
||||||
if (isHoverOnPaddingTop) {
|
if (isHoverOnPaddingTop) {
|
||||||
return segments.at(0)?.dateFormatted;
|
return segments.at(0)?.dateFormatted;
|
||||||
@ -182,11 +182,11 @@
|
|||||||
}
|
}
|
||||||
return activeSegment?.dataset.label;
|
return activeSegment?.dataset.label;
|
||||||
});
|
});
|
||||||
const bucketDate = $derived.by(() => {
|
const segmentDate = $derived.by(() => {
|
||||||
if (!activeSegment?.dataset.timeSegmentBucketDate) {
|
if (!activeSegment?.dataset.segmentYearMonth) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const [year, month] = activeSegment.dataset.timeSegmentBucketDate.split('-').map(Number);
|
const [year, month] = activeSegment.dataset.segmentYearMonth.split('-').map(Number);
|
||||||
return { year, month };
|
return { year, month };
|
||||||
});
|
});
|
||||||
const scrollSegment = $derived.by(() => {
|
const scrollSegment = $derived.by(() => {
|
||||||
@ -241,17 +241,17 @@
|
|||||||
const boundingClientRect = bestElement.boundingClientRect;
|
const boundingClientRect = bestElement.boundingClientRect;
|
||||||
const sy = boundingClientRect.y;
|
const sy = boundingClientRect.y;
|
||||||
const relativeY = y - sy;
|
const relativeY = y - sy;
|
||||||
const bucketPercentY = relativeY / boundingClientRect.height;
|
const monthGroupPercentY = relativeY / boundingClientRect.height;
|
||||||
return {
|
return {
|
||||||
isOnPaddingTop: false,
|
isOnPaddingTop: false,
|
||||||
isOnPaddingBottom: false,
|
isOnPaddingBottom: false,
|
||||||
segment,
|
segment,
|
||||||
bucketPercentY,
|
monthGroupPercentY,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if padding
|
// check if padding
|
||||||
const bar = findElementBestY(elements, 0, 'immich-scrubbable-scrollbar');
|
const bar = findElementBestY(elements, 0, 'scrubber');
|
||||||
let isOnPaddingTop = false;
|
let isOnPaddingTop = false;
|
||||||
let isOnPaddingBottom = false;
|
let isOnPaddingBottom = false;
|
||||||
|
|
||||||
@ -269,7 +269,7 @@
|
|||||||
isOnPaddingTop,
|
isOnPaddingTop,
|
||||||
isOnPaddingBottom,
|
isOnPaddingBottom,
|
||||||
segment: undefined,
|
segment: undefined,
|
||||||
bucketPercentY: 0,
|
monthGroupPercentY: 0,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -288,19 +288,19 @@
|
|||||||
const upper = rect?.height - (PADDING_TOP + PADDING_BOTTOM);
|
const upper = rect?.height - (PADDING_TOP + PADDING_BOTTOM);
|
||||||
hoverY = clamp(clientY - rect?.top - PADDING_TOP, lower, upper);
|
hoverY = clamp(clientY - rect?.top - PADDING_TOP, lower, upper);
|
||||||
const x = rect!.left + rect!.width / 2;
|
const x = rect!.left + rect!.width / 2;
|
||||||
const { segment, bucketPercentY, isOnPaddingTop, isOnPaddingBottom } = getActive(x, clientY);
|
const { segment, monthGroupPercentY, isOnPaddingTop, isOnPaddingBottom } = getActive(x, clientY);
|
||||||
activeSegment = segment;
|
activeSegment = segment;
|
||||||
isHoverOnPaddingTop = isOnPaddingTop;
|
isHoverOnPaddingTop = isOnPaddingTop;
|
||||||
isHoverOnPaddingBottom = isOnPaddingBottom;
|
isHoverOnPaddingBottom = isOnPaddingBottom;
|
||||||
|
|
||||||
const scrollPercent = toTimelineY(hoverY);
|
const scrollPercent = toTimelineY(hoverY);
|
||||||
if (wasDragging === false && isDragging) {
|
if (wasDragging === false && isDragging) {
|
||||||
void startScrub?.(bucketDate!, scrollPercent, bucketPercentY);
|
void startScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
|
||||||
void onScrub?.(bucketDate!, scrollPercent, bucketPercentY);
|
void onScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wasDragging && !isDragging) {
|
if (wasDragging && !isDragging) {
|
||||||
void stopScrub?.(bucketDate!, scrollPercent, bucketPercentY);
|
void stopScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -308,7 +308,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void onScrub?.(bucketDate!, scrollPercent, bucketPercentY);
|
void onScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
|
||||||
};
|
};
|
||||||
const getTouch = (event: TouchEvent) => {
|
const getTouch = (event: TouchEvent) => {
|
||||||
if (event.touches.length === 1) {
|
if (event.touches.length === 1) {
|
||||||
@ -324,7 +324,7 @@
|
|||||||
}
|
}
|
||||||
const elements = document.elementsFromPoint(touch.clientX, touch.clientY);
|
const elements = document.elementsFromPoint(touch.clientX, touch.clientY);
|
||||||
const isHoverScrollbar =
|
const isHoverScrollbar =
|
||||||
findElementBestY(elements, 0, 'immich-scrubbable-scrollbar', 'time-label', 'lead-in', 'lead-out') !== undefined;
|
findElementBestY(elements, 0, 'scrubber', 'time-label', 'lead-in', 'lead-out') !== undefined;
|
||||||
|
|
||||||
isHover = isHoverScrollbar;
|
isHover = isHoverScrollbar;
|
||||||
|
|
||||||
@ -451,7 +451,7 @@
|
|||||||
aria-valuenow={scrollY + PADDING_TOP}
|
aria-valuenow={scrollY + PADDING_TOP}
|
||||||
aria-valuemax={toScrollY(1)}
|
aria-valuemax={toScrollY(1)}
|
||||||
aria-valuemin={toScrollY(0)}
|
aria-valuemin={toScrollY(0)}
|
||||||
data-id="immich-scrubbable-scrollbar"
|
data-id="scrubber"
|
||||||
class="absolute end-0 z-1 select-none hover:cursor-row-resize"
|
class="absolute end-0 z-1 select-none hover:cursor-row-resize"
|
||||||
style:padding-top={PADDING_TOP + 'px'}
|
style:padding-top={PADDING_TOP + 'px'}
|
||||||
style:padding-bottom={PADDING_BOTTOM + 'px'}
|
style:padding-bottom={PADDING_BOTTOM + 'px'}
|
||||||
@ -477,7 +477,7 @@
|
|||||||
{hoverLabel}
|
{hoverLabel}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if usingMobileDevice && ((assetStore.scrolling && scrollHoverLabel) || isHover || isDragging)}
|
{#if usingMobileDevice && ((timelineManager.scrolling && scrollHoverLabel) || isHover || isDragging)}
|
||||||
<div
|
<div
|
||||||
id="time-label"
|
id="time-label"
|
||||||
class="rounded-s-full w-[32px] ps-2 text-white bg-immich-primary dark:bg-gray-600 hover:cursor-pointer select-none"
|
class="rounded-s-full w-[32px] ps-2 text-white bg-immich-primary dark:bg-gray-600 hover:cursor-pointer select-none"
|
||||||
@ -490,7 +490,7 @@
|
|||||||
>
|
>
|
||||||
<Icon path={mdiPlay} size="20" class="-rotate-90 relative top-[9px] -end-[2px]" />
|
<Icon path={mdiPlay} size="20" class="-rotate-90 relative top-[9px] -end-[2px]" />
|
||||||
<Icon path={mdiPlay} size="20" class="rotate-90 relative top-px -end-[2px]" />
|
<Icon path={mdiPlay} size="20" class="rotate-90 relative top-px -end-[2px]" />
|
||||||
{#if (assetStore.scrolling && scrollHoverLabel) || isHover || isDragging}
|
{#if (timelineManager.scrolling && scrollHoverLabel) || isHover || isDragging}
|
||||||
<p
|
<p
|
||||||
transition:fade={{ duration: 200 }}
|
transition:fade={{ duration: 200 }}
|
||||||
style:bottom={50 / 2 - 30 / 2 + 'px'}
|
style:bottom={50 / 2 - 30 / 2 + 'px'}
|
||||||
@ -509,7 +509,7 @@
|
|||||||
class="absolute end-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary"
|
class="absolute end-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary"
|
||||||
style:top="{scrollY + PADDING_TOP - 2}px"
|
style:top="{scrollY + PADDING_TOP - 2}px"
|
||||||
>
|
>
|
||||||
{#if assetStore.scrolling && scrollHoverLabel && !isHover}
|
{#if timelineManager.scrolling && scrollHoverLabel && !isHover}
|
||||||
<p
|
<p
|
||||||
transition:fade={{ duration: 200 }}
|
transition:fade={{ duration: 200 }}
|
||||||
class="truncate pointer-events-none absolute end-0 bottom-0 min-w-20 max-w-64 w-fit rounded-tl-md border-b-2 border-immich-primary bg-subtle/90 z-1 py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:text-immich-dark-fg"
|
class="truncate pointer-events-none absolute end-0 bottom-0 min-w-20 max-w-64 w-fit rounded-tl-md border-b-2 border-immich-primary bg-subtle/90 z-1 py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:text-immich-dark-fg"
|
||||||
@ -523,7 +523,7 @@
|
|||||||
class="relative"
|
class="relative"
|
||||||
style:height={relativeTopOffset + 'px'}
|
style:height={relativeTopOffset + 'px'}
|
||||||
data-id="lead-in"
|
data-id="lead-in"
|
||||||
data-time-segment-bucket-date={segments.at(0)?.year + '-' + segments.at(0)?.month}
|
data-segment-year-month={segments.at(0)?.year + '-' + segments.at(0)?.month}
|
||||||
data-label={segments.at(0)?.dateFormatted}
|
data-label={segments.at(0)?.dateFormatted}
|
||||||
>
|
>
|
||||||
{#if relativeTopOffset > 6}
|
{#if relativeTopOffset > 6}
|
||||||
@ -535,7 +535,7 @@
|
|||||||
<div
|
<div
|
||||||
class="relative"
|
class="relative"
|
||||||
data-id="time-segment"
|
data-id="time-segment"
|
||||||
data-time-segment-bucket-date={segment.year + '-' + segment.month}
|
data-segment-year-month={segment.year + '-' + segment.month}
|
||||||
data-label={segment.dateFormatted}
|
data-label={segment.dateFormatted}
|
||||||
style:height={segment.height + 'px'}
|
style:height={segment.height + 'px'}
|
||||||
>
|
>
|
||||||
|
@ -1,60 +0,0 @@
|
|||||||
import type { TimelinePlainDate } from '$lib/utils/timeline-util';
|
|
||||||
import { AssetOrder } from '@immich/sdk';
|
|
||||||
import type { AssetBucket } from './asset-bucket.svelte';
|
|
||||||
import type { AssetDateGroup } from './asset-date-group.svelte';
|
|
||||||
import type { TimelineAsset } from './types';
|
|
||||||
|
|
||||||
export class AddContext {
|
|
||||||
#lookupCache: {
|
|
||||||
[year: number]: { [month: number]: { [day: number]: AssetDateGroup } };
|
|
||||||
} = {};
|
|
||||||
unprocessedAssets: TimelineAsset[] = [];
|
|
||||||
changedDateGroups = new Set<AssetDateGroup>();
|
|
||||||
newDateGroups = new Set<AssetDateGroup>();
|
|
||||||
|
|
||||||
getDateGroup({ year, month, day }: TimelinePlainDate): AssetDateGroup | undefined {
|
|
||||||
return this.#lookupCache[year]?.[month]?.[day];
|
|
||||||
}
|
|
||||||
|
|
||||||
setDateGroup(dateGroup: AssetDateGroup, { year, month, day }: TimelinePlainDate) {
|
|
||||||
if (!this.#lookupCache[year]) {
|
|
||||||
this.#lookupCache[year] = {};
|
|
||||||
}
|
|
||||||
if (!this.#lookupCache[year][month]) {
|
|
||||||
this.#lookupCache[year][month] = {};
|
|
||||||
}
|
|
||||||
this.#lookupCache[year][month][day] = dateGroup;
|
|
||||||
}
|
|
||||||
|
|
||||||
get existingDateGroups() {
|
|
||||||
return this.changedDateGroups.difference(this.newDateGroups);
|
|
||||||
}
|
|
||||||
|
|
||||||
get updatedBuckets() {
|
|
||||||
const updated = new Set<AssetBucket>();
|
|
||||||
for (const group of this.changedDateGroups) {
|
|
||||||
updated.add(group.bucket);
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
get bucketsWithNewDateGroups() {
|
|
||||||
const updated = new Set<AssetBucket>();
|
|
||||||
for (const group of this.newDateGroups) {
|
|
||||||
updated.add(group.bucket);
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
sort(bucket: AssetBucket, sortOrder: AssetOrder = AssetOrder.Desc) {
|
|
||||||
for (const group of this.changedDateGroups) {
|
|
||||||
group.sortAssets(sortOrder);
|
|
||||||
}
|
|
||||||
for (const group of this.newDateGroups) {
|
|
||||||
group.sortAssets(sortOrder);
|
|
||||||
}
|
|
||||||
if (this.newDateGroups.size > 0) {
|
|
||||||
bucket.sortDateGroups();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,934 +0,0 @@
|
|||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
|
||||||
import { websocketEvents } from '$lib/stores/websocket';
|
|
||||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
|
||||||
import {
|
|
||||||
plainDateTimeCompare,
|
|
||||||
toISOYearMonthUTC,
|
|
||||||
toTimelineAsset,
|
|
||||||
type TimelinePlainDate,
|
|
||||||
type TimelinePlainDateTime,
|
|
||||||
type TimelinePlainYearMonth,
|
|
||||||
} from '$lib/utils/timeline-util';
|
|
||||||
import { TUNABLES } from '$lib/utils/tunables';
|
|
||||||
import { getAssetInfo, getTimeBucket, getTimeBuckets } from '@immich/sdk';
|
|
||||||
import { clamp, debounce, isEqual, throttle } from 'lodash-es';
|
|
||||||
import { SvelteSet } from 'svelte/reactivity';
|
|
||||||
import type { Unsubscriber } from 'svelte/store';
|
|
||||||
import { AddContext } from './add-context.svelte';
|
|
||||||
import { AssetBucket } from './asset-bucket.svelte';
|
|
||||||
import { AssetDateGroup } from './asset-date-group.svelte';
|
|
||||||
import type {
|
|
||||||
AssetDescriptor,
|
|
||||||
AssetOperation,
|
|
||||||
AssetStoreLayoutOptions,
|
|
||||||
AssetStoreOptions,
|
|
||||||
Direction,
|
|
||||||
LiteBucket,
|
|
||||||
PendingChange,
|
|
||||||
TimelineAsset,
|
|
||||||
UpdateGeometryOptions,
|
|
||||||
Viewport,
|
|
||||||
} from './types';
|
|
||||||
import { isMismatched, updateObject } from './utils.svelte';
|
|
||||||
|
|
||||||
const {
|
|
||||||
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
|
|
||||||
} = TUNABLES;
|
|
||||||
|
|
||||||
export class AssetStore {
|
|
||||||
isInitialized = $state(false);
|
|
||||||
buckets: AssetBucket[] = $state([]);
|
|
||||||
topSectionHeight = $state(0);
|
|
||||||
timelineHeight = $derived(
|
|
||||||
this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0) + this.topSectionHeight,
|
|
||||||
);
|
|
||||||
count = $derived(this.buckets.reduce((accumulator, b) => accumulator + b.bucketCount, 0));
|
|
||||||
|
|
||||||
albumAssets: Set<string> = new SvelteSet();
|
|
||||||
|
|
||||||
scrubberBuckets: LiteBucket[] = $state([]);
|
|
||||||
scrubberTimelineHeight: number = $state(0);
|
|
||||||
|
|
||||||
topIntersectingBucket: AssetBucket | undefined = $state();
|
|
||||||
|
|
||||||
visibleWindow = $derived.by(() => ({
|
|
||||||
top: this.#scrollTop,
|
|
||||||
bottom: this.#scrollTop + this.viewportHeight,
|
|
||||||
}));
|
|
||||||
|
|
||||||
initTask = new CancellableTask(
|
|
||||||
() => {
|
|
||||||
this.isInitialized = true;
|
|
||||||
if (this.#options.albumId || this.#options.personId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.connect();
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
this.disconnect();
|
|
||||||
this.isInitialized = false;
|
|
||||||
},
|
|
||||||
() => void 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
static #INIT_OPTIONS = {};
|
|
||||||
#viewportHeight = $state(0);
|
|
||||||
#viewportWidth = $state(0);
|
|
||||||
#scrollTop = $state(0);
|
|
||||||
#pendingChanges: PendingChange[] = [];
|
|
||||||
#unsubscribers: Unsubscriber[] = [];
|
|
||||||
|
|
||||||
#rowHeight = $state(235);
|
|
||||||
#headerHeight = $state(48);
|
|
||||||
#gap = $state(12);
|
|
||||||
|
|
||||||
#options: AssetStoreOptions = AssetStore.#INIT_OPTIONS;
|
|
||||||
|
|
||||||
#scrolling = $state(false);
|
|
||||||
#suspendTransitions = $state(false);
|
|
||||||
#resetScrolling = debounce(() => (this.#scrolling = false), 1000);
|
|
||||||
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
|
|
||||||
scrollCompensation: {
|
|
||||||
heightDelta: number | undefined;
|
|
||||||
scrollTop: number | undefined;
|
|
||||||
bucket: AssetBucket | undefined;
|
|
||||||
} = $state({
|
|
||||||
heightDelta: 0,
|
|
||||||
scrollTop: 0,
|
|
||||||
bucket: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
constructor() {}
|
|
||||||
|
|
||||||
setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }: AssetStoreLayoutOptions) {
|
|
||||||
let changed = false;
|
|
||||||
changed ||= this.#setHeaderHeight(headerHeight);
|
|
||||||
changed ||= this.#setGap(gap);
|
|
||||||
changed ||= this.#setRowHeight(rowHeight);
|
|
||||||
if (changed) {
|
|
||||||
this.refreshLayout();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#setHeaderHeight(value: number) {
|
|
||||||
if (this.#headerHeight == value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
this.#headerHeight = value;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
get headerHeight() {
|
|
||||||
return this.#headerHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
#setGap(value: number) {
|
|
||||||
if (this.#gap == value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
this.#gap = value;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
get gap() {
|
|
||||||
return this.#gap;
|
|
||||||
}
|
|
||||||
|
|
||||||
#setRowHeight(value: number) {
|
|
||||||
if (this.#rowHeight == value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
this.#rowHeight = value;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
get rowHeight() {
|
|
||||||
return this.#rowHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
set scrolling(value: boolean) {
|
|
||||||
this.#scrolling = value;
|
|
||||||
if (value) {
|
|
||||||
this.suspendTransitions = true;
|
|
||||||
this.#resetScrolling();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get scrolling() {
|
|
||||||
return this.#scrolling;
|
|
||||||
}
|
|
||||||
|
|
||||||
set suspendTransitions(value: boolean) {
|
|
||||||
this.#suspendTransitions = value;
|
|
||||||
if (value) {
|
|
||||||
this.#resetSuspendTransitions();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get suspendTransitions() {
|
|
||||||
return this.#suspendTransitions;
|
|
||||||
}
|
|
||||||
|
|
||||||
set viewportWidth(value: number) {
|
|
||||||
const changed = value !== this.#viewportWidth;
|
|
||||||
this.#viewportWidth = value;
|
|
||||||
this.suspendTransitions = true;
|
|
||||||
void this.#updateViewportGeometry(changed);
|
|
||||||
}
|
|
||||||
|
|
||||||
get viewportWidth() {
|
|
||||||
return this.#viewportWidth;
|
|
||||||
}
|
|
||||||
|
|
||||||
set viewportHeight(value: number) {
|
|
||||||
this.#viewportHeight = value;
|
|
||||||
this.#suspendTransitions = true;
|
|
||||||
void this.#updateViewportGeometry(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
get viewportHeight() {
|
|
||||||
return this.#viewportHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
async *assetsIterator(options?: {
|
|
||||||
startBucket?: AssetBucket;
|
|
||||||
startDateGroup?: AssetDateGroup;
|
|
||||||
startAsset?: TimelineAsset;
|
|
||||||
direction?: Direction;
|
|
||||||
}) {
|
|
||||||
const direction = options?.direction ?? 'earlier';
|
|
||||||
let { startDateGroup, startAsset } = options ?? {};
|
|
||||||
for (const bucket of this.bucketsIterator({ direction, startBucket: options?.startBucket })) {
|
|
||||||
await this.loadBucket(bucket.yearMonth, { cancelable: false });
|
|
||||||
yield* bucket.assetsIterator({ startDateGroup, startAsset, direction });
|
|
||||||
startDateGroup = startAsset = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
*bucketsIterator(options?: { direction?: Direction; startBucket?: AssetBucket }) {
|
|
||||||
const isEarlier = options?.direction === 'earlier';
|
|
||||||
let startIndex = options?.startBucket
|
|
||||||
? this.buckets.indexOf(options.startBucket)
|
|
||||||
: isEarlier
|
|
||||||
? 0
|
|
||||||
: this.buckets.length - 1;
|
|
||||||
|
|
||||||
while (startIndex >= 0 && startIndex < this.buckets.length) {
|
|
||||||
yield this.buckets[startIndex];
|
|
||||||
startIndex += isEarlier ? 1 : -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#addPendingChanges(...changes: PendingChange[]) {
|
|
||||||
this.#pendingChanges.push(...changes);
|
|
||||||
this.#processPendingChanges();
|
|
||||||
}
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
this.#unsubscribers.push(
|
|
||||||
websocketEvents.on('on_upload_success', (asset) =>
|
|
||||||
this.#addPendingChanges({ type: 'add', values: [toTimelineAsset(asset)] }),
|
|
||||||
),
|
|
||||||
websocketEvents.on('on_asset_trash', (ids) => this.#addPendingChanges({ type: 'trash', values: ids })),
|
|
||||||
websocketEvents.on('on_asset_update', (asset) =>
|
|
||||||
this.#addPendingChanges({ type: 'update', values: [toTimelineAsset(asset)] }),
|
|
||||||
),
|
|
||||||
websocketEvents.on('on_asset_delete', (id: string) => this.#addPendingChanges({ type: 'delete', values: [id] })),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect() {
|
|
||||||
for (const unsubscribe of this.#unsubscribers) {
|
|
||||||
unsubscribe();
|
|
||||||
}
|
|
||||||
this.#unsubscribers = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
#getPendingChangeBatches() {
|
|
||||||
const batch: {
|
|
||||||
add: TimelineAsset[];
|
|
||||||
update: TimelineAsset[];
|
|
||||||
remove: string[];
|
|
||||||
} = {
|
|
||||||
add: [],
|
|
||||||
update: [],
|
|
||||||
remove: [],
|
|
||||||
};
|
|
||||||
for (const { type, values } of this.#pendingChanges) {
|
|
||||||
switch (type) {
|
|
||||||
case 'add': {
|
|
||||||
batch.add.push(...values);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'update': {
|
|
||||||
batch.update.push(...values);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'delete':
|
|
||||||
case 'trash': {
|
|
||||||
batch.remove.push(...values);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return batch;
|
|
||||||
}
|
|
||||||
|
|
||||||
#findBucketForAsset(id: string) {
|
|
||||||
for (const bucket of this.buckets) {
|
|
||||||
const asset = bucket.findAssetById({ id });
|
|
||||||
if (asset) {
|
|
||||||
return { bucket, asset };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#findBucketForDate(targetYearMonth: TimelinePlainYearMonth) {
|
|
||||||
for (const bucket of this.buckets) {
|
|
||||||
const { year, month } = bucket.yearMonth;
|
|
||||||
if (month === targetYearMonth.month && year === targetYearMonth.year) {
|
|
||||||
return bucket;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSlidingWindow(scrollTop: number) {
|
|
||||||
if (this.#scrollTop !== scrollTop) {
|
|
||||||
this.#scrollTop = scrollTop;
|
|
||||||
this.updateIntersections();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearScrollCompensation() {
|
|
||||||
this.scrollCompensation = {
|
|
||||||
heightDelta: undefined,
|
|
||||||
scrollTop: undefined,
|
|
||||||
bucket: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
updateIntersections() {
|
|
||||||
if (!this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let topIntersectingBucket = undefined;
|
|
||||||
for (const bucket of this.buckets) {
|
|
||||||
this.#updateIntersection(bucket);
|
|
||||||
if (!topIntersectingBucket && bucket.actuallyIntersecting) {
|
|
||||||
topIntersectingBucket = bucket;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (topIntersectingBucket !== undefined && this.topIntersectingBucket !== topIntersectingBucket) {
|
|
||||||
this.topIntersectingBucket = topIntersectingBucket;
|
|
||||||
}
|
|
||||||
for (const bucket of this.buckets) {
|
|
||||||
if (bucket === this.topIntersectingBucket) {
|
|
||||||
this.topIntersectingBucket.percent = clamp(
|
|
||||||
(this.visibleWindow.top - this.topIntersectingBucket.top) / this.topIntersectingBucket.bucketHeight,
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
bucket.percent = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#calculateIntersecting(bucket: AssetBucket, expandTop: number, expandBottom: number) {
|
|
||||||
const bucketTop = bucket.top;
|
|
||||||
const bucketBottom = bucketTop + bucket.bucketHeight;
|
|
||||||
const topWindow = this.visibleWindow.top - expandTop;
|
|
||||||
const bottomWindow = this.visibleWindow.bottom + expandBottom;
|
|
||||||
|
|
||||||
return (
|
|
||||||
(bucketTop >= topWindow && bucketTop < bottomWindow) ||
|
|
||||||
(bucketBottom >= topWindow && bucketBottom < bottomWindow) ||
|
|
||||||
(bucketTop < topWindow && bucketBottom >= bottomWindow)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearDeferredLayout(bucket: AssetBucket) {
|
|
||||||
const hasDeferred = bucket.dateGroups.some((group) => group.deferredLayout);
|
|
||||||
if (hasDeferred) {
|
|
||||||
this.#updateGeometry(bucket, { invalidateHeight: true, noDefer: true });
|
|
||||||
for (const group of bucket.dateGroups) {
|
|
||||||
group.deferredLayout = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#updateIntersection(bucket: AssetBucket) {
|
|
||||||
const actuallyIntersecting = this.#calculateIntersecting(bucket, 0, 0);
|
|
||||||
let preIntersecting = false;
|
|
||||||
if (!actuallyIntersecting) {
|
|
||||||
preIntersecting = this.#calculateIntersecting(bucket, INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM);
|
|
||||||
}
|
|
||||||
bucket.intersecting = actuallyIntersecting || preIntersecting;
|
|
||||||
bucket.actuallyIntersecting = actuallyIntersecting;
|
|
||||||
if (preIntersecting || actuallyIntersecting) {
|
|
||||||
this.clearDeferredLayout(bucket);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#processPendingChanges = throttle(() => {
|
|
||||||
const { add, update, remove } = this.#getPendingChangeBatches();
|
|
||||||
if (add.length > 0) {
|
|
||||||
this.addAssets(add);
|
|
||||||
}
|
|
||||||
if (update.length > 0) {
|
|
||||||
this.updateAssets(update);
|
|
||||||
}
|
|
||||||
if (remove.length > 0) {
|
|
||||||
this.removeAssets(remove);
|
|
||||||
}
|
|
||||||
this.#pendingChanges = [];
|
|
||||||
}, 2500);
|
|
||||||
|
|
||||||
async #initializeTimeBuckets() {
|
|
||||||
const timebuckets = await getTimeBuckets({
|
|
||||||
...this.#options,
|
|
||||||
key: authManager.key,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.buckets = timebuckets.map((bucket) => {
|
|
||||||
const date = new Date(bucket.timeBucket);
|
|
||||||
return new AssetBucket(
|
|
||||||
this,
|
|
||||||
{ year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 },
|
|
||||||
bucket.count,
|
|
||||||
this.#options.order,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
this.albumAssets.clear();
|
|
||||||
this.#updateViewportGeometry(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateOptions(options: AssetStoreOptions) {
|
|
||||||
if (options.deferInit) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.#options !== AssetStore.#INIT_OPTIONS && isEqual(this.#options, options)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.initTask.reset();
|
|
||||||
await this.#init(options);
|
|
||||||
this.#updateViewportGeometry(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async #init(options: AssetStoreOptions) {
|
|
||||||
this.isInitialized = false;
|
|
||||||
this.buckets = [];
|
|
||||||
this.albumAssets.clear();
|
|
||||||
await this.initTask.execute(async () => {
|
|
||||||
this.#options = options;
|
|
||||||
await this.#initializeTimeBuckets();
|
|
||||||
}, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public destroy() {
|
|
||||||
this.disconnect();
|
|
||||||
this.isInitialized = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateViewport(viewport: Viewport) {
|
|
||||||
if (viewport.height === 0 && viewport.width === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.viewportHeight === viewport.height && this.viewportWidth === viewport.width) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.initTask.executed) {
|
|
||||||
await (this.initTask.loading ? this.initTask.waitUntilCompletion() : this.#init(this.#options));
|
|
||||||
}
|
|
||||||
|
|
||||||
const changedWidth = viewport.width !== this.viewportWidth;
|
|
||||||
this.viewportHeight = viewport.height;
|
|
||||||
this.viewportWidth = viewport.width;
|
|
||||||
this.#updateViewportGeometry(changedWidth);
|
|
||||||
}
|
|
||||||
|
|
||||||
#updateViewportGeometry(changedWidth: boolean) {
|
|
||||||
if (!this.isInitialized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.viewportWidth === 0 || this.viewportHeight === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const bucket of this.buckets) {
|
|
||||||
this.#updateGeometry(bucket, { invalidateHeight: changedWidth });
|
|
||||||
}
|
|
||||||
this.updateIntersections();
|
|
||||||
this.#createScrubBuckets();
|
|
||||||
}
|
|
||||||
|
|
||||||
#createScrubBuckets() {
|
|
||||||
this.scrubberBuckets = this.buckets.map((bucket) => ({
|
|
||||||
assetCount: bucket.bucketCount,
|
|
||||||
year: bucket.yearMonth.year,
|
|
||||||
month: bucket.yearMonth.month,
|
|
||||||
bucketDateFormattted: bucket.bucketDateFormatted,
|
|
||||||
bucketHeight: bucket.bucketHeight,
|
|
||||||
}));
|
|
||||||
this.scrubberTimelineHeight = this.timelineHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
createLayoutOptions() {
|
|
||||||
const viewportWidth = this.viewportWidth;
|
|
||||||
|
|
||||||
return {
|
|
||||||
spacing: 2,
|
|
||||||
heightTolerance: 0.15,
|
|
||||||
rowHeight: this.#rowHeight,
|
|
||||||
rowWidth: Math.floor(viewportWidth),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
#updateGeometry(bucket: AssetBucket, options: UpdateGeometryOptions) {
|
|
||||||
const { invalidateHeight, noDefer = false } = options;
|
|
||||||
if (invalidateHeight) {
|
|
||||||
bucket.isBucketHeightActual = false;
|
|
||||||
}
|
|
||||||
if (!bucket.isLoaded) {
|
|
||||||
const viewportWidth = this.viewportWidth;
|
|
||||||
if (!bucket.isBucketHeightActual) {
|
|
||||||
const unwrappedWidth = (3 / 2) * bucket.bucketCount * this.#rowHeight * (7 / 10);
|
|
||||||
const rows = Math.ceil(unwrappedWidth / viewportWidth);
|
|
||||||
const height = 51 + Math.max(1, rows) * this.#rowHeight;
|
|
||||||
bucket.bucketHeight = height;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.#layoutBucket(bucket, noDefer);
|
|
||||||
}
|
|
||||||
|
|
||||||
#layoutBucket(bucket: AssetBucket, noDefer: boolean = false) {
|
|
||||||
let cummulativeHeight = 0;
|
|
||||||
let cummulativeWidth = 0;
|
|
||||||
let lastRowHeight = 0;
|
|
||||||
let lastRow = 0;
|
|
||||||
|
|
||||||
let dateGroupRow = 0;
|
|
||||||
let dateGroupCol = 0;
|
|
||||||
|
|
||||||
const rowSpaceRemaining: number[] = Array.from({ length: bucket.dateGroups.length });
|
|
||||||
rowSpaceRemaining.fill(this.viewportWidth, 0, bucket.dateGroups.length);
|
|
||||||
const options = this.createLayoutOptions();
|
|
||||||
for (const assetGroup of bucket.dateGroups) {
|
|
||||||
assetGroup.layout(options, noDefer);
|
|
||||||
rowSpaceRemaining[dateGroupRow] -= assetGroup.width - 1;
|
|
||||||
if (dateGroupCol > 0) {
|
|
||||||
rowSpaceRemaining[dateGroupRow] -= this.gap;
|
|
||||||
}
|
|
||||||
if (rowSpaceRemaining[dateGroupRow] >= 0) {
|
|
||||||
assetGroup.row = dateGroupRow;
|
|
||||||
assetGroup.col = dateGroupCol;
|
|
||||||
assetGroup.left = cummulativeWidth;
|
|
||||||
assetGroup.top = cummulativeHeight;
|
|
||||||
|
|
||||||
dateGroupCol++;
|
|
||||||
|
|
||||||
cummulativeWidth += assetGroup.width + this.gap;
|
|
||||||
} else {
|
|
||||||
cummulativeWidth = 0;
|
|
||||||
dateGroupRow++;
|
|
||||||
dateGroupCol = 0;
|
|
||||||
assetGroup.row = dateGroupRow;
|
|
||||||
assetGroup.col = dateGroupCol;
|
|
||||||
assetGroup.left = cummulativeWidth;
|
|
||||||
|
|
||||||
rowSpaceRemaining[dateGroupRow] -= assetGroup.width;
|
|
||||||
dateGroupCol++;
|
|
||||||
cummulativeHeight += lastRowHeight;
|
|
||||||
assetGroup.top = cummulativeHeight;
|
|
||||||
cummulativeWidth += assetGroup.width + this.gap;
|
|
||||||
lastRow = assetGroup.row - 1;
|
|
||||||
}
|
|
||||||
lastRowHeight = assetGroup.height + this.headerHeight;
|
|
||||||
}
|
|
||||||
if (lastRow === 0 || lastRow !== bucket.lastDateGroup?.row) {
|
|
||||||
cummulativeHeight += lastRowHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
bucket.bucketHeight = cummulativeHeight;
|
|
||||||
bucket.isBucketHeightActual = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadBucket(yearMonth: TimelinePlainYearMonth, options?: { cancelable: boolean }): Promise<void> {
|
|
||||||
let cancelable = true;
|
|
||||||
if (options) {
|
|
||||||
cancelable = options.cancelable;
|
|
||||||
}
|
|
||||||
const bucket = this.getBucketByDate(yearMonth);
|
|
||||||
if (!bucket) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bucket.loader?.executed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await bucket.loader?.execute(async (signal: AbortSignal) => {
|
|
||||||
if (bucket.getFirstAsset()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const timeBucket = toISOYearMonthUTC(bucket.yearMonth);
|
|
||||||
const key = authManager.key;
|
|
||||||
const bucketResponse = await getTimeBucket(
|
|
||||||
{
|
|
||||||
...this.#options,
|
|
||||||
timeBucket,
|
|
||||||
key,
|
|
||||||
},
|
|
||||||
{ signal },
|
|
||||||
);
|
|
||||||
if (bucketResponse) {
|
|
||||||
if (this.#options.timelineAlbumId) {
|
|
||||||
const albumAssets = await getTimeBucket(
|
|
||||||
{
|
|
||||||
albumId: this.#options.timelineAlbumId,
|
|
||||||
timeBucket,
|
|
||||||
key,
|
|
||||||
},
|
|
||||||
{ signal },
|
|
||||||
);
|
|
||||||
for (const id of albumAssets.id) {
|
|
||||||
this.albumAssets.add(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const unprocessedAssets = bucket.addAssets(bucketResponse);
|
|
||||||
if (unprocessedAssets.length > 0) {
|
|
||||||
console.error(
|
|
||||||
`Warning: getTimeBucket API returning assets not in requested month: ${bucket.yearMonth.month}, ${JSON.stringify(
|
|
||||||
unprocessedAssets.map((unprocessed) => ({
|
|
||||||
id: unprocessed.id,
|
|
||||||
localDateTime: unprocessed.localDateTime,
|
|
||||||
})),
|
|
||||||
)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this.#layoutBucket(bucket);
|
|
||||||
}
|
|
||||||
}, cancelable);
|
|
||||||
if (result === 'LOADED') {
|
|
||||||
this.#updateIntersection(bucket);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addAssets(assets: TimelineAsset[]) {
|
|
||||||
const assetsToUpdate: TimelineAsset[] = [];
|
|
||||||
|
|
||||||
for (const asset of assets) {
|
|
||||||
if (this.isExcluded(asset)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
assetsToUpdate.push(asset);
|
|
||||||
}
|
|
||||||
|
|
||||||
const notUpdated = this.updateAssets(assetsToUpdate);
|
|
||||||
this.#addAssetsToBuckets([...notUpdated]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#addAssetsToBuckets(assets: TimelineAsset[]) {
|
|
||||||
if (assets.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const addContext = new AddContext();
|
|
||||||
const updatedBuckets = new Set<AssetBucket>();
|
|
||||||
const bucketCount = this.buckets.length;
|
|
||||||
for (const asset of assets) {
|
|
||||||
let bucket = this.getBucketByDate(asset.localDateTime);
|
|
||||||
|
|
||||||
if (!bucket) {
|
|
||||||
bucket = new AssetBucket(this, asset.localDateTime, 1, this.#options.order);
|
|
||||||
bucket.isLoaded = true;
|
|
||||||
this.buckets.push(bucket);
|
|
||||||
}
|
|
||||||
|
|
||||||
bucket.addTimelineAsset(asset, addContext);
|
|
||||||
updatedBuckets.add(bucket);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.buckets.length !== bucketCount) {
|
|
||||||
this.buckets.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.existingDateGroups) {
|
|
||||||
group.sortAssets(this.#options.order);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const bucket of addContext.bucketsWithNewDateGroups) {
|
|
||||||
bucket.sortDateGroups();
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const bucket of addContext.updatedBuckets) {
|
|
||||||
bucket.sortDateGroups();
|
|
||||||
this.#updateGeometry(bucket, { invalidateHeight: true });
|
|
||||||
}
|
|
||||||
this.updateIntersections();
|
|
||||||
}
|
|
||||||
|
|
||||||
getBucketByDate(targetYearMonth: TimelinePlainYearMonth): AssetBucket | undefined {
|
|
||||||
return this.buckets.find(
|
|
||||||
(bucket) => bucket.yearMonth.year === targetYearMonth.year && bucket.yearMonth.month === targetYearMonth.month,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async findBucketForAsset(id: string) {
|
|
||||||
if (!this.isInitialized) {
|
|
||||||
await this.initTask.waitUntilCompletion();
|
|
||||||
}
|
|
||||||
let { bucket } = this.#findBucketForAsset(id) ?? {};
|
|
||||||
if (bucket) {
|
|
||||||
return bucket;
|
|
||||||
}
|
|
||||||
const asset = toTimelineAsset(await getAssetInfo({ id, key: authManager.key }));
|
|
||||||
if (!asset || this.isExcluded(asset)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
bucket = await this.#loadBucketAtTime(asset.localDateTime, { cancelable: false });
|
|
||||||
if (bucket?.findAssetById({ id })) {
|
|
||||||
return bucket;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async #loadBucketAtTime(yearMonth: TimelinePlainYearMonth, options?: { cancelable: boolean }) {
|
|
||||||
await this.loadBucket(yearMonth, options);
|
|
||||||
return this.getBucketByDate(yearMonth);
|
|
||||||
}
|
|
||||||
|
|
||||||
getBucketIndexByAssetId(assetId: string) {
|
|
||||||
const bucketInfo = this.#findBucketForAsset(assetId);
|
|
||||||
return bucketInfo?.bucket;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getRandomBucket() {
|
|
||||||
const random = Math.floor(Math.random() * this.buckets.length);
|
|
||||||
const bucket = this.buckets[random];
|
|
||||||
await this.loadBucket(bucket.yearMonth, { cancelable: false });
|
|
||||||
return bucket;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getRandomAsset() {
|
|
||||||
const bucket = await this.getRandomBucket();
|
|
||||||
return bucket?.getRandomAsset();
|
|
||||||
}
|
|
||||||
|
|
||||||
#runAssetOperation(ids: Set<string>, operation: AssetOperation) {
|
|
||||||
if (ids.size === 0) {
|
|
||||||
return { processedIds: new Set(), unprocessedIds: ids, changedGeometry: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const changedBuckets = new Set<AssetBucket>();
|
|
||||||
let idsToProcess = new Set(ids);
|
|
||||||
const idsProcessed = new Set<string>();
|
|
||||||
const combinedMoveAssets: { asset: TimelineAsset; date: TimelinePlainDate }[][] = [];
|
|
||||||
for (const bucket of this.buckets) {
|
|
||||||
if (idsToProcess.size > 0) {
|
|
||||||
const { moveAssets, processedIds, changedGeometry } = bucket.runAssetOperation(idsToProcess, operation);
|
|
||||||
if (moveAssets.length > 0) {
|
|
||||||
combinedMoveAssets.push(moveAssets);
|
|
||||||
}
|
|
||||||
idsToProcess = idsToProcess.difference(processedIds);
|
|
||||||
for (const id of processedIds) {
|
|
||||||
idsProcessed.add(id);
|
|
||||||
}
|
|
||||||
if (changedGeometry) {
|
|
||||||
changedBuckets.add(bucket);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (combinedMoveAssets.length > 0) {
|
|
||||||
this.#addAssetsToBuckets(combinedMoveAssets.flat().map((a) => a.asset));
|
|
||||||
}
|
|
||||||
const changedGeometry = changedBuckets.size > 0;
|
|
||||||
for (const bucket of changedBuckets) {
|
|
||||||
this.#updateGeometry(bucket, { invalidateHeight: true });
|
|
||||||
}
|
|
||||||
if (changedGeometry) {
|
|
||||||
this.updateIntersections();
|
|
||||||
}
|
|
||||||
return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
|
|
||||||
}
|
|
||||||
|
|
||||||
updateAssetOperation(ids: string[], operation: AssetOperation) {
|
|
||||||
this.#runAssetOperation(new Set(ids), operation);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateAssets(assets: TimelineAsset[]) {
|
|
||||||
const lookup = new Map<string, TimelineAsset>(assets.map((asset) => [asset.id, asset]));
|
|
||||||
const { unprocessedIds } = this.#runAssetOperation(new Set(lookup.keys()), (asset) => {
|
|
||||||
updateObject(asset, lookup.get(asset.id));
|
|
||||||
return { remove: false };
|
|
||||||
});
|
|
||||||
return unprocessedIds.values().map((id) => lookup.get(id)!);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeAssets(ids: string[]) {
|
|
||||||
const { unprocessedIds } = this.#runAssetOperation(new Set(ids), () => {
|
|
||||||
return { remove: true };
|
|
||||||
});
|
|
||||||
return [...unprocessedIds];
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshLayout() {
|
|
||||||
for (const bucket of this.buckets) {
|
|
||||||
this.#updateGeometry(bucket, { invalidateHeight: true });
|
|
||||||
}
|
|
||||||
this.updateIntersections();
|
|
||||||
}
|
|
||||||
|
|
||||||
getFirstAsset(): TimelineAsset | undefined {
|
|
||||||
return this.buckets[0]?.getFirstAsset();
|
|
||||||
}
|
|
||||||
|
|
||||||
async getLaterAsset(
|
|
||||||
assetDescriptor: AssetDescriptor,
|
|
||||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
|
||||||
): Promise<TimelineAsset | undefined> {
|
|
||||||
return await this.#getAssetWithOffset(assetDescriptor, interval, 'later');
|
|
||||||
}
|
|
||||||
|
|
||||||
async getEarlierAsset(
|
|
||||||
assetDescriptor: AssetDescriptor,
|
|
||||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
|
||||||
): Promise<TimelineAsset | undefined> {
|
|
||||||
return await this.#getAssetWithOffset(assetDescriptor, interval, 'earlier');
|
|
||||||
}
|
|
||||||
|
|
||||||
async getClosestAssetToDate(dateTime: TimelinePlainDateTime) {
|
|
||||||
const bucket = this.#findBucketForDate(dateTime);
|
|
||||||
if (!bucket) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.loadBucket(dateTime, { cancelable: false });
|
|
||||||
const asset = bucket.findClosest(dateTime);
|
|
||||||
if (asset) {
|
|
||||||
return asset;
|
|
||||||
}
|
|
||||||
for await (const asset of this.assetsIterator({ startBucket: bucket })) {
|
|
||||||
return asset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async retrieveRange(start: AssetDescriptor, end: AssetDescriptor) {
|
|
||||||
let { asset: startAsset, bucket: startBucket } = this.#findBucketForAsset(start.id) ?? {};
|
|
||||||
if (!startBucket || !startAsset) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
let { asset: endAsset, bucket: endBucket } = this.#findBucketForAsset(end.id) ?? {};
|
|
||||||
if (!endBucket || !endAsset) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
let direction: Direction = 'earlier';
|
|
||||||
if (plainDateTimeCompare(true, startAsset.localDateTime, endAsset.localDateTime) < 0) {
|
|
||||||
[startAsset, endAsset] = [endAsset, startAsset];
|
|
||||||
[startBucket, endBucket] = [endBucket, startBucket];
|
|
||||||
direction = 'earlier';
|
|
||||||
}
|
|
||||||
|
|
||||||
const range: TimelineAsset[] = [];
|
|
||||||
const startDateGroup = startBucket.findDateGroupForAsset(startAsset);
|
|
||||||
for await (const targetAsset of this.assetsIterator({
|
|
||||||
startBucket,
|
|
||||||
startDateGroup,
|
|
||||||
startAsset,
|
|
||||||
direction,
|
|
||||||
})) {
|
|
||||||
range.push(targetAsset);
|
|
||||||
if (targetAsset.id === endAsset.id) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return range;
|
|
||||||
}
|
|
||||||
|
|
||||||
async #getAssetWithOffset(
|
|
||||||
assetDescriptor: AssetDescriptor,
|
|
||||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
|
||||||
direction: Direction,
|
|
||||||
): Promise<TimelineAsset | undefined> {
|
|
||||||
const { asset, bucket } = this.#findBucketForAsset(assetDescriptor.id) ?? {};
|
|
||||||
if (!bucket || !asset) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (interval) {
|
|
||||||
case 'asset': {
|
|
||||||
return this.#getAssetByAssetOffset(asset, bucket, direction);
|
|
||||||
}
|
|
||||||
case 'day': {
|
|
||||||
return this.#getAssetByDayOffset(asset, bucket, direction);
|
|
||||||
}
|
|
||||||
case 'month': {
|
|
||||||
return this.#getAssetByMonthOffset(bucket, direction);
|
|
||||||
}
|
|
||||||
case 'year': {
|
|
||||||
return this.#getAssetByYearOffset(bucket, direction);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async #getAssetByAssetOffset(asset: TimelineAsset, bucket: AssetBucket, direction: Direction) {
|
|
||||||
const dateGroup = bucket.findDateGroupForAsset(asset);
|
|
||||||
for await (const targetAsset of this.assetsIterator({
|
|
||||||
startBucket: bucket,
|
|
||||||
startDateGroup: dateGroup,
|
|
||||||
startAsset: asset,
|
|
||||||
direction,
|
|
||||||
})) {
|
|
||||||
if (asset.id === targetAsset.id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return targetAsset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async #getAssetByDayOffset(asset: TimelineAsset, bucket: AssetBucket, direction: Direction) {
|
|
||||||
const dateGroup = bucket.findDateGroupForAsset(asset);
|
|
||||||
for await (const targetAsset of this.assetsIterator({
|
|
||||||
startBucket: bucket,
|
|
||||||
startDateGroup: dateGroup,
|
|
||||||
startAsset: asset,
|
|
||||||
direction,
|
|
||||||
})) {
|
|
||||||
if (targetAsset.localDateTime.day !== asset.localDateTime.day) {
|
|
||||||
return targetAsset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async #getAssetByMonthOffset(bucket: AssetBucket, direction: Direction) {
|
|
||||||
for (const targetBucket of this.bucketsIterator({ startBucket: bucket, direction })) {
|
|
||||||
if (targetBucket.yearMonth.month !== bucket.yearMonth.month) {
|
|
||||||
for await (const targetAsset of this.assetsIterator({ startBucket: targetBucket, direction })) {
|
|
||||||
return targetAsset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async #getAssetByYearOffset(bucket: AssetBucket, direction: Direction) {
|
|
||||||
for (const targetBucket of this.bucketsIterator({ startBucket: bucket, direction })) {
|
|
||||||
if (targetBucket.yearMonth.year !== bucket.yearMonth.year) {
|
|
||||||
for await (const targetAsset of this.assetsIterator({ startBucket: targetBucket, direction })) {
|
|
||||||
return targetAsset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isExcluded(asset: TimelineAsset) {
|
|
||||||
return (
|
|
||||||
isMismatched(this.#options.visibility, asset.visibility) ||
|
|
||||||
isMismatched(this.#options.isFavorite, asset.isFavorite) ||
|
|
||||||
isMismatched(this.#options.isTrashed, asset.isTrashed)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,21 +1,23 @@
|
|||||||
|
import { AssetOrder } from '@immich/sdk';
|
||||||
|
|
||||||
import type { CommonLayoutOptions } from '$lib/utils/layout-utils';
|
import type { CommonLayoutOptions } from '$lib/utils/layout-utils';
|
||||||
import { getJustifiedLayoutFromAssets, getPosition } from '$lib/utils/layout-utils';
|
import { getJustifiedLayoutFromAssets, getPosition } from '$lib/utils/layout-utils';
|
||||||
import { plainDateTimeCompare } from '$lib/utils/timeline-util';
|
import { plainDateTimeCompare } from '$lib/utils/timeline-util';
|
||||||
import { AssetOrder } from '@immich/sdk';
|
|
||||||
import type { AssetBucket } from './asset-bucket.svelte';
|
|
||||||
import { IntersectingAsset } from './intersecting-asset.svelte';
|
|
||||||
import type { AssetOperation, Direction, MoveAsset, TimelineAsset } from './types';
|
|
||||||
|
|
||||||
export class AssetDateGroup {
|
import type { MonthGroup } from './month-group.svelte';
|
||||||
readonly bucket: AssetBucket;
|
import type { AssetOperation, Direction, MoveAsset, TimelineAsset } from './types';
|
||||||
|
import { ViewerAsset } from './viewer-asset.svelte';
|
||||||
|
|
||||||
|
export class DayGroup {
|
||||||
|
readonly monthGroup: MonthGroup;
|
||||||
readonly index: number;
|
readonly index: number;
|
||||||
readonly groupTitle: string;
|
readonly groupTitle: string;
|
||||||
readonly day: number;
|
readonly day: number;
|
||||||
intersectingAssets: IntersectingAsset[] = $state([]);
|
viewerAssets: ViewerAsset[] = $state([]);
|
||||||
|
|
||||||
height = $state(0);
|
height = $state(0);
|
||||||
width = $state(0);
|
width = $state(0);
|
||||||
intersecting = $derived.by(() => this.intersectingAssets.some((asset) => asset.intersecting));
|
intersecting = $derived.by(() => this.viewerAssets.some((viewAsset) => viewAsset.intersecting));
|
||||||
|
|
||||||
#top: number = $state(0);
|
#top: number = $state(0);
|
||||||
#left: number = $state(0);
|
#left: number = $state(0);
|
||||||
@ -23,9 +25,9 @@ export class AssetDateGroup {
|
|||||||
#col = $state(0);
|
#col = $state(0);
|
||||||
#deferredLayout = false;
|
#deferredLayout = false;
|
||||||
|
|
||||||
constructor(bucket: AssetBucket, index: number, day: number, groupTitle: string) {
|
constructor(monthGroup: MonthGroup, index: number, day: number, groupTitle: string) {
|
||||||
this.index = index;
|
this.index = index;
|
||||||
this.bucket = bucket;
|
this.monthGroup = monthGroup;
|
||||||
this.day = day;
|
this.day = day;
|
||||||
this.groupTitle = groupTitle;
|
this.groupTitle = groupTitle;
|
||||||
}
|
}
|
||||||
@ -72,35 +74,35 @@ export class AssetDateGroup {
|
|||||||
|
|
||||||
sortAssets(sortOrder: AssetOrder = AssetOrder.Desc) {
|
sortAssets(sortOrder: AssetOrder = AssetOrder.Desc) {
|
||||||
const sortFn = plainDateTimeCompare.bind(undefined, sortOrder === AssetOrder.Asc);
|
const sortFn = plainDateTimeCompare.bind(undefined, sortOrder === AssetOrder.Asc);
|
||||||
this.intersectingAssets.sort((a, b) => sortFn(a.asset.fileCreatedAt, b.asset.fileCreatedAt));
|
this.viewerAssets.sort((a, b) => sortFn(a.asset.fileCreatedAt, b.asset.fileCreatedAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
getFirstAsset() {
|
getFirstAsset() {
|
||||||
return this.intersectingAssets[0]?.asset;
|
return this.viewerAssets[0]?.asset;
|
||||||
}
|
}
|
||||||
|
|
||||||
getRandomAsset() {
|
getRandomAsset() {
|
||||||
const random = Math.floor(Math.random() * this.intersectingAssets.length);
|
const random = Math.floor(Math.random() * this.viewerAssets.length);
|
||||||
return this.intersectingAssets[random];
|
return this.viewerAssets[random];
|
||||||
}
|
}
|
||||||
|
|
||||||
*assetsIterator(options: { startAsset?: TimelineAsset; direction?: Direction } = {}) {
|
*assetsIterator(options: { startAsset?: TimelineAsset; direction?: Direction } = {}) {
|
||||||
const isEarlier = (options?.direction ?? 'earlier') === 'earlier';
|
const isEarlier = (options?.direction ?? 'earlier') === 'earlier';
|
||||||
let assetIndex = options?.startAsset
|
let assetIndex = options?.startAsset
|
||||||
? this.intersectingAssets.findIndex((intersectingAsset) => intersectingAsset.asset.id === options.startAsset!.id)
|
? this.viewerAssets.findIndex((viewerAsset) => viewerAsset.asset.id === options.startAsset!.id)
|
||||||
: isEarlier
|
: isEarlier
|
||||||
? 0
|
? 0
|
||||||
: this.intersectingAssets.length - 1;
|
: this.viewerAssets.length - 1;
|
||||||
|
|
||||||
while (assetIndex >= 0 && assetIndex < this.intersectingAssets.length) {
|
while (assetIndex >= 0 && assetIndex < this.viewerAssets.length) {
|
||||||
const intersectingAsset = this.intersectingAssets[assetIndex];
|
const viewerAsset = this.viewerAssets[assetIndex];
|
||||||
yield intersectingAsset.asset;
|
yield viewerAsset.asset;
|
||||||
assetIndex += isEarlier ? 1 : -1;
|
assetIndex += isEarlier ? 1 : -1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getAssets() {
|
getAssets() {
|
||||||
return this.intersectingAssets.map((intersectingasset) => intersectingasset.asset);
|
return this.viewerAssets.map((viewerAsset) => viewerAsset.asset);
|
||||||
}
|
}
|
||||||
|
|
||||||
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
|
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
|
||||||
@ -117,12 +119,12 @@ export class AssetDateGroup {
|
|||||||
const moveAssets: MoveAsset[] = [];
|
const moveAssets: MoveAsset[] = [];
|
||||||
let changedGeometry = false;
|
let changedGeometry = false;
|
||||||
for (const assetId of unprocessedIds) {
|
for (const assetId of unprocessedIds) {
|
||||||
const index = this.intersectingAssets.findIndex((ia) => ia.id == assetId);
|
const index = this.viewerAssets.findIndex((viewAsset) => viewAsset.id == assetId);
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const asset = this.intersectingAssets[index].asset!;
|
const asset = this.viewerAssets[index].asset!;
|
||||||
const oldTime = { ...asset.localDateTime };
|
const oldTime = { ...asset.localDateTime };
|
||||||
let { remove } = operation(asset);
|
let { remove } = operation(asset);
|
||||||
const newTime = asset.localDateTime;
|
const newTime = asset.localDateTime;
|
||||||
@ -133,8 +135,8 @@ export class AssetDateGroup {
|
|||||||
}
|
}
|
||||||
unprocessedIds.delete(assetId);
|
unprocessedIds.delete(assetId);
|
||||||
processedIds.add(assetId);
|
processedIds.add(assetId);
|
||||||
if (remove || this.bucket.store.isExcluded(asset)) {
|
if (remove || this.monthGroup.timelineManager.isExcluded(asset)) {
|
||||||
this.intersectingAssets.splice(index, 1);
|
this.viewerAssets.splice(index, 1);
|
||||||
changedGeometry = true;
|
changedGeometry = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -142,21 +144,21 @@ export class AssetDateGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
layout(options: CommonLayoutOptions, noDefer: boolean) {
|
layout(options: CommonLayoutOptions, noDefer: boolean) {
|
||||||
if (!noDefer && !this.bucket.intersecting) {
|
if (!noDefer && !this.monthGroup.intersecting) {
|
||||||
this.#deferredLayout = true;
|
this.#deferredLayout = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const assets = this.intersectingAssets.map((intersetingAsset) => intersetingAsset.asset!);
|
const assets = this.viewerAssets.map((viewerAsset) => viewerAsset.asset!);
|
||||||
const geometry = getJustifiedLayoutFromAssets(assets, options);
|
const geometry = getJustifiedLayoutFromAssets(assets, options);
|
||||||
this.width = geometry.containerWidth;
|
this.width = geometry.containerWidth;
|
||||||
this.height = assets.length === 0 ? 0 : geometry.containerHeight;
|
this.height = assets.length === 0 ? 0 : geometry.containerHeight;
|
||||||
for (let i = 0; i < this.intersectingAssets.length; i++) {
|
for (let i = 0; i < this.viewerAssets.length; i++) {
|
||||||
const position = getPosition(geometry, i);
|
const position = getPosition(geometry, i);
|
||||||
this.intersectingAssets[i].position = position;
|
this.viewerAssets[i].position = position;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get absoluteDateGroupTop() {
|
get absoluteDayGroupTop() {
|
||||||
return this.bucket.top + this.#top;
|
return this.monthGroup.top + this.#top;
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -0,0 +1,60 @@
|
|||||||
|
import type { TimelinePlainDate } from '$lib/utils/timeline-util';
|
||||||
|
import { AssetOrder } from '@immich/sdk';
|
||||||
|
import type { DayGroup } from './day-group.svelte';
|
||||||
|
import type { MonthGroup } from './month-group.svelte';
|
||||||
|
import type { TimelineAsset } from './types';
|
||||||
|
|
||||||
|
export class GroupInsertionCache {
|
||||||
|
#lookupCache: {
|
||||||
|
[year: number]: { [month: number]: { [day: number]: DayGroup } };
|
||||||
|
} = {};
|
||||||
|
unprocessedAssets: TimelineAsset[] = [];
|
||||||
|
changedDayGroups = new Set<DayGroup>();
|
||||||
|
newDayGroups = new Set<DayGroup>();
|
||||||
|
|
||||||
|
getDayGroup({ year, month, day }: TimelinePlainDate): DayGroup | undefined {
|
||||||
|
return this.#lookupCache[year]?.[month]?.[day];
|
||||||
|
}
|
||||||
|
|
||||||
|
setDayGroup(dayGroup: DayGroup, { year, month, day }: TimelinePlainDate) {
|
||||||
|
if (!this.#lookupCache[year]) {
|
||||||
|
this.#lookupCache[year] = {};
|
||||||
|
}
|
||||||
|
if (!this.#lookupCache[year][month]) {
|
||||||
|
this.#lookupCache[year][month] = {};
|
||||||
|
}
|
||||||
|
this.#lookupCache[year][month][day] = dayGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
get existingDayGroups() {
|
||||||
|
return this.changedDayGroups.difference(this.newDayGroups);
|
||||||
|
}
|
||||||
|
|
||||||
|
get updatedBuckets() {
|
||||||
|
const updated = new Set<MonthGroup>();
|
||||||
|
for (const group of this.changedDayGroups) {
|
||||||
|
updated.add(group.monthGroup);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
get bucketsWithNewDayGroups() {
|
||||||
|
const updated = new Set<MonthGroup>();
|
||||||
|
for (const group of this.newDayGroups) {
|
||||||
|
updated.add(group.monthGroup);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
sort(monthGroup: MonthGroup, sortOrder: AssetOrder = AssetOrder.Desc) {
|
||||||
|
for (const group of this.changedDayGroups) {
|
||||||
|
group.sortAssets(sortOrder);
|
||||||
|
}
|
||||||
|
for (const group of this.newDayGroups) {
|
||||||
|
group.sortAssets(sortOrder);
|
||||||
|
}
|
||||||
|
if (this.newDayGroups.size > 0) {
|
||||||
|
monthGroup.sortDayGroups();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,77 @@
|
|||||||
|
import { TUNABLES } from '$lib/utils/tunables';
|
||||||
|
import type { MonthGroup } from '../month-group.svelte';
|
||||||
|
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||||
|
|
||||||
|
const {
|
||||||
|
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
|
||||||
|
} = TUNABLES;
|
||||||
|
|
||||||
|
export function updateIntersectionMonthGroup(timelineManager: TimelineManager, month: MonthGroup) {
|
||||||
|
const actuallyIntersecting = calculateMonthGroupIntersecting(timelineManager, month, 0, 0);
|
||||||
|
let preIntersecting = false;
|
||||||
|
if (!actuallyIntersecting) {
|
||||||
|
preIntersecting = calculateMonthGroupIntersecting(
|
||||||
|
timelineManager,
|
||||||
|
month,
|
||||||
|
INTERSECTION_EXPAND_TOP,
|
||||||
|
INTERSECTION_EXPAND_BOTTOM,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
month.intersecting = actuallyIntersecting || preIntersecting;
|
||||||
|
month.actuallyIntersecting = actuallyIntersecting;
|
||||||
|
if (preIntersecting || actuallyIntersecting) {
|
||||||
|
timelineManager.clearDeferredLayout(month);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* General function to check if a rectangular region intersects with a window.
|
||||||
|
* @param regionTop - Top position of the region to check
|
||||||
|
* @param regionBottom - Bottom position of the region to check
|
||||||
|
* @param windowTop - Top position of the window
|
||||||
|
* @param windowBottom - Bottom position of the window
|
||||||
|
* @returns true if the region intersects with the window
|
||||||
|
*/
|
||||||
|
export function isIntersecting(regionTop: number, regionBottom: number, windowTop: number, windowBottom: number) {
|
||||||
|
return (
|
||||||
|
(regionTop >= windowTop && regionTop < windowBottom) ||
|
||||||
|
(regionBottom >= windowTop && regionBottom < windowBottom) ||
|
||||||
|
(regionTop < windowTop && regionBottom >= windowBottom)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateMonthGroupIntersecting(
|
||||||
|
timelineManager: TimelineManager,
|
||||||
|
monthGroup: MonthGroup,
|
||||||
|
expandTop: number,
|
||||||
|
expandBottom: number,
|
||||||
|
) {
|
||||||
|
const monthGroupTop = monthGroup.top;
|
||||||
|
const monthGroupBottom = monthGroupTop + monthGroup.height;
|
||||||
|
const topWindow = timelineManager.visibleWindow.top - expandTop;
|
||||||
|
const bottomWindow = timelineManager.visibleWindow.bottom + expandBottom;
|
||||||
|
|
||||||
|
return isIntersecting(monthGroupTop, monthGroupBottom, topWindow, bottomWindow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate intersection for viewer assets with additional parameters like header height and scroll compensation
|
||||||
|
*/
|
||||||
|
export function calculateViewerAssetIntersecting(
|
||||||
|
timelineManager: TimelineManager,
|
||||||
|
positionTop: number,
|
||||||
|
positionHeight: number,
|
||||||
|
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 positionBottom = positionTop + positionHeight;
|
||||||
|
|
||||||
|
return isIntersecting(positionTop, positionBottom, topWindow, bottomWindow);
|
||||||
|
}
|
@ -0,0 +1,70 @@
|
|||||||
|
import type { MonthGroup } from '../month-group.svelte';
|
||||||
|
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||||
|
import type { UpdateGeometryOptions } from '../types';
|
||||||
|
|
||||||
|
export function updateGeometry(timelineManager: TimelineManager, month: MonthGroup, options: UpdateGeometryOptions) {
|
||||||
|
const { invalidateHeight, noDefer = false } = options;
|
||||||
|
if (invalidateHeight) {
|
||||||
|
month.isHeightActual = false;
|
||||||
|
}
|
||||||
|
if (!month.isLoaded) {
|
||||||
|
const viewportWidth = timelineManager.viewportWidth;
|
||||||
|
if (!month.isHeightActual) {
|
||||||
|
const unwrappedWidth = (3 / 2) * month.assetsCount * timelineManager.rowHeight * (7 / 10);
|
||||||
|
const rows = Math.ceil(unwrappedWidth / viewportWidth);
|
||||||
|
const height = 51 + Math.max(1, rows) * timelineManager.rowHeight;
|
||||||
|
month.height = height;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
layoutMonthGroup(timelineManager, month, noDefer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function layoutMonthGroup(timelineManager: TimelineManager, month: MonthGroup, noDefer: boolean = false) {
|
||||||
|
let cumulativeHeight = 0;
|
||||||
|
let cumulativeWidth = 0;
|
||||||
|
let currentRowHeight = 0;
|
||||||
|
|
||||||
|
let dayGroupRow = 0;
|
||||||
|
let dayGroupCol = 0;
|
||||||
|
|
||||||
|
const options = timelineManager.createLayoutOptions();
|
||||||
|
for (const dayGroup of month.dayGroups) {
|
||||||
|
dayGroup.layout(options, noDefer);
|
||||||
|
|
||||||
|
// Calculate space needed for this item (including gap if not first in row)
|
||||||
|
const spaceNeeded = dayGroup.width + (dayGroupCol > 0 ? timelineManager.gap : 0);
|
||||||
|
const fitsInCurrentRow = cumulativeWidth + spaceNeeded <= timelineManager.viewportWidth;
|
||||||
|
|
||||||
|
if (fitsInCurrentRow) {
|
||||||
|
dayGroup.row = dayGroupRow;
|
||||||
|
dayGroup.col = dayGroupCol++;
|
||||||
|
dayGroup.left = cumulativeWidth;
|
||||||
|
dayGroup.top = cumulativeHeight;
|
||||||
|
|
||||||
|
cumulativeWidth += dayGroup.width + timelineManager.gap;
|
||||||
|
} else {
|
||||||
|
// Move to next row
|
||||||
|
cumulativeHeight += currentRowHeight;
|
||||||
|
cumulativeWidth = 0;
|
||||||
|
dayGroupRow++;
|
||||||
|
dayGroupCol = 0;
|
||||||
|
|
||||||
|
// Position at start of new row
|
||||||
|
dayGroup.row = dayGroupRow;
|
||||||
|
dayGroup.col = dayGroupCol;
|
||||||
|
dayGroup.left = 0;
|
||||||
|
dayGroup.top = cumulativeHeight;
|
||||||
|
|
||||||
|
dayGroupCol++;
|
||||||
|
cumulativeWidth += dayGroup.width + timelineManager.gap;
|
||||||
|
}
|
||||||
|
currentRowHeight = dayGroup.height + timelineManager.headerHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the height of the final row
|
||||||
|
cumulativeHeight += currentRowHeight;
|
||||||
|
|
||||||
|
month.height = cumulativeHeight;
|
||||||
|
month.isHeightActual = true;
|
||||||
|
}
|
@ -0,0 +1,62 @@
|
|||||||
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
|
import { toISOYearMonthUTC } from '$lib/utils/timeline-util';
|
||||||
|
import { getTimeBucket } from '@immich/sdk';
|
||||||
|
|
||||||
|
import type { MonthGroup } from '../month-group.svelte';
|
||||||
|
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||||
|
import type { TimelineManagerOptions } from '../types';
|
||||||
|
import { layoutMonthGroup } from './layout-support.svelte';
|
||||||
|
|
||||||
|
export async function loadFromTimeBuckets(
|
||||||
|
timelineManager: TimelineManager,
|
||||||
|
monthGroup: MonthGroup,
|
||||||
|
options: TimelineManagerOptions,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
if (monthGroup.getFirstAsset()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeBucket = toISOYearMonthUTC(monthGroup.yearMonth);
|
||||||
|
const key = authManager.key;
|
||||||
|
const bucketResponse = await getTimeBucket(
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
timeBucket,
|
||||||
|
key,
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!bucketResponse) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.timelineAlbumId) {
|
||||||
|
const albumAssets = await getTimeBucket(
|
||||||
|
{
|
||||||
|
albumId: options.timelineAlbumId,
|
||||||
|
timeBucket,
|
||||||
|
key,
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
for (const id of albumAssets.id) {
|
||||||
|
timelineManager.albumAssets.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unprocessedAssets = monthGroup.addAssets(bucketResponse);
|
||||||
|
if (unprocessedAssets.length > 0) {
|
||||||
|
console.error(
|
||||||
|
`Warning: getTimeBucket API returning assets not in requested month: ${monthGroup.yearMonth.month}, ${JSON.stringify(
|
||||||
|
unprocessedAssets.map((unprocessed) => ({
|
||||||
|
id: unprocessed.id,
|
||||||
|
localDateTime: unprocessed.localDateTime,
|
||||||
|
})),
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
layoutMonthGroup(timelineManager, monthGroup);
|
||||||
|
}
|
@ -0,0 +1,103 @@
|
|||||||
|
import type { TimelinePlainDate } from '$lib/utils/timeline-util';
|
||||||
|
import { AssetOrder } from '@immich/sdk';
|
||||||
|
|
||||||
|
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 Set<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, options.order);
|
||||||
|
month.isLoaded = true;
|
||||||
|
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 Set(), unprocessedIds: ids, changedGeometry: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const changedMonthGroups = new Set<MonthGroup>();
|
||||||
|
let idsToProcess = new Set(ids);
|
||||||
|
const idsProcessed = new Set<string>();
|
||||||
|
const combinedMoveAssets: { asset: TimelineAsset; date: TimelinePlainDate }[][] = [];
|
||||||
|
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 = idsToProcess.difference(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 };
|
||||||
|
}
|
@ -0,0 +1,146 @@
|
|||||||
|
import { plainDateTimeCompare, type TimelinePlainYearMonth } from '$lib/utils/timeline-util';
|
||||||
|
import type { MonthGroup } from '../month-group.svelte';
|
||||||
|
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||||
|
import type { AssetDescriptor, Direction, TimelineAsset } from '../types';
|
||||||
|
|
||||||
|
export async function getAssetWithOffset(
|
||||||
|
timelineManager: TimelineManager,
|
||||||
|
assetDescriptor: AssetDescriptor,
|
||||||
|
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||||
|
direction: Direction,
|
||||||
|
): Promise<TimelineAsset | undefined> {
|
||||||
|
const { asset, monthGroup } = findMonthGroupForAsset(timelineManager, assetDescriptor.id) ?? {};
|
||||||
|
if (!monthGroup || !asset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (interval) {
|
||||||
|
case 'asset': {
|
||||||
|
return getAssetByAssetOffset(timelineManager, asset, monthGroup, direction);
|
||||||
|
}
|
||||||
|
case 'day': {
|
||||||
|
return getAssetByDayOffset(timelineManager, asset, monthGroup, direction);
|
||||||
|
}
|
||||||
|
case 'month': {
|
||||||
|
return getAssetByMonthOffset(timelineManager, monthGroup, direction);
|
||||||
|
}
|
||||||
|
case 'year': {
|
||||||
|
return getAssetByYearOffset(timelineManager, monthGroup, direction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findMonthGroupForAsset(timelineManager: TimelineManager, id: string) {
|
||||||
|
for (const month of timelineManager.months) {
|
||||||
|
const asset = month.findAssetById({ id });
|
||||||
|
if (asset) {
|
||||||
|
return { monthGroup: month, asset };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMonthGroupByDate(
|
||||||
|
timelineManager: TimelineManager,
|
||||||
|
targetYearMonth: TimelinePlainYearMonth,
|
||||||
|
): MonthGroup | undefined {
|
||||||
|
return timelineManager.months.find(
|
||||||
|
(month) => month.yearMonth.year === targetYearMonth.year && month.yearMonth.month === targetYearMonth.month,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAssetByAssetOffset(
|
||||||
|
timelineManager: TimelineManager,
|
||||||
|
asset: TimelineAsset,
|
||||||
|
monthGroup: MonthGroup,
|
||||||
|
direction: Direction,
|
||||||
|
) {
|
||||||
|
const dayGroup = monthGroup.findDayGroupForAsset(asset);
|
||||||
|
for await (const targetAsset of timelineManager.assetsIterator({
|
||||||
|
startMonthGroup: monthGroup,
|
||||||
|
startDayGroup: dayGroup,
|
||||||
|
startAsset: asset,
|
||||||
|
direction,
|
||||||
|
})) {
|
||||||
|
if (asset.id !== targetAsset.id) {
|
||||||
|
return targetAsset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAssetByDayOffset(
|
||||||
|
timelineManager: TimelineManager,
|
||||||
|
asset: TimelineAsset,
|
||||||
|
monthGroup: MonthGroup,
|
||||||
|
direction: Direction,
|
||||||
|
) {
|
||||||
|
const dayGroup = monthGroup.findDayGroupForAsset(asset);
|
||||||
|
for await (const targetAsset of timelineManager.assetsIterator({
|
||||||
|
startMonthGroup: monthGroup,
|
||||||
|
startDayGroup: dayGroup,
|
||||||
|
startAsset: asset,
|
||||||
|
direction,
|
||||||
|
})) {
|
||||||
|
if (targetAsset.localDateTime.day !== asset.localDateTime.day) {
|
||||||
|
return targetAsset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAssetByMonthOffset(timelineManager: TimelineManager, month: MonthGroup, direction: Direction) {
|
||||||
|
for (const targetMonth of timelineManager.monthGroupIterator({ startMonthGroup: month, direction })) {
|
||||||
|
if (targetMonth.yearMonth.month !== month.yearMonth.month) {
|
||||||
|
const { value, done } = await timelineManager.assetsIterator({ startMonthGroup: targetMonth, direction }).next();
|
||||||
|
return done ? undefined : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAssetByYearOffset(timelineManager: TimelineManager, month: MonthGroup, direction: Direction) {
|
||||||
|
for (const targetMonth of timelineManager.monthGroupIterator({ startMonthGroup: month, direction })) {
|
||||||
|
if (targetMonth.yearMonth.year !== month.yearMonth.year) {
|
||||||
|
const { value, done } = await timelineManager.assetsIterator({ startMonthGroup: targetMonth, direction }).next();
|
||||||
|
return done ? undefined : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retrieveRange(timelineManager: TimelineManager, start: AssetDescriptor, end: AssetDescriptor) {
|
||||||
|
let { asset: startAsset, monthGroup: startMonthGroup } = findMonthGroupForAsset(timelineManager, start.id) ?? {};
|
||||||
|
if (!startMonthGroup || !startAsset) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
let { asset: endAsset, monthGroup: endMonthGroup } = findMonthGroupForAsset(timelineManager, end.id) ?? {};
|
||||||
|
if (!endMonthGroup || !endAsset) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
let direction: Direction = 'earlier';
|
||||||
|
if (plainDateTimeCompare(true, startAsset.localDateTime, endAsset.localDateTime) < 0) {
|
||||||
|
[startAsset, endAsset] = [endAsset, startAsset];
|
||||||
|
[startMonthGroup, endMonthGroup] = [endMonthGroup, startMonthGroup];
|
||||||
|
direction = 'earlier';
|
||||||
|
}
|
||||||
|
|
||||||
|
const range: TimelineAsset[] = [];
|
||||||
|
const startDayGroup = startMonthGroup.findDayGroupForAsset(startAsset);
|
||||||
|
for await (const targetAsset of timelineManager.assetsIterator({
|
||||||
|
startMonthGroup,
|
||||||
|
startDayGroup,
|
||||||
|
startAsset,
|
||||||
|
direction,
|
||||||
|
})) {
|
||||||
|
range.push(targetAsset);
|
||||||
|
if (targetAsset.id === endAsset.id) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return range;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findMonthGroupForDate(timelineManager: TimelineManager, targetYearMonth: TimelinePlainYearMonth) {
|
||||||
|
for (const month of timelineManager.months) {
|
||||||
|
const { year, month: monthNum } = month.yearMonth;
|
||||||
|
if (monthNum === targetYearMonth.month && year === targetYearMonth.year) {
|
||||||
|
return month;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,28 @@
|
|||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export function updateObject(target: any, source: any): boolean {
|
||||||
|
if (!target) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let updated = false;
|
||||||
|
for (const key in source) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(source, key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === '__proto__' || key === 'constructor') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const isDate = target[key] instanceof Date;
|
||||||
|
if (typeof target[key] === 'object' && !isDate) {
|
||||||
|
updated = updated || updateObject(target[key], source[key]);
|
||||||
|
} else {
|
||||||
|
if (target[key] !== source[key]) {
|
||||||
|
target[key] = source[key];
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
export function isMismatched<T>(option: T | undefined, value: T): boolean {
|
||||||
|
return option === undefined ? false : option !== value;
|
||||||
|
}
|
@ -0,0 +1,85 @@
|
|||||||
|
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
|
import type { PendingChange, TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
|
import { websocketEvents } from '$lib/stores/websocket';
|
||||||
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
|
import { throttle } from 'lodash-es';
|
||||||
|
import type { Unsubscriber } from 'svelte/store';
|
||||||
|
|
||||||
|
export class WebsocketSupport {
|
||||||
|
#pendingChanges: PendingChange[] = [];
|
||||||
|
#unsubscribers: Unsubscriber[] = [];
|
||||||
|
#timelineManager: TimelineManager;
|
||||||
|
|
||||||
|
#processPendingChanges = throttle(() => {
|
||||||
|
const { add, update, remove } = this.#getPendingChangeBatches();
|
||||||
|
if (add.length > 0) {
|
||||||
|
this.#timelineManager.addAssets(add);
|
||||||
|
}
|
||||||
|
if (update.length > 0) {
|
||||||
|
this.#timelineManager.updateAssets(update);
|
||||||
|
}
|
||||||
|
if (remove.length > 0) {
|
||||||
|
this.#timelineManager.removeAssets(remove);
|
||||||
|
}
|
||||||
|
this.#pendingChanges = [];
|
||||||
|
}, 2500);
|
||||||
|
|
||||||
|
constructor(timeineManager: TimelineManager) {
|
||||||
|
this.#timelineManager = timeineManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectWebsocketEvents() {
|
||||||
|
this.#unsubscribers.push(
|
||||||
|
websocketEvents.on('on_upload_success', (asset) =>
|
||||||
|
this.#addPendingChanges({ type: 'add', values: [toTimelineAsset(asset)] }),
|
||||||
|
),
|
||||||
|
websocketEvents.on('on_asset_trash', (ids) => this.#addPendingChanges({ type: 'trash', values: ids })),
|
||||||
|
websocketEvents.on('on_asset_update', (asset) =>
|
||||||
|
this.#addPendingChanges({ type: 'update', values: [toTimelineAsset(asset)] }),
|
||||||
|
),
|
||||||
|
websocketEvents.on('on_asset_delete', (id: string) => this.#addPendingChanges({ type: 'delete', values: [id] })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectWebsocketEvents() {
|
||||||
|
for (const unsubscribe of this.#unsubscribers) {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
this.#unsubscribers = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
#addPendingChanges(...changes: PendingChange[]) {
|
||||||
|
this.#pendingChanges.push(...changes);
|
||||||
|
this.#processPendingChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
#getPendingChangeBatches() {
|
||||||
|
const batch: {
|
||||||
|
add: TimelineAsset[];
|
||||||
|
update: TimelineAsset[];
|
||||||
|
remove: string[];
|
||||||
|
} = {
|
||||||
|
add: [],
|
||||||
|
update: [],
|
||||||
|
remove: [],
|
||||||
|
};
|
||||||
|
for (const { type, values } of this.#pendingChanges) {
|
||||||
|
switch (type) {
|
||||||
|
case 'add': {
|
||||||
|
batch.add.push(...values);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'update': {
|
||||||
|
batch.update.push(...values);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'delete':
|
||||||
|
case 'trash': {
|
||||||
|
batch.remove.push(...values);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return batch;
|
||||||
|
}
|
||||||
|
}
|
@ -1,45 +0,0 @@
|
|||||||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
|
||||||
import { TUNABLES } from '$lib/utils/tunables';
|
|
||||||
import type { AssetDateGroup } from './asset-date-group.svelte';
|
|
||||||
import type { TimelineAsset } from './types';
|
|
||||||
|
|
||||||
const {
|
|
||||||
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
|
|
||||||
} = TUNABLES;
|
|
||||||
|
|
||||||
export class IntersectingAsset {
|
|
||||||
readonly #group: AssetDateGroup;
|
|
||||||
|
|
||||||
intersecting = $derived.by(() => {
|
|
||||||
if (!this.position) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = this.#group.bucket.store;
|
|
||||||
|
|
||||||
const scrollCompensation = store.scrollCompensation;
|
|
||||||
const scrollCompensationHeightDelta = scrollCompensation?.heightDelta ?? 0;
|
|
||||||
|
|
||||||
const topWindow =
|
|
||||||
store.visibleWindow.top - store.headerHeight - INTERSECTION_EXPAND_TOP + scrollCompensationHeightDelta;
|
|
||||||
const bottomWindow =
|
|
||||||
store.visibleWindow.bottom + store.headerHeight + INTERSECTION_EXPAND_BOTTOM + scrollCompensationHeightDelta;
|
|
||||||
const positionTop = this.#group.absoluteDateGroupTop + this.position.top;
|
|
||||||
const positionBottom = positionTop + this.position.height;
|
|
||||||
|
|
||||||
const intersecting =
|
|
||||||
(positionTop >= topWindow && positionTop < bottomWindow) ||
|
|
||||||
(positionBottom >= topWindow && positionBottom < bottomWindow) ||
|
|
||||||
(positionTop < topWindow && positionBottom >= bottomWindow);
|
|
||||||
return intersecting;
|
|
||||||
});
|
|
||||||
|
|
||||||
position: CommonPosition | undefined = $state();
|
|
||||||
asset: TimelineAsset = <TimelineAsset>$state();
|
|
||||||
id: string | undefined = $derived(this.asset?.id);
|
|
||||||
|
|
||||||
constructor(group: AssetDateGroup, asset: TimelineAsset) {
|
|
||||||
this.#group = group;
|
|
||||||
this.asset = asset;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,8 +1,10 @@
|
|||||||
|
import { AssetOrder, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||||
|
|
||||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import {
|
import {
|
||||||
formatBucketTitle,
|
|
||||||
formatGroupTitle,
|
formatGroupTitle,
|
||||||
|
formatMonthGroupTitle,
|
||||||
fromTimelinePlainDate,
|
fromTimelinePlainDate,
|
||||||
fromTimelinePlainDateTime,
|
fromTimelinePlainDateTime,
|
||||||
fromTimelinePlainYearMonth,
|
fromTimelinePlainYearMonth,
|
||||||
@ -10,59 +12,60 @@ import {
|
|||||||
type TimelinePlainDateTime,
|
type TimelinePlainDateTime,
|
||||||
type TimelinePlainYearMonth,
|
type TimelinePlainYearMonth,
|
||||||
} from '$lib/utils/timeline-util';
|
} from '$lib/utils/timeline-util';
|
||||||
import { AssetOrder, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { AddContext } from './add-context.svelte';
|
|
||||||
import { AssetDateGroup } from './asset-date-group.svelte';
|
|
||||||
import type { AssetStore } from './asset-store.svelte';
|
|
||||||
import { IntersectingAsset } from './intersecting-asset.svelte';
|
|
||||||
import type { AssetDescriptor, AssetOperation, Direction, MoveAsset, TimelineAsset } from './types';
|
|
||||||
|
|
||||||
export class AssetBucket {
|
import { DayGroup } from './day-group.svelte';
|
||||||
|
import { GroupInsertionCache } from './group-insertion-cache.svelte';
|
||||||
|
import type { TimelineManager } from './timeline-manager.svelte';
|
||||||
|
import type { AssetDescriptor, AssetOperation, Direction, MoveAsset, TimelineAsset } from './types';
|
||||||
|
import { ViewerAsset } from './viewer-asset.svelte';
|
||||||
|
|
||||||
|
export class MonthGroup {
|
||||||
#intersecting: boolean = $state(false);
|
#intersecting: boolean = $state(false);
|
||||||
actuallyIntersecting: boolean = $state(false);
|
actuallyIntersecting: boolean = $state(false);
|
||||||
isLoaded: boolean = $state(false);
|
isLoaded: boolean = $state(false);
|
||||||
dateGroups: AssetDateGroup[] = $state([]);
|
dayGroups: DayGroup[] = $state([]);
|
||||||
readonly store: AssetStore;
|
readonly timelineManager: TimelineManager;
|
||||||
|
|
||||||
#bucketHeight: number = $state(0);
|
#height: number = $state(0);
|
||||||
#top: number = $state(0);
|
#top: number = $state(0);
|
||||||
|
|
||||||
#initialCount: number = 0;
|
#initialCount: number = 0;
|
||||||
#sortOrder: AssetOrder = AssetOrder.Desc;
|
#sortOrder: AssetOrder = AssetOrder.Desc;
|
||||||
percent: number = $state(0);
|
percent: number = $state(0);
|
||||||
|
|
||||||
bucketCount: number = $derived(
|
assetsCount: number = $derived(
|
||||||
this.isLoaded
|
this.isLoaded
|
||||||
? this.dateGroups.reduce((accumulator, g) => accumulator + g.intersectingAssets.length, 0)
|
? this.dayGroups.reduce((accumulator, g) => accumulator + g.viewerAssets.length, 0)
|
||||||
: this.#initialCount,
|
: this.#initialCount,
|
||||||
);
|
);
|
||||||
loader: CancellableTask | undefined;
|
loader: CancellableTask | undefined;
|
||||||
isBucketHeightActual: boolean = $state(false);
|
isHeightActual: boolean = $state(false);
|
||||||
|
|
||||||
readonly bucketDateFormatted: string;
|
readonly monthGroupTitle: string;
|
||||||
readonly yearMonth: TimelinePlainYearMonth;
|
readonly yearMonth: TimelinePlainYearMonth;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
store: AssetStore,
|
store: TimelineManager,
|
||||||
yearMonth: TimelinePlainYearMonth,
|
yearMonth: TimelinePlainYearMonth,
|
||||||
initialCount: number,
|
initialCount: number,
|
||||||
order: AssetOrder = AssetOrder.Desc,
|
order: AssetOrder = AssetOrder.Desc,
|
||||||
) {
|
) {
|
||||||
this.store = store;
|
this.timelineManager = store;
|
||||||
this.#initialCount = initialCount;
|
this.#initialCount = initialCount;
|
||||||
this.#sortOrder = order;
|
this.#sortOrder = order;
|
||||||
|
|
||||||
this.yearMonth = yearMonth;
|
this.yearMonth = yearMonth;
|
||||||
this.bucketDateFormatted = formatBucketTitle(fromTimelinePlainYearMonth(yearMonth));
|
this.monthGroupTitle = formatMonthGroupTitle(fromTimelinePlainYearMonth(yearMonth));
|
||||||
|
|
||||||
this.loader = new CancellableTask(
|
this.loader = new CancellableTask(
|
||||||
() => {
|
() => {
|
||||||
this.isLoaded = true;
|
this.isLoaded = true;
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
this.dateGroups = [];
|
this.dayGroups = [];
|
||||||
this.isLoaded = false;
|
this.isLoaded = false;
|
||||||
},
|
},
|
||||||
this.#handleLoadError,
|
this.#handleLoadError,
|
||||||
@ -76,7 +79,7 @@ export class AssetBucket {
|
|||||||
}
|
}
|
||||||
this.#intersecting = newValue;
|
this.#intersecting = newValue;
|
||||||
if (newValue) {
|
if (newValue) {
|
||||||
void this.store.loadBucket(this.yearMonth);
|
void this.timelineManager.loadMonthGroup(this.yearMonth);
|
||||||
} else {
|
} else {
|
||||||
this.cancel();
|
this.cancel();
|
||||||
}
|
}
|
||||||
@ -86,28 +89,25 @@ export class AssetBucket {
|
|||||||
return this.#intersecting;
|
return this.#intersecting;
|
||||||
}
|
}
|
||||||
|
|
||||||
get lastDateGroup() {
|
get lastDayGroup() {
|
||||||
return this.dateGroups.at(-1);
|
return this.dayGroups.at(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
getFirstAsset() {
|
getFirstAsset() {
|
||||||
return this.dateGroups[0]?.getFirstAsset();
|
return this.dayGroups[0]?.getFirstAsset();
|
||||||
}
|
}
|
||||||
|
|
||||||
getAssets() {
|
getAssets() {
|
||||||
// eslint-disable-next-line unicorn/no-array-reduce
|
// eslint-disable-next-line unicorn/no-array-reduce
|
||||||
return this.dateGroups.reduce(
|
return this.dayGroups.reduce((accumulator: TimelineAsset[], g: DayGroup) => accumulator.concat(g.getAssets()), []);
|
||||||
(accumulator: TimelineAsset[], g: AssetDateGroup) => accumulator.concat(g.getAssets()),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sortDateGroups() {
|
sortDayGroups() {
|
||||||
if (this.#sortOrder === AssetOrder.Asc) {
|
if (this.#sortOrder === AssetOrder.Asc) {
|
||||||
return this.dateGroups.sort((a, b) => a.day - b.day);
|
return this.dayGroups.sort((a, b) => a.day - b.day);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.dateGroups.sort((a, b) => b.day - a.day);
|
return this.dayGroups.sort((a, b) => b.day - a.day);
|
||||||
}
|
}
|
||||||
|
|
||||||
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
|
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
|
||||||
@ -119,15 +119,15 @@ export class AssetBucket {
|
|||||||
changedGeometry: false,
|
changedGeometry: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const { dateGroups } = this;
|
const { dayGroups } = this;
|
||||||
let combinedChangedGeometry = false;
|
let combinedChangedGeometry = false;
|
||||||
let idsToProcess = new Set(ids);
|
let idsToProcess = new Set(ids);
|
||||||
const idsProcessed = new Set<string>();
|
const idsProcessed = new Set<string>();
|
||||||
const combinedMoveAssets: MoveAsset[][] = [];
|
const combinedMoveAssets: MoveAsset[][] = [];
|
||||||
let index = dateGroups.length;
|
let index = dayGroups.length;
|
||||||
while (index--) {
|
while (index--) {
|
||||||
if (idsToProcess.size > 0) {
|
if (idsToProcess.size > 0) {
|
||||||
const group = dateGroups[index];
|
const group = dayGroups[index];
|
||||||
const { moveAssets, processedIds, changedGeometry } = group.runAssetOperation(ids, operation);
|
const { moveAssets, processedIds, changedGeometry } = group.runAssetOperation(ids, operation);
|
||||||
if (moveAssets.length > 0) {
|
if (moveAssets.length > 0) {
|
||||||
combinedMoveAssets.push(moveAssets);
|
combinedMoveAssets.push(moveAssets);
|
||||||
@ -137,8 +137,8 @@ export class AssetBucket {
|
|||||||
idsProcessed.add(id);
|
idsProcessed.add(id);
|
||||||
}
|
}
|
||||||
combinedChangedGeometry = combinedChangedGeometry || changedGeometry;
|
combinedChangedGeometry = combinedChangedGeometry || changedGeometry;
|
||||||
if (group.intersectingAssets.length === 0) {
|
if (group.viewerAssets.length === 0) {
|
||||||
dateGroups.splice(index, 1);
|
dayGroups.splice(index, 1);
|
||||||
combinedChangedGeometry = true;
|
combinedChangedGeometry = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -152,7 +152,7 @@ export class AssetBucket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addAssets(bucketAssets: TimeBucketAssetResponseDto) {
|
addAssets(bucketAssets: TimeBucketAssetResponseDto) {
|
||||||
const addContext = new AddContext();
|
const addContext = new GroupInsertionCache();
|
||||||
for (let i = 0; i < bucketAssets.id.length; i++) {
|
for (let i = 0; i < bucketAssets.id.length; i++) {
|
||||||
const { localDateTime, fileCreatedAt } = getTimes(
|
const { localDateTime, fileCreatedAt } = getTimes(
|
||||||
bucketAssets.fileCreatedAt[i],
|
bucketAssets.fileCreatedAt[i],
|
||||||
@ -188,12 +188,12 @@ export class AssetBucket {
|
|||||||
this.addTimelineAsset(timelineAsset, addContext);
|
this.addTimelineAsset(timelineAsset, addContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const group of addContext.existingDateGroups) {
|
for (const group of addContext.existingDayGroups) {
|
||||||
group.sortAssets(this.#sortOrder);
|
group.sortAssets(this.#sortOrder);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (addContext.newDateGroups.size > 0) {
|
if (addContext.newDayGroups.size > 0) {
|
||||||
this.sortDateGroups();
|
this.sortDayGroups();
|
||||||
}
|
}
|
||||||
|
|
||||||
addContext.sort(this, this.#sortOrder);
|
addContext.sort(this, this.#sortOrder);
|
||||||
@ -201,7 +201,7 @@ export class AssetBucket {
|
|||||||
return addContext.unprocessedAssets;
|
return addContext.unprocessedAssets;
|
||||||
}
|
}
|
||||||
|
|
||||||
addTimelineAsset(timelineAsset: TimelineAsset, addContext: AddContext) {
|
addTimelineAsset(timelineAsset: TimelineAsset, addContext: GroupInsertionCache) {
|
||||||
const { localDateTime } = timelineAsset;
|
const { localDateTime } = timelineAsset;
|
||||||
|
|
||||||
const { year, month } = this.yearMonth;
|
const { year, month } = this.yearMonth;
|
||||||
@ -210,29 +210,29 @@ export class AssetBucket {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let dateGroup = addContext.getDateGroup(localDateTime) || this.findDateGroupByDay(localDateTime.day);
|
let dayGroup = addContext.getDayGroup(localDateTime) || this.findDayGroupByDay(localDateTime.day);
|
||||||
if (dateGroup) {
|
if (dayGroup) {
|
||||||
addContext.setDateGroup(dateGroup, localDateTime);
|
addContext.setDayGroup(dayGroup, localDateTime);
|
||||||
} else {
|
} else {
|
||||||
const groupTitle = formatGroupTitle(fromTimelinePlainDate(localDateTime));
|
const groupTitle = formatGroupTitle(fromTimelinePlainDate(localDateTime));
|
||||||
dateGroup = new AssetDateGroup(this, this.dateGroups.length, localDateTime.day, groupTitle);
|
dayGroup = new DayGroup(this, this.dayGroups.length, localDateTime.day, groupTitle);
|
||||||
this.dateGroups.push(dateGroup);
|
this.dayGroups.push(dayGroup);
|
||||||
addContext.setDateGroup(dateGroup, localDateTime);
|
addContext.setDayGroup(dayGroup, localDateTime);
|
||||||
addContext.newDateGroups.add(dateGroup);
|
addContext.newDayGroups.add(dayGroup);
|
||||||
}
|
}
|
||||||
|
|
||||||
const intersectingAsset = new IntersectingAsset(dateGroup, timelineAsset);
|
const viewerAsset = new ViewerAsset(dayGroup, timelineAsset);
|
||||||
dateGroup.intersectingAssets.push(intersectingAsset);
|
dayGroup.viewerAssets.push(viewerAsset);
|
||||||
addContext.changedDateGroups.add(dateGroup);
|
addContext.changedDayGroups.add(dayGroup);
|
||||||
}
|
}
|
||||||
|
|
||||||
getRandomDateGroup() {
|
getRandomDayGroup() {
|
||||||
const random = Math.floor(Math.random() * this.dateGroups.length);
|
const random = Math.floor(Math.random() * this.dayGroups.length);
|
||||||
return this.dateGroups[random];
|
return this.dayGroups[random];
|
||||||
}
|
}
|
||||||
|
|
||||||
getRandomAsset() {
|
getRandomAsset() {
|
||||||
return this.getRandomDateGroup()?.getRandomAsset()?.asset;
|
return this.getRandomDayGroup()?.getRandomAsset()?.asset;
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewId() {
|
get viewId() {
|
||||||
@ -240,55 +240,55 @@ export class AssetBucket {
|
|||||||
return year + '-' + month;
|
return year + '-' + month;
|
||||||
}
|
}
|
||||||
|
|
||||||
set bucketHeight(height: number) {
|
set height(height: number) {
|
||||||
if (this.#bucketHeight === height) {
|
if (this.#height === height) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { store, percent } = this;
|
const { timelineManager: store, percent } = this;
|
||||||
const index = store.buckets.indexOf(this);
|
const index = store.months.indexOf(this);
|
||||||
const bucketHeightDelta = height - this.#bucketHeight;
|
const heightDelta = height - this.#height;
|
||||||
this.#bucketHeight = height;
|
this.#height = height;
|
||||||
const prevBucket = store.buckets[index - 1];
|
const prevMonthGroup = store.months[index - 1];
|
||||||
if (prevBucket) {
|
if (prevMonthGroup) {
|
||||||
const newTop = prevBucket.#top + prevBucket.#bucketHeight;
|
const newTop = prevMonthGroup.#top + prevMonthGroup.#height;
|
||||||
if (this.#top !== newTop) {
|
if (this.#top !== newTop) {
|
||||||
this.#top = newTop;
|
this.#top = newTop;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (let cursor = index + 1; cursor < store.buckets.length; cursor++) {
|
for (let cursor = index + 1; cursor < store.months.length; cursor++) {
|
||||||
const bucket = this.store.buckets[cursor];
|
const monthGroup = this.timelineManager.months[cursor];
|
||||||
const newTop = bucket.#top + bucketHeightDelta;
|
const newTop = monthGroup.#top + heightDelta;
|
||||||
if (bucket.#top !== newTop) {
|
if (monthGroup.#top !== newTop) {
|
||||||
bucket.#top = newTop;
|
monthGroup.#top = newTop;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (store.topIntersectingBucket) {
|
if (store.topIntersectingMonthGroup) {
|
||||||
const currentIndex = store.buckets.indexOf(store.topIntersectingBucket);
|
const currentIndex = store.months.indexOf(store.topIntersectingMonthGroup);
|
||||||
if (currentIndex > 0) {
|
if (currentIndex > 0) {
|
||||||
if (index < currentIndex) {
|
if (index < currentIndex) {
|
||||||
store.scrollCompensation = {
|
store.scrollCompensation = {
|
||||||
heightDelta: bucketHeightDelta,
|
heightDelta,
|
||||||
scrollTop: undefined,
|
scrollTop: undefined,
|
||||||
bucket: this,
|
monthGroup: this,
|
||||||
};
|
};
|
||||||
} else if (percent > 0) {
|
} else if (percent > 0) {
|
||||||
const top = this.top + height * percent;
|
const top = this.top + height * percent;
|
||||||
store.scrollCompensation = {
|
store.scrollCompensation = {
|
||||||
heightDelta: undefined,
|
heightDelta: undefined,
|
||||||
scrollTop: top,
|
scrollTop: top,
|
||||||
bucket: this,
|
monthGroup: this,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get bucketHeight() {
|
get height() {
|
||||||
return this.#bucketHeight;
|
return this.#height;
|
||||||
}
|
}
|
||||||
|
|
||||||
get top(): number {
|
get top(): number {
|
||||||
return this.#top + this.store.topSectionHeight;
|
return this.#top + this.timelineManager.topSectionHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
#handleLoadError(error: unknown) {
|
#handleLoadError(error: unknown) {
|
||||||
@ -296,45 +296,45 @@ export class AssetBucket {
|
|||||||
handleError(error, _$t('errors.failed_to_load_assets'));
|
handleError(error, _$t('errors.failed_to_load_assets'));
|
||||||
}
|
}
|
||||||
|
|
||||||
findDateGroupForAsset(asset: TimelineAsset) {
|
findDayGroupForAsset(asset: TimelineAsset) {
|
||||||
for (const group of this.dateGroups) {
|
for (const group of this.dayGroups) {
|
||||||
if (group.intersectingAssets.some((IntersectingAsset) => IntersectingAsset.id === asset.id)) {
|
if (group.viewerAssets.some((viewerAsset) => viewerAsset.id === asset.id)) {
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
findDateGroupByDay(day: number) {
|
findDayGroupByDay(day: number) {
|
||||||
return this.dateGroups.find((group) => group.day === day);
|
return this.dayGroups.find((group) => group.day === day);
|
||||||
}
|
}
|
||||||
|
|
||||||
findAssetAbsolutePosition(assetId: string) {
|
findAssetAbsolutePosition(assetId: string) {
|
||||||
this.store.clearDeferredLayout(this);
|
this.timelineManager.clearDeferredLayout(this);
|
||||||
for (const group of this.dateGroups) {
|
for (const group of this.dayGroups) {
|
||||||
const intersectingAsset = group.intersectingAssets.find((asset) => asset.id === assetId);
|
const viewerAsset = group.viewerAssets.find((viewAsset) => viewAsset.id === assetId);
|
||||||
if (intersectingAsset) {
|
if (viewerAsset) {
|
||||||
if (!intersectingAsset.position) {
|
if (!viewerAsset.position) {
|
||||||
console.warn('No position for asset');
|
console.warn('No position for asset');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
return this.top + group.top + intersectingAsset.position.top + this.store.headerHeight;
|
return this.top + group.top + viewerAsset.position.top + this.timelineManager.headerHeight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
*assetsIterator(options?: { startDateGroup?: AssetDateGroup; startAsset?: TimelineAsset; direction?: Direction }) {
|
*assetsIterator(options?: { startDayGroup?: DayGroup; startAsset?: TimelineAsset; direction?: Direction }) {
|
||||||
const direction = options?.direction ?? 'earlier';
|
const direction = options?.direction ?? 'earlier';
|
||||||
let { startAsset } = options ?? {};
|
let { startAsset } = options ?? {};
|
||||||
const isEarlier = direction === 'earlier';
|
const isEarlier = direction === 'earlier';
|
||||||
let groupIndex = options?.startDateGroup
|
let groupIndex = options?.startDayGroup
|
||||||
? this.dateGroups.indexOf(options.startDateGroup)
|
? this.dayGroups.indexOf(options.startDayGroup)
|
||||||
: isEarlier
|
: isEarlier
|
||||||
? 0
|
? 0
|
||||||
: this.dateGroups.length - 1;
|
: this.dayGroups.length - 1;
|
||||||
|
|
||||||
while (groupIndex >= 0 && groupIndex < this.dateGroups.length) {
|
while (groupIndex >= 0 && groupIndex < this.dayGroups.length) {
|
||||||
const group = this.dateGroups[groupIndex];
|
const group = this.dayGroups[groupIndex];
|
||||||
yield* group.assetsIterator({ startAsset, direction });
|
yield* group.assetsIterator({ startAsset, direction });
|
||||||
startAsset = undefined;
|
startAsset = undefined;
|
||||||
groupIndex += isEarlier ? 1 : -1;
|
groupIndex += isEarlier ? 1 : -1;
|
@ -0,0 +1,583 @@
|
|||||||
|
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
||||||
|
import { getMonthGroupByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
||||||
|
import { AbortError } from '$lib/utils';
|
||||||
|
import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util';
|
||||||
|
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||||
|
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
|
||||||
|
import { TimelineManager } from './timeline-manager.svelte';
|
||||||
|
import type { TimelineAsset } from './types';
|
||||||
|
|
||||||
|
async function getAssets(timelineManager: TimelineManager) {
|
||||||
|
const assets = [];
|
||||||
|
for await (const asset of timelineManager.assetsIterator()) {
|
||||||
|
assets.push(asset);
|
||||||
|
}
|
||||||
|
return assets;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveLocalDateTimeFromFileCreatedAt(arg: TimelineAsset): TimelineAsset {
|
||||||
|
return {
|
||||||
|
...arg,
|
||||||
|
localDateTime: arg.fileCreatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TimelineManager', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('init', () => {
|
||||||
|
let timelineManager: TimelineManager;
|
||||||
|
const bucketAssets: Record<string, TimelineAsset[]> = {
|
||||||
|
'2024-03-01T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
|
||||||
|
deriveLocalDateTimeFromFileCreatedAt({
|
||||||
|
...asset,
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-01T00:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
'2024-02-01T00:00:00.000Z': timelineAssetFactory.buildList(100).map((asset) =>
|
||||||
|
deriveLocalDateTimeFromFileCreatedAt({
|
||||||
|
...asset,
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-01T00:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
'2024-01-01T00:00:00.000Z': timelineAssetFactory.buildList(3).map((asset) =>
|
||||||
|
deriveLocalDateTimeFromFileCreatedAt({
|
||||||
|
...asset,
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-01T00:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
|
||||||
|
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
timelineManager = new TimelineManager();
|
||||||
|
sdkMock.getTimeBuckets.mockResolvedValue([
|
||||||
|
{ count: 1, timeBucket: '2024-03-01' },
|
||||||
|
{ count: 100, timeBucket: '2024-02-01' },
|
||||||
|
{ count: 3, timeBucket: '2024-01-01' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
|
||||||
|
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should load months in viewport', () => {
|
||||||
|
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
|
||||||
|
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates month height', () => {
|
||||||
|
const plainMonths = timelineManager.months.map((month) => ({
|
||||||
|
year: month.yearMonth.year,
|
||||||
|
month: month.yearMonth.month,
|
||||||
|
height: month.height,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(plainMonths).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ year: 2024, month: 3, height: 185.5 }),
|
||||||
|
expect.objectContaining({ year: 2024, month: 2, height: 12_016 }),
|
||||||
|
expect.objectContaining({ year: 2024, month: 1, height: 286 }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates timeline height', () => {
|
||||||
|
expect(timelineManager.timelineHeight).toBe(12_487.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadMonthGroup', () => {
|
||||||
|
let timelineManager: TimelineManager;
|
||||||
|
const bucketAssets: Record<string, TimelineAsset[]> = {
|
||||||
|
'2024-01-03T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
|
||||||
|
deriveLocalDateTimeFromFileCreatedAt({
|
||||||
|
...asset,
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-01T00:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
'2024-01-01T00:00:00.000Z': timelineAssetFactory.buildList(3).map((asset) =>
|
||||||
|
deriveLocalDateTimeFromFileCreatedAt({
|
||||||
|
...asset,
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-01T00:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
|
||||||
|
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
|
||||||
|
);
|
||||||
|
beforeEach(async () => {
|
||||||
|
timelineManager = new TimelineManager();
|
||||||
|
sdkMock.getTimeBuckets.mockResolvedValue([
|
||||||
|
{ count: 1, timeBucket: '2024-03-01T00:00:00.000Z' },
|
||||||
|
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
|
||||||
|
]);
|
||||||
|
sdkMock.getTimeBucket.mockImplementation(async ({ timeBucket }, { signal } = {}) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new AbortError();
|
||||||
|
}
|
||||||
|
return bucketAssetsResponse[timeBucket];
|
||||||
|
});
|
||||||
|
await timelineManager.updateViewport({ width: 1588, height: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads a month', async () => {
|
||||||
|
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
|
||||||
|
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||||
|
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||||
|
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores invalid months', async () => {
|
||||||
|
await timelineManager.loadMonthGroup({ year: 2023, month: 1 });
|
||||||
|
expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels month loading', async () => {
|
||||||
|
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })!;
|
||||||
|
void timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||||
|
const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort');
|
||||||
|
month?.cancel();
|
||||||
|
expect(abortSpy).toBeCalledTimes(1);
|
||||||
|
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||||
|
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents loading months multiple times', async () => {
|
||||||
|
await Promise.all([
|
||||||
|
timelineManager.loadMonthGroup({ year: 2024, month: 1 }),
|
||||||
|
timelineManager.loadMonthGroup({ year: 2024, month: 1 }),
|
||||||
|
]);
|
||||||
|
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||||
|
|
||||||
|
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||||
|
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows loading a canceled month', async () => {
|
||||||
|
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })!;
|
||||||
|
const loadPromise = timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||||
|
|
||||||
|
month.cancel();
|
||||||
|
await loadPromise;
|
||||||
|
expect(month?.getAssets().length).toEqual(0);
|
||||||
|
|
||||||
|
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||||
|
expect(month!.getAssets().length).toEqual(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addAssets', () => {
|
||||||
|
let timelineManager: TimelineManager;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
timelineManager = new TimelineManager();
|
||||||
|
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is empty initially', () => {
|
||||||
|
expect(timelineManager.months.length).toEqual(0);
|
||||||
|
expect(timelineManager.assetCount).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds assets to new month', () => {
|
||||||
|
const asset = deriveLocalDateTimeFromFileCreatedAt(
|
||||||
|
timelineAssetFactory.build({
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
timelineManager.addAssets([asset]);
|
||||||
|
|
||||||
|
expect(timelineManager.months.length).toEqual(1);
|
||||||
|
expect(timelineManager.assetCount).toEqual(1);
|
||||||
|
expect(timelineManager.months[0].getAssets().length).toEqual(1);
|
||||||
|
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
|
||||||
|
expect(timelineManager.months[0].yearMonth.month).toEqual(1);
|
||||||
|
expect(timelineManager.months[0].getFirstAsset().id).toEqual(asset.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds assets to existing month', () => {
|
||||||
|
const [assetOne, assetTwo] = timelineAssetFactory
|
||||||
|
.buildList(2, {
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||||
|
})
|
||||||
|
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
||||||
|
timelineManager.addAssets([assetOne]);
|
||||||
|
timelineManager.addAssets([assetTwo]);
|
||||||
|
|
||||||
|
expect(timelineManager.months.length).toEqual(1);
|
||||||
|
expect(timelineManager.assetCount).toEqual(2);
|
||||||
|
expect(timelineManager.months[0].getAssets().length).toEqual(2);
|
||||||
|
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
|
||||||
|
expect(timelineManager.months[0].yearMonth.month).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('orders assets in months by descending date', () => {
|
||||||
|
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
||||||
|
timelineAssetFactory.build({
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
||||||
|
timelineAssetFactory.build({
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const assetThree = deriveLocalDateTimeFromFileCreatedAt(
|
||||||
|
timelineAssetFactory.build({
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-16T12:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
timelineManager.addAssets([assetOne, assetTwo, assetThree]);
|
||||||
|
|
||||||
|
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 });
|
||||||
|
expect(month).not.toBeNull();
|
||||||
|
expect(month?.getAssets().length).toEqual(3);
|
||||||
|
expect(month?.getAssets()[0].id).toEqual(assetOne.id);
|
||||||
|
expect(month?.getAssets()[1].id).toEqual(assetThree.id);
|
||||||
|
expect(month?.getAssets()[2].id).toEqual(assetTwo.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('orders months by descending date', () => {
|
||||||
|
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
||||||
|
timelineAssetFactory.build({
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
||||||
|
timelineAssetFactory.build({
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-04-20T12:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const assetThree = deriveLocalDateTimeFromFileCreatedAt(
|
||||||
|
timelineAssetFactory.build({
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2023-01-20T12:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
timelineManager.addAssets([assetOne, assetTwo, assetThree]);
|
||||||
|
|
||||||
|
expect(timelineManager.months.length).toEqual(3);
|
||||||
|
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
|
||||||
|
expect(timelineManager.months[0].yearMonth.month).toEqual(4);
|
||||||
|
|
||||||
|
expect(timelineManager.months[1].yearMonth.year).toEqual(2024);
|
||||||
|
expect(timelineManager.months[1].yearMonth.month).toEqual(1);
|
||||||
|
|
||||||
|
expect(timelineManager.months[2].yearMonth.year).toEqual(2023);
|
||||||
|
expect(timelineManager.months[2].yearMonth.month).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates existing asset', () => {
|
||||||
|
const updateAssetsSpy = vi.spyOn(timelineManager, 'updateAssets');
|
||||||
|
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build());
|
||||||
|
timelineManager.addAssets([asset]);
|
||||||
|
|
||||||
|
timelineManager.addAssets([asset]);
|
||||||
|
expect(updateAssetsSpy).toBeCalledWith([asset]);
|
||||||
|
expect(timelineManager.assetCount).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// disabled due to the wasm Justified Layout import
|
||||||
|
it('ignores trashed assets when isTrashed is true', async () => {
|
||||||
|
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isTrashed: false }));
|
||||||
|
const trashedAsset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isTrashed: true }));
|
||||||
|
|
||||||
|
const timelineManager = new TimelineManager();
|
||||||
|
await timelineManager.updateOptions({ isTrashed: true });
|
||||||
|
timelineManager.addAssets([asset, trashedAsset]);
|
||||||
|
expect(await getAssets(timelineManager)).toEqual([trashedAsset]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateAssets', () => {
|
||||||
|
let timelineManager: TimelineManager;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
timelineManager = new TimelineManager();
|
||||||
|
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
expect(timelineManager.assetCount).toEqual(1);
|
||||||
|
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(false);
|
||||||
|
|
||||||
|
timelineManager.updateAssets([updatedAsset]);
|
||||||
|
expect(timelineManager.assetCount).toEqual(1);
|
||||||
|
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asset moves months when asset date changes', () => {
|
||||||
|
const asset = deriveLocalDateTimeFromFileCreatedAt(
|
||||||
|
timelineAssetFactory.build({
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const updatedAsset = deriveLocalDateTimeFromFileCreatedAt({
|
||||||
|
...asset,
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-20T12:00:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
timelineManager.addAssets([asset]);
|
||||||
|
expect(timelineManager.months.length).toEqual(1);
|
||||||
|
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
|
||||||
|
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(1);
|
||||||
|
|
||||||
|
timelineManager.updateAssets([updatedAsset]);
|
||||||
|
expect(timelineManager.months.length).toEqual(2);
|
||||||
|
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
|
||||||
|
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
|
||||||
|
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })).not.toBeUndefined();
|
||||||
|
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })?.getAssets().length).toEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeAssets', () => {
|
||||||
|
let timelineManager: TimelineManager;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
timelineManager = new TimelineManager();
|
||||||
|
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores invalid IDs', () => {
|
||||||
|
timelineManager.addAssets(
|
||||||
|
timelineAssetFactory
|
||||||
|
.buildList(2, {
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||||
|
})
|
||||||
|
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset)),
|
||||||
|
);
|
||||||
|
timelineManager.removeAssets(['', 'invalid', '4c7d9acc']);
|
||||||
|
|
||||||
|
expect(timelineManager.assetCount).toEqual(2);
|
||||||
|
expect(timelineManager.months.length).toEqual(1);
|
||||||
|
expect(timelineManager.months[0].getAssets().length).toEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes asset from month', () => {
|
||||||
|
const [assetOne, assetTwo] = timelineAssetFactory
|
||||||
|
.buildList(2, {
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||||
|
})
|
||||||
|
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
||||||
|
timelineManager.addAssets([assetOne, assetTwo]);
|
||||||
|
timelineManager.removeAssets([assetOne.id]);
|
||||||
|
|
||||||
|
expect(timelineManager.assetCount).toEqual(1);
|
||||||
|
expect(timelineManager.months.length).toEqual(1);
|
||||||
|
expect(timelineManager.months[0].getAssets().length).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not remove month when empty', () => {
|
||||||
|
const assets = timelineAssetFactory
|
||||||
|
.buildList(2, {
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||||
|
})
|
||||||
|
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
||||||
|
timelineManager.addAssets(assets);
|
||||||
|
timelineManager.removeAssets(assets.map((asset) => asset.id));
|
||||||
|
|
||||||
|
expect(timelineManager.assetCount).toEqual(0);
|
||||||
|
expect(timelineManager.months.length).toEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('firstAsset', () => {
|
||||||
|
let timelineManager: TimelineManager;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
timelineManager = new TimelineManager();
|
||||||
|
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||||
|
await timelineManager.updateViewport({ width: 0, height: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty store returns null', () => {
|
||||||
|
expect(timelineManager.getFirstAsset()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('populated store returns first asset', () => {
|
||||||
|
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
||||||
|
timelineAssetFactory.build({
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
||||||
|
timelineAssetFactory.build({
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
timelineManager.addAssets([assetOne, assetTwo]);
|
||||||
|
expect(timelineManager.getFirstAsset()).toEqual(assetOne);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLaterAsset', () => {
|
||||||
|
let timelineManager: TimelineManager;
|
||||||
|
const bucketAssets: Record<string, TimelineAsset[]> = {
|
||||||
|
'2024-03-01T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
|
||||||
|
deriveLocalDateTimeFromFileCreatedAt({
|
||||||
|
...asset,
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-01T00:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
'2024-02-01T00:00:00.000Z': timelineAssetFactory.buildList(6).map((asset) =>
|
||||||
|
deriveLocalDateTimeFromFileCreatedAt({
|
||||||
|
...asset,
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-01T00:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
'2024-01-01T00:00:00.000Z': timelineAssetFactory.buildList(3).map((asset) =>
|
||||||
|
deriveLocalDateTimeFromFileCreatedAt({
|
||||||
|
...asset,
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-01T00:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
|
||||||
|
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
timelineManager = new TimelineManager();
|
||||||
|
sdkMock.getTimeBuckets.mockResolvedValue([
|
||||||
|
{ count: 1, timeBucket: '2024-03-01T00:00:00.000Z' },
|
||||||
|
{ count: 6, timeBucket: '2024-02-01T00:00:00.000Z' },
|
||||||
|
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
|
||||||
|
]);
|
||||||
|
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
|
||||||
|
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for invalid assetId', async () => {
|
||||||
|
expect(() => timelineManager.getLaterAsset({ id: 'invalid' } as AssetResponseDto)).not.toThrow();
|
||||||
|
expect(await timelineManager.getLaterAsset({ id: 'invalid' } as AssetResponseDto)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns previous assetId', async () => {
|
||||||
|
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||||
|
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 });
|
||||||
|
|
||||||
|
const a = month!.getAssets()[0];
|
||||||
|
const b = month!.getAssets()[1];
|
||||||
|
const previous = await timelineManager.getLaterAsset(b);
|
||||||
|
expect(previous).toEqual(a);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns previous assetId spanning multiple months', async () => {
|
||||||
|
await timelineManager.loadMonthGroup({ year: 2024, month: 2 });
|
||||||
|
await timelineManager.loadMonthGroup({ year: 2024, month: 3 });
|
||||||
|
|
||||||
|
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 });
|
||||||
|
const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 });
|
||||||
|
const a = month!.getAssets()[0];
|
||||||
|
const b = previousMonth!.getAssets()[0];
|
||||||
|
const previous = await timelineManager.getLaterAsset(a);
|
||||||
|
expect(previous).toEqual(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads previous month', async () => {
|
||||||
|
await timelineManager.loadMonthGroup({ year: 2024, month: 2 });
|
||||||
|
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 });
|
||||||
|
const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 });
|
||||||
|
const a = month!.getFirstAsset();
|
||||||
|
const b = previousMonth!.getFirstAsset();
|
||||||
|
const loadMonthGroupSpy = vi.spyOn(month!.loader!, 'execute');
|
||||||
|
const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute');
|
||||||
|
const previous = await timelineManager.getLaterAsset(a);
|
||||||
|
expect(previous).toEqual(b);
|
||||||
|
expect(loadMonthGroupSpy).toBeCalledTimes(0);
|
||||||
|
expect(previousMonthSpy).toBeCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips removed assets', async () => {
|
||||||
|
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||||
|
await timelineManager.loadMonthGroup({ year: 2024, month: 2 });
|
||||||
|
await timelineManager.loadMonthGroup({ year: 2024, month: 3 });
|
||||||
|
|
||||||
|
const [assetOne, assetTwo, assetThree] = await getAssets(timelineManager);
|
||||||
|
timelineManager.removeAssets([assetTwo.id]);
|
||||||
|
expect(await timelineManager.getLaterAsset(assetThree)).toEqual(assetOne);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no more assets', async () => {
|
||||||
|
await timelineManager.loadMonthGroup({ year: 2024, month: 3 });
|
||||||
|
expect(await timelineManager.getLaterAsset(timelineManager.months[0].getFirstAsset())).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getMonthGroupIndexByAssetId', () => {
|
||||||
|
let timelineManager: TimelineManager;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
timelineManager = new TimelineManager();
|
||||||
|
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await timelineManager.updateViewport({ width: 0, height: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for invalid months', () => {
|
||||||
|
expect(getMonthGroupByDate(timelineManager, { year: -1, month: -1 })).toBeUndefined();
|
||||||
|
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the month index', () => {
|
||||||
|
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
||||||
|
timelineAssetFactory.build({
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
||||||
|
timelineAssetFactory.build({
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
timelineManager.addAssets([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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores removed months', () => {
|
||||||
|
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
||||||
|
timelineAssetFactory.build({
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
||||||
|
timelineAssetFactory.build({
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
timelineManager.addAssets([assetOne, assetTwo]);
|
||||||
|
|
||||||
|
timelineManager.removeAssets([assetTwo.id]);
|
||||||
|
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
|
||||||
|
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
539
web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts
Normal file
539
web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts
Normal file
@ -0,0 +1,539 @@
|
|||||||
|
import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk';
|
||||||
|
|
||||||
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
|
|
||||||
|
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||||
|
import { toTimelineAsset, type TimelinePlainDateTime, type TimelinePlainYearMonth } from '$lib/utils/timeline-util';
|
||||||
|
|
||||||
|
import { clamp, debounce, isEqual } from 'lodash-es';
|
||||||
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
||||||
|
import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
|
||||||
|
import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte';
|
||||||
|
import {
|
||||||
|
addAssetsToMonthGroups,
|
||||||
|
runAssetOperation,
|
||||||
|
} from '$lib/managers/timeline-manager/internal/operations-support.svelte';
|
||||||
|
import {
|
||||||
|
findMonthGroupForAsset as findMonthGroupForAssetUtil,
|
||||||
|
findMonthGroupForDate,
|
||||||
|
getAssetWithOffset,
|
||||||
|
getMonthGroupByDate,
|
||||||
|
retrieveRange as retrieveRangeUtil,
|
||||||
|
} 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 { MonthGroup } from './month-group.svelte';
|
||||||
|
import type {
|
||||||
|
AssetDescriptor,
|
||||||
|
AssetOperation,
|
||||||
|
Direction,
|
||||||
|
ScrubberMonth,
|
||||||
|
TimelineAsset,
|
||||||
|
TimelineManagerLayoutOptions,
|
||||||
|
TimelineManagerOptions,
|
||||||
|
Viewport,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export class TimelineManager {
|
||||||
|
isInitialized = $state(false);
|
||||||
|
months: MonthGroup[] = $state([]);
|
||||||
|
topSectionHeight = $state(0);
|
||||||
|
timelineHeight = $derived(this.months.reduce((accumulator, b) => accumulator + b.height, 0) + this.topSectionHeight);
|
||||||
|
assetCount = $derived(this.months.reduce((accumulator, b) => accumulator + b.assetsCount, 0));
|
||||||
|
|
||||||
|
albumAssets: Set<string> = new SvelteSet();
|
||||||
|
|
||||||
|
scrubberMonths: ScrubberMonth[] = $state([]);
|
||||||
|
scrubberTimelineHeight: number = $state(0);
|
||||||
|
|
||||||
|
topIntersectingMonthGroup: MonthGroup | undefined = $state();
|
||||||
|
|
||||||
|
visibleWindow = $derived.by(() => ({
|
||||||
|
top: this.#scrollTop,
|
||||||
|
bottom: this.#scrollTop + this.viewportHeight,
|
||||||
|
}));
|
||||||
|
|
||||||
|
initTask = new CancellableTask(
|
||||||
|
() => {
|
||||||
|
this.isInitialized = true;
|
||||||
|
if (this.#options.albumId || this.#options.personId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.connect();
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
this.disconnect();
|
||||||
|
this.isInitialized = false;
|
||||||
|
},
|
||||||
|
() => void 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
static #INIT_OPTIONS = {};
|
||||||
|
#viewportHeight = $state(0);
|
||||||
|
#viewportWidth = $state(0);
|
||||||
|
#scrollTop = $state(0);
|
||||||
|
#websocketSupport: WebsocketSupport | undefined;
|
||||||
|
|
||||||
|
#rowHeight = $state(235);
|
||||||
|
#headerHeight = $state(48);
|
||||||
|
#gap = $state(12);
|
||||||
|
|
||||||
|
#options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS;
|
||||||
|
|
||||||
|
#scrolling = $state(false);
|
||||||
|
#suspendTransitions = $state(false);
|
||||||
|
#resetScrolling = debounce(() => (this.#scrolling = false), 1000);
|
||||||
|
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
|
||||||
|
scrollCompensation: {
|
||||||
|
heightDelta: number | undefined;
|
||||||
|
scrollTop: number | undefined;
|
||||||
|
monthGroup: MonthGroup | undefined;
|
||||||
|
} = $state({
|
||||||
|
heightDelta: 0,
|
||||||
|
scrollTop: 0,
|
||||||
|
monthGroup: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#setHeaderHeight(value: number) {
|
||||||
|
if (this.#headerHeight == value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.#headerHeight = value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get headerHeight() {
|
||||||
|
return this.#headerHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
#setGap(value: number) {
|
||||||
|
if (this.#gap == value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.#gap = value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get gap() {
|
||||||
|
return this.#gap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#setRowHeight(value: number) {
|
||||||
|
if (this.#rowHeight == value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.#rowHeight = value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get rowHeight() {
|
||||||
|
return this.#rowHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
set scrolling(value: boolean) {
|
||||||
|
this.#scrolling = value;
|
||||||
|
if (value) {
|
||||||
|
this.suspendTransitions = true;
|
||||||
|
this.#resetScrolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get scrolling() {
|
||||||
|
return this.#scrolling;
|
||||||
|
}
|
||||||
|
|
||||||
|
set suspendTransitions(value: boolean) {
|
||||||
|
this.#suspendTransitions = value;
|
||||||
|
if (value) {
|
||||||
|
this.#resetSuspendTransitions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get suspendTransitions() {
|
||||||
|
return this.#suspendTransitions;
|
||||||
|
}
|
||||||
|
|
||||||
|
set viewportWidth(value: number) {
|
||||||
|
const changed = value !== this.#viewportWidth;
|
||||||
|
this.#viewportWidth = value;
|
||||||
|
this.suspendTransitions = true;
|
||||||
|
void this.#updateViewportGeometry(changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
get viewportWidth() {
|
||||||
|
return this.#viewportWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
set viewportHeight(value: number) {
|
||||||
|
this.#viewportHeight = value;
|
||||||
|
this.#suspendTransitions = true;
|
||||||
|
void this.#updateViewportGeometry(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
get viewportHeight() {
|
||||||
|
return this.#viewportHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async *assetsIterator(options?: {
|
||||||
|
startMonthGroup?: MonthGroup;
|
||||||
|
startDayGroup?: DayGroup;
|
||||||
|
startAsset?: TimelineAsset;
|
||||||
|
direction?: Direction;
|
||||||
|
}) {
|
||||||
|
const direction = options?.direction ?? 'earlier';
|
||||||
|
let { startDayGroup, startAsset } = options ?? {};
|
||||||
|
for (const monthGroup of this.monthGroupIterator({ direction, startMonthGroup: options?.startMonthGroup })) {
|
||||||
|
await this.loadMonthGroup(monthGroup.yearMonth, { cancelable: false });
|
||||||
|
yield* monthGroup.assetsIterator({ startDayGroup, startAsset, direction });
|
||||||
|
startDayGroup = startAsset = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*monthGroupIterator(options?: { direction?: Direction; startMonthGroup?: MonthGroup }) {
|
||||||
|
const isEarlier = options?.direction === 'earlier';
|
||||||
|
let startIndex = options?.startMonthGroup
|
||||||
|
? this.months.indexOf(options.startMonthGroup)
|
||||||
|
: isEarlier
|
||||||
|
? 0
|
||||||
|
: this.months.length - 1;
|
||||||
|
|
||||||
|
while (startIndex >= 0 && startIndex < this.months.length) {
|
||||||
|
yield this.months[startIndex];
|
||||||
|
startIndex += isEarlier ? 1 : -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
if (this.#websocketSupport) {
|
||||||
|
throw new Error('TimelineManager already connected');
|
||||||
|
}
|
||||||
|
this.#websocketSupport = new WebsocketSupport(this);
|
||||||
|
this.#websocketSupport.connectWebsocketEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
if (!this.#websocketSupport) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#websocketSupport.disconnectWebsocketEvents();
|
||||||
|
this.#websocketSupport = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSlidingWindow(scrollTop: number) {
|
||||||
|
if (this.#scrollTop !== scrollTop) {
|
||||||
|
this.#scrollTop = scrollTop;
|
||||||
|
this.updateIntersections();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearScrollCompensation() {
|
||||||
|
this.scrollCompensation = {
|
||||||
|
heightDelta: undefined,
|
||||||
|
scrollTop: undefined,
|
||||||
|
monthGroup: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
updateIntersections() {
|
||||||
|
if (!this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let topIntersectingMonthGroup = undefined;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearDeferredLayout(month: MonthGroup) {
|
||||||
|
const hasDeferred = month.dayGroups.some((group) => group.deferredLayout);
|
||||||
|
if (hasDeferred) {
|
||||||
|
updateGeometry(this, month, { invalidateHeight: true, noDefer: true });
|
||||||
|
for (const group of month.dayGroups) {
|
||||||
|
group.deferredLayout = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #initializeMonthGroups() {
|
||||||
|
const timebuckets = await getTimeBuckets({
|
||||||
|
...this.#options,
|
||||||
|
key: authManager.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.months = timebuckets.map((timeBucket) => {
|
||||||
|
const date = new Date(timeBucket.timeBucket);
|
||||||
|
return new MonthGroup(
|
||||||
|
this,
|
||||||
|
{ year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 },
|
||||||
|
timeBucket.count,
|
||||||
|
this.#options.order,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
this.albumAssets.clear();
|
||||||
|
this.#updateViewportGeometry(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOptions(options: TimelineManagerOptions) {
|
||||||
|
if (options.deferInit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.#options !== TimelineManager.#INIT_OPTIONS && isEqual(this.#options, options)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.initTask.reset();
|
||||||
|
await this.#init(options);
|
||||||
|
this.#updateViewportGeometry(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async #init(options: TimelineManagerOptions) {
|
||||||
|
this.isInitialized = false;
|
||||||
|
this.months = [];
|
||||||
|
this.albumAssets.clear();
|
||||||
|
await this.initTask.execute(async () => {
|
||||||
|
this.#options = options;
|
||||||
|
await this.#initializeMonthGroups();
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy() {
|
||||||
|
this.disconnect();
|
||||||
|
this.isInitialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateViewport(viewport: Viewport) {
|
||||||
|
if (viewport.height === 0 && viewport.width === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.viewportHeight === viewport.height && this.viewportWidth === viewport.width) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.initTask.executed) {
|
||||||
|
await (this.initTask.loading ? this.initTask.waitUntilCompletion() : this.#init(this.#options));
|
||||||
|
}
|
||||||
|
|
||||||
|
const changedWidth = viewport.width !== this.viewportWidth;
|
||||||
|
this.viewportHeight = viewport.height;
|
||||||
|
this.viewportWidth = viewport.width;
|
||||||
|
this.#updateViewportGeometry(changedWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
#updateViewportGeometry(changedWidth: boolean) {
|
||||||
|
if (!this.isInitialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.viewportWidth === 0 || this.viewportHeight === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const month of this.months) {
|
||||||
|
updateGeometry(this, month, { invalidateHeight: changedWidth });
|
||||||
|
}
|
||||||
|
this.updateIntersections();
|
||||||
|
this.#createScrubberMonths();
|
||||||
|
}
|
||||||
|
|
||||||
|
#createScrubberMonths() {
|
||||||
|
this.scrubberMonths = this.months.map((month) => ({
|
||||||
|
assetCount: month.assetsCount,
|
||||||
|
year: month.yearMonth.year,
|
||||||
|
month: month.yearMonth.month,
|
||||||
|
title: month.monthGroupTitle,
|
||||||
|
height: month.height,
|
||||||
|
}));
|
||||||
|
this.scrubberTimelineHeight = this.timelineHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
createLayoutOptions() {
|
||||||
|
const viewportWidth = this.viewportWidth;
|
||||||
|
|
||||||
|
return {
|
||||||
|
spacing: 2,
|
||||||
|
heightTolerance: 0.15,
|
||||||
|
rowHeight: this.#rowHeight,
|
||||||
|
rowWidth: Math.floor(viewportWidth),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadMonthGroup(yearMonth: TimelinePlainYearMonth, options?: { cancelable: boolean }): Promise<void> {
|
||||||
|
let cancelable = true;
|
||||||
|
if (options) {
|
||||||
|
cancelable = options.cancelable;
|
||||||
|
}
|
||||||
|
const monthGroup = getMonthGroupByDate(this, yearMonth);
|
||||||
|
if (!monthGroup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (monthGroup.loader?.executed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await monthGroup.loader?.execute(async (signal: AbortSignal) => {
|
||||||
|
await loadFromTimeBuckets(this, monthGroup, this.#options, signal);
|
||||||
|
}, cancelable);
|
||||||
|
if (result === 'LOADED') {
|
||||||
|
updateIntersectionMonthGroup(this, monthGroup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if (!this.isInitialized) {
|
||||||
|
await this.initTask.waitUntilCompletion();
|
||||||
|
}
|
||||||
|
let { monthGroup } = findMonthGroupForAssetUtil(this, id) ?? {};
|
||||||
|
if (monthGroup) {
|
||||||
|
return monthGroup;
|
||||||
|
}
|
||||||
|
const asset = toTimelineAsset(await getAssetInfo({ id, key: authManager.key }));
|
||||||
|
if (!asset || this.isExcluded(asset)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
monthGroup = await this.#loadMonthGroupAtTime(asset.localDateTime, { cancelable: false });
|
||||||
|
if (monthGroup?.findAssetById({ id })) {
|
||||||
|
return monthGroup;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #loadMonthGroupAtTime(yearMonth: TimelinePlainYearMonth, options?: { cancelable: boolean }) {
|
||||||
|
await this.loadMonthGroup(yearMonth, options);
|
||||||
|
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];
|
||||||
|
await this.loadMonthGroup(month.yearMonth, { cancelable: false });
|
||||||
|
return month;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRandomAsset() {
|
||||||
|
const month = await this.getRandomMonthGroup();
|
||||||
|
return month?.getRandomAsset();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAssetOperation(ids: string[], operation: AssetOperation) {
|
||||||
|
runAssetOperation(this, new Set(ids), operation, { order: this.#options.order ?? AssetOrder.Desc });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAssets(assets: TimelineAsset[]) {
|
||||||
|
const lookup = new Map<string, TimelineAsset>(assets.map((asset) => [asset.id, asset]));
|
||||||
|
const { unprocessedIds } = runAssetOperation(
|
||||||
|
this,
|
||||||
|
new Set(lookup.keys()),
|
||||||
|
(asset) => {
|
||||||
|
updateObject(asset, lookup.get(asset.id));
|
||||||
|
return { remove: false };
|
||||||
|
},
|
||||||
|
{ order: this.#options.order ?? AssetOrder.Desc },
|
||||||
|
);
|
||||||
|
return unprocessedIds.values().map((id) => lookup.get(id)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAssets(ids: string[]) {
|
||||||
|
const { unprocessedIds } = runAssetOperation(
|
||||||
|
this,
|
||||||
|
new Set(ids),
|
||||||
|
() => {
|
||||||
|
return { remove: true };
|
||||||
|
},
|
||||||
|
{ order: this.#options.order ?? AssetOrder.Desc },
|
||||||
|
);
|
||||||
|
return [...unprocessedIds];
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshLayout() {
|
||||||
|
for (const month of this.months) {
|
||||||
|
updateGeometry(this, month, { invalidateHeight: true });
|
||||||
|
}
|
||||||
|
this.updateIntersections();
|
||||||
|
}
|
||||||
|
|
||||||
|
getFirstAsset(): TimelineAsset | undefined {
|
||||||
|
return this.months[0]?.getFirstAsset();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLaterAsset(
|
||||||
|
assetDescriptor: AssetDescriptor,
|
||||||
|
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||||
|
): Promise<TimelineAsset | undefined> {
|
||||||
|
return await getAssetWithOffset(this, assetDescriptor, interval, 'later');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEarlierAsset(
|
||||||
|
assetDescriptor: AssetDescriptor,
|
||||||
|
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||||
|
): Promise<TimelineAsset | undefined> {
|
||||||
|
return await getAssetWithOffset(this, assetDescriptor, interval, 'earlier');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getClosestAssetToDate(dateTime: TimelinePlainDateTime) {
|
||||||
|
const monthGroup = findMonthGroupForDate(this, dateTime);
|
||||||
|
if (!monthGroup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.loadMonthGroup(dateTime, { cancelable: false });
|
||||||
|
const asset = monthGroup.findClosest(dateTime);
|
||||||
|
if (asset) {
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
for await (const asset of this.assetsIterator({ startMonthGroup: monthGroup })) {
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async retrieveRange(start: AssetDescriptor, end: AssetDescriptor) {
|
||||||
|
return retrieveRangeUtil(this, start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
isExcluded(asset: TimelineAsset) {
|
||||||
|
return (
|
||||||
|
isMismatched(this.#options.visibility, asset.visibility) ||
|
||||||
|
isMismatched(this.#options.isFavorite, asset.isFavorite) ||
|
||||||
|
isMismatched(this.#options.isTrashed, asset.isTrashed)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -3,7 +3,7 @@ import type { AssetStackResponseDto, AssetVisibility } from '@immich/sdk';
|
|||||||
|
|
||||||
export type AssetApiGetTimeBucketsRequest = Parameters<typeof import('@immich/sdk').getTimeBuckets>[0];
|
export type AssetApiGetTimeBucketsRequest = Parameters<typeof import('@immich/sdk').getTimeBuckets>[0];
|
||||||
|
|
||||||
export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'> & {
|
export type TimelineManagerOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'> & {
|
||||||
timelineAlbumId?: string;
|
timelineAlbumId?: string;
|
||||||
deferInit?: boolean;
|
deferInit?: boolean;
|
||||||
};
|
};
|
||||||
@ -74,15 +74,15 @@ export interface UpdateStackAssets {
|
|||||||
|
|
||||||
export type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets | UpdateStackAssets;
|
export type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets | UpdateStackAssets;
|
||||||
|
|
||||||
export type LiteBucket = {
|
export type ScrubberMonth = {
|
||||||
bucketHeight: number;
|
height: number;
|
||||||
assetCount: number;
|
assetCount: number;
|
||||||
year: number;
|
year: number;
|
||||||
month: number;
|
month: number;
|
||||||
bucketDateFormattted: string;
|
title: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AssetStoreLayoutOptions = {
|
export type TimelineManagerLayoutOptions = {
|
||||||
rowHeight?: number;
|
rowHeight?: number;
|
||||||
headerHeight?: number;
|
headerHeight?: number;
|
||||||
gap?: number;
|
gap?: number;
|
||||||
|
@ -1,34 +1,4 @@
|
|||||||
import type { TimelineAsset } from './types';
|
import type { TimelineAsset } from './types';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export function updateObject(target: any, source: any): boolean {
|
|
||||||
if (!target) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let updated = false;
|
|
||||||
for (const key in source) {
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(source, key)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (key === '__proto__' || key === 'constructor') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const isDate = target[key] instanceof Date;
|
|
||||||
if (typeof target[key] === 'object' && !isDate) {
|
|
||||||
updated = updated || updateObject(target[key], source[key]);
|
|
||||||
} else {
|
|
||||||
if (target[key] !== source[key]) {
|
|
||||||
target[key] = source[key];
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isMismatched<T>(option: T | undefined, value: T): boolean {
|
|
||||||
return option === undefined ? false : option !== value;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const assetSnapshot = (asset: TimelineAsset): TimelineAsset => $state.snapshot(asset);
|
export const assetSnapshot = (asset: TimelineAsset): TimelineAsset => $state.snapshot(asset);
|
||||||
export const assetsSnapshot = (assets: TimelineAsset[]) => assets.map((asset) => $state.snapshot(asset));
|
export const assetsSnapshot = (assets: TimelineAsset[]) => assets.map((asset) => $state.snapshot(asset));
|
||||||
|
29
web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts
Normal file
29
web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||||
|
|
||||||
|
import type { DayGroup } from './day-group.svelte';
|
||||||
|
import { calculateViewerAssetIntersecting } from './internal/intersection-support.svelte';
|
||||||
|
import type { TimelineAsset } from './types';
|
||||||
|
|
||||||
|
export class ViewerAsset {
|
||||||
|
readonly #group: DayGroup;
|
||||||
|
|
||||||
|
intersecting = $derived.by(() => {
|
||||||
|
if (!this.position) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = this.#group.monthGroup.timelineManager;
|
||||||
|
const positionTop = this.#group.absoluteDayGroupTop + this.position.top;
|
||||||
|
|
||||||
|
return calculateViewerAssetIntersecting(store, positionTop, this.position.height);
|
||||||
|
});
|
||||||
|
|
||||||
|
position: CommonPosition | undefined = $state();
|
||||||
|
asset: TimelineAsset = <TimelineAsset>$state();
|
||||||
|
id: string = $derived(this.asset.id);
|
||||||
|
|
||||||
|
constructor(group: DayGroup, asset: TimelineAsset) {
|
||||||
|
this.#group = group;
|
||||||
|
this.asset = asset;
|
||||||
|
}
|
||||||
|
}
|
@ -1,582 +0,0 @@
|
|||||||
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
|
||||||
import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
|
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
|
||||||
import { AbortError } from '$lib/utils';
|
|
||||||
import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util';
|
|
||||||
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
|
||||||
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
|
|
||||||
|
|
||||||
async function getAssets(store: AssetStore) {
|
|
||||||
const assets = [];
|
|
||||||
for await (const asset of store.assetsIterator()) {
|
|
||||||
assets.push(asset);
|
|
||||||
}
|
|
||||||
return assets;
|
|
||||||
}
|
|
||||||
|
|
||||||
function deriveLocalDateTimeFromFileCreatedAt(arg: TimelineAsset): TimelineAsset {
|
|
||||||
return {
|
|
||||||
...arg,
|
|
||||||
localDateTime: arg.fileCreatedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('AssetStore', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('init', () => {
|
|
||||||
let assetStore: AssetStore;
|
|
||||||
const bucketAssets: Record<string, TimelineAsset[]> = {
|
|
||||||
'2024-03-01T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
|
|
||||||
deriveLocalDateTimeFromFileCreatedAt({
|
|
||||||
...asset,
|
|
||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-01T00:00:00.000Z'),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
'2024-02-01T00:00:00.000Z': timelineAssetFactory.buildList(100).map((asset) =>
|
|
||||||
deriveLocalDateTimeFromFileCreatedAt({
|
|
||||||
...asset,
|
|
||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-01T00:00:00.000Z'),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
'2024-01-01T00:00:00.000Z': timelineAssetFactory.buildList(3).map((asset) =>
|
|
||||||
deriveLocalDateTimeFromFileCreatedAt({
|
|
||||||
...asset,
|
|
||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-01T00:00:00.000Z'),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
|
|
||||||
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
|
|
||||||
);
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
assetStore = new AssetStore();
|
|
||||||
sdkMock.getTimeBuckets.mockResolvedValue([
|
|
||||||
{ count: 1, timeBucket: '2024-03-01' },
|
|
||||||
{ count: 100, timeBucket: '2024-02-01' },
|
|
||||||
{ count: 3, timeBucket: '2024-01-01' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
|
|
||||||
await assetStore.updateViewport({ width: 1588, height: 1000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should load buckets in viewport', () => {
|
|
||||||
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
|
|
||||||
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calculates bucket height', () => {
|
|
||||||
const plainBuckets = assetStore.buckets.map((bucket) => ({
|
|
||||||
year: bucket.yearMonth.year,
|
|
||||||
month: bucket.yearMonth.month,
|
|
||||||
bucketHeight: bucket.bucketHeight,
|
|
||||||
}));
|
|
||||||
|
|
||||||
expect(plainBuckets).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
expect.objectContaining({ year: 2024, month: 3, bucketHeight: 185.5 }),
|
|
||||||
expect.objectContaining({ year: 2024, month: 2, bucketHeight: 12_016 }),
|
|
||||||
expect.objectContaining({ year: 2024, month: 1, bucketHeight: 286 }),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calculates timeline height', () => {
|
|
||||||
expect(assetStore.timelineHeight).toBe(12_487.5);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('loadBucket', () => {
|
|
||||||
let assetStore: AssetStore;
|
|
||||||
const bucketAssets: Record<string, TimelineAsset[]> = {
|
|
||||||
'2024-01-03T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
|
|
||||||
deriveLocalDateTimeFromFileCreatedAt({
|
|
||||||
...asset,
|
|
||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-01T00:00:00.000Z'),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
'2024-01-01T00:00:00.000Z': timelineAssetFactory.buildList(3).map((asset) =>
|
|
||||||
deriveLocalDateTimeFromFileCreatedAt({
|
|
||||||
...asset,
|
|
||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-01T00:00:00.000Z'),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
|
|
||||||
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
|
|
||||||
);
|
|
||||||
beforeEach(async () => {
|
|
||||||
assetStore = new AssetStore();
|
|
||||||
sdkMock.getTimeBuckets.mockResolvedValue([
|
|
||||||
{ count: 1, timeBucket: '2024-03-01T00:00:00.000Z' },
|
|
||||||
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
|
|
||||||
]);
|
|
||||||
sdkMock.getTimeBucket.mockImplementation(async ({ timeBucket }, { signal } = {}) => {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
if (signal?.aborted) {
|
|
||||||
throw new AbortError();
|
|
||||||
}
|
|
||||||
return bucketAssetsResponse[timeBucket];
|
|
||||||
});
|
|
||||||
await assetStore.updateViewport({ width: 1588, height: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('loads a bucket', async () => {
|
|
||||||
expect(assetStore.getBucketByDate({ year: 2024, month: 1 })?.getAssets().length).toEqual(0);
|
|
||||||
await assetStore.loadBucket({ year: 2024, month: 1 });
|
|
||||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
|
||||||
expect(assetStore.getBucketByDate({ year: 2024, month: 1 })?.getAssets().length).toEqual(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores invalid buckets', async () => {
|
|
||||||
await assetStore.loadBucket({ year: 2023, month: 1 });
|
|
||||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('cancels bucket loading', async () => {
|
|
||||||
const bucket = assetStore.getBucketByDate({ year: 2024, month: 1 })!;
|
|
||||||
void assetStore.loadBucket({ year: 2024, month: 1 });
|
|
||||||
const abortSpy = vi.spyOn(bucket!.loader!.cancelToken!, 'abort');
|
|
||||||
bucket?.cancel();
|
|
||||||
expect(abortSpy).toBeCalledTimes(1);
|
|
||||||
await assetStore.loadBucket({ year: 2024, month: 1 });
|
|
||||||
expect(assetStore.getBucketByDate({ year: 2024, month: 1 })?.getAssets().length).toEqual(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prevents loading buckets multiple times', async () => {
|
|
||||||
await Promise.all([
|
|
||||||
assetStore.loadBucket({ year: 2024, month: 1 }),
|
|
||||||
assetStore.loadBucket({ year: 2024, month: 1 }),
|
|
||||||
]);
|
|
||||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
|
||||||
|
|
||||||
await assetStore.loadBucket({ year: 2024, month: 1 });
|
|
||||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows loading a canceled bucket', async () => {
|
|
||||||
const bucket = assetStore.getBucketByDate({ year: 2024, month: 1 })!;
|
|
||||||
const loadPromise = assetStore.loadBucket({ year: 2024, month: 1 });
|
|
||||||
|
|
||||||
bucket.cancel();
|
|
||||||
await loadPromise;
|
|
||||||
expect(bucket?.getAssets().length).toEqual(0);
|
|
||||||
|
|
||||||
await assetStore.loadBucket({ year: 2024, month: 1 });
|
|
||||||
expect(bucket!.getAssets().length).toEqual(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('addAssets', () => {
|
|
||||||
let assetStore: AssetStore;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
assetStore = new AssetStore();
|
|
||||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
|
||||||
|
|
||||||
await assetStore.updateViewport({ width: 1588, height: 1000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is empty initially', () => {
|
|
||||||
expect(assetStore.buckets.length).toEqual(0);
|
|
||||||
expect(assetStore.count).toEqual(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('adds assets to new bucket', () => {
|
|
||||||
const asset = deriveLocalDateTimeFromFileCreatedAt(
|
|
||||||
timelineAssetFactory.build({
|
|
||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
assetStore.addAssets([asset]);
|
|
||||||
|
|
||||||
expect(assetStore.buckets.length).toEqual(1);
|
|
||||||
expect(assetStore.count).toEqual(1);
|
|
||||||
expect(assetStore.buckets[0].getAssets().length).toEqual(1);
|
|
||||||
expect(assetStore.buckets[0].yearMonth.year).toEqual(2024);
|
|
||||||
expect(assetStore.buckets[0].yearMonth.month).toEqual(1);
|
|
||||||
expect(assetStore.buckets[0].getFirstAsset().id).toEqual(asset.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('adds assets to existing bucket', () => {
|
|
||||||
const [assetOne, assetTwo] = timelineAssetFactory
|
|
||||||
.buildList(2, {
|
|
||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
|
||||||
})
|
|
||||||
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
|
||||||
assetStore.addAssets([assetOne]);
|
|
||||||
assetStore.addAssets([assetTwo]);
|
|
||||||
|
|
||||||
expect(assetStore.buckets.length).toEqual(1);
|
|
||||||
expect(assetStore.count).toEqual(2);
|
|
||||||
expect(assetStore.buckets[0].getAssets().length).toEqual(2);
|
|
||||||
expect(assetStore.buckets[0].yearMonth.year).toEqual(2024);
|
|
||||||
expect(assetStore.buckets[0].yearMonth.month).toEqual(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('orders assets in buckets by descending date', () => {
|
|
||||||
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
|
||||||
timelineAssetFactory.build({
|
|
||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
|
||||||
timelineAssetFactory.build({
|
|
||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const assetThree = deriveLocalDateTimeFromFileCreatedAt(
|
|
||||||
timelineAssetFactory.build({
|
|
||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-16T12:00:00.000Z'),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
assetStore.addAssets([assetOne, assetTwo, assetThree]);
|
|
||||||
|
|
||||||
const bucket = assetStore.getBucketByDate({ year: 2024, month: 1 });
|
|
||||||
expect(bucket).not.toBeNull();
|
|
||||||
expect(bucket?.getAssets().length).toEqual(3);
|
|
||||||
expect(bucket?.getAssets()[0].id).toEqual(assetOne.id);
|
|
||||||
expect(bucket?.getAssets()[1].id).toEqual(assetThree.id);
|
|
||||||
expect(bucket?.getAssets()[2].id).toEqual(assetTwo.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('orders buckets by descending date', () => {
|
|
||||||
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
|
||||||
timelineAssetFactory.build({
|
|
||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
|
||||||
timelineAssetFactory.build({
|
|
||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-04-20T12:00:00.000Z'),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const assetThree = deriveLocalDateTimeFromFileCreatedAt(
|
|
||||||
timelineAssetFactory.build({
|
|
||||||
fileCreatedAt: fromISODateTimeUTCToObject('2023-01-20T12:00:00.000Z'),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
assetStore.addAssets([assetOne, assetTwo, assetThree]);
|
|
||||||
|
|
||||||
expect(assetStore.buckets.length).toEqual(3);
|
|
||||||
expect(assetStore.buckets[0].yearMonth.year).toEqual(2024);
|
|
||||||
expect(assetStore.buckets[0].yearMonth.month).toEqual(4);
|
|
||||||
|
|
||||||
expect(assetStore.buckets[1].yearMonth.year).toEqual(2024);
|
|
||||||
expect(assetStore.buckets[1].yearMonth.month).toEqual(1);
|
|
||||||
|
|
||||||
expect(assetStore.buckets[2].yearMonth.year).toEqual(2023);
|
|
||||||
expect(assetStore.buckets[2].yearMonth.month).toEqual(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates existing asset', () => {
|
|
||||||
const updateAssetsSpy = vi.spyOn(assetStore, 'updateAssets');
|
|
||||||
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build());
|
|
||||||
assetStore.addAssets([asset]);
|
|
||||||
|
|
||||||
assetStore.addAssets([asset]);
|
|
||||||
expect(updateAssetsSpy).toBeCalledWith([asset]);
|
|
||||||
expect(assetStore.count).toEqual(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// disabled due to the wasm Justified Layout import
|
|
||||||
it('ignores trashed assets when isTrashed is true', async () => {
|
|
||||||
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isTrashed: false }));
|
|
||||||
const trashedAsset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isTrashed: true }));
|
|
||||||
|
|
||||||
const assetStore = new AssetStore();
|
|
||||||
await assetStore.updateOptions({ isTrashed: true });
|
|
||||||
assetStore.addAssets([asset, trashedAsset]);
|
|
||||||
expect(await getAssets(assetStore)).toEqual([trashedAsset]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('updateAssets', () => {
|
|
||||||
let assetStore: AssetStore;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
assetStore = new AssetStore();
|
|
||||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
|
||||||
|
|
||||||
await assetStore.updateViewport({ width: 1588, height: 1000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores non-existing assets', () => {
|
|
||||||
assetStore.updateAssets([deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build())]);
|
|
||||||
|
|
||||||
expect(assetStore.buckets.length).toEqual(0);
|
|
||||||
expect(assetStore.count).toEqual(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates an asset', () => {
|
|
||||||
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isFavorite: false }));
|
|
||||||
const updatedAsset = { ...asset, isFavorite: true };
|
|
||||||
|
|
||||||
assetStore.addAssets([asset]);
|
|
||||||
expect(assetStore.count).toEqual(1);
|
|
||||||
expect(assetStore.buckets[0].getFirstAsset().isFavorite).toEqual(false);
|
|
||||||
|
|
||||||
assetStore.updateAssets([updatedAsset]);
|
|
||||||
expect(assetStore.count).toEqual(1);
|
|
||||||
expect(assetStore.buckets[0].getFirstAsset().isFavorite).toEqual(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('asset moves buckets when asset date changes', () => {
|
|
||||||
const asset = deriveLocalDateTimeFromFileCreatedAt(
|
|
||||||
timelineAssetFactory.build({
|
|
||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const updatedAsset = deriveLocalDateTimeFromFileCreatedAt({
|
|
||||||
...asset,
|
|
||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-20T12:00:00.000Z'),
|
|
||||||
});
|
|
||||||
|
|
||||||
assetStore.addAssets([asset]);
|
|
||||||
expect(assetStore.buckets.length).toEqual(1);
|
|
||||||
expect(assetStore.getBucketByDate({ year: 2024, month: 1 })).not.toBeUndefined();
|
|
||||||
expect(assetStore.getBucketByDate({ year: 2024, month: 1 })?.getAssets().length).toEqual(1);
|
|
||||||
|
|
||||||
assetStore.updateAssets([updatedAsset]);
|
|
||||||
expect(assetStore.buckets.length).toEqual(2);
|
|
||||||
expect(assetStore.getBucketByDate({ year: 2024, month: 1 })).not.toBeUndefined();
|
|
||||||
expect(assetStore.getBucketByDate({ year: 2024, month: 1 })?.getAssets().length).toEqual(0);
|
|
||||||
expect(assetStore.getBucketByDate({ year: 2024, month: 3 })).not.toBeUndefined();
|
|
||||||
expect(assetStore.getBucketByDate({ year: 2024, month: 3 })?.getAssets().length).toEqual(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('removeAssets', () => {
|
|
||||||
let assetStore: AssetStore;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
assetStore = new AssetStore();
|
|
||||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
|
||||||
|
|
||||||
await assetStore.updateViewport({ width: 1588, height: 1000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores invalid IDs', () => {
|
|
||||||
assetStore.addAssets(
|
|
||||||
timelineAssetFactory
|
|
||||||
.buildList(2, {
|
|
||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
|
||||||
})
|
|
||||||
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset)),
|
|
||||||
);
|
|
||||||
assetStore.removeAssets(['', 'invalid', '4c7d9acc']);
|
|
||||||
|
|
||||||
expect(assetStore.count).toEqual(2);
|
|
||||||
expect(assetStore.buckets.length).toEqual(1);
|
|
||||||
expect(assetStore.buckets[0].getAssets().length).toEqual(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('removes asset from bucket', () => {
|
|
||||||
const [assetOne, assetTwo] = timelineAssetFactory
|
|
||||||
.buildList(2, {
|
|
||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
|
||||||
})
|
|
||||||
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
|
||||||
assetStore.addAssets([assetOne, assetTwo]);
|
|
||||||
assetStore.removeAssets([assetOne.id]);
|
|
||||||
|
|
||||||
expect(assetStore.count).toEqual(1);
|
|
||||||
expect(assetStore.buckets.length).toEqual(1);
|
|
||||||
expect(assetStore.buckets[0].getAssets().length).toEqual(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not remove bucket when empty', () => {
|
|
||||||
const assets = timelineAssetFactory
|
|
||||||
.buildList(2, {
|
|
||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
|
||||||
})
|
|
||||||
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
|
||||||
assetStore.addAssets(assets);
|
|
||||||
assetStore.removeAssets(assets.map((asset) => asset.id));
|
|
||||||
|
|
||||||
expect(assetStore.count).toEqual(0);
|
|
||||||
expect(assetStore.buckets.length).toEqual(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('firstAsset', () => {
|
|
||||||
let assetStore: AssetStore;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
assetStore = new AssetStore();
|
|
||||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
|
||||||
await assetStore.updateViewport({ width: 0, height: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('empty store returns null', () => {
|
|
||||||
expect(assetStore.getFirstAsset()).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('populated store returns first asset', () => {
|
|
||||||
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
|
||||||
timelineAssetFactory.build({
|
|
||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
|
||||||
timelineAssetFactory.build({
|
|
||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
assetStore.addAssets([assetOne, assetTwo]);
|
|
||||||
expect(assetStore.getFirstAsset()).toEqual(assetOne);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getLaterAsset', () => {
|
|
||||||
let assetStore: AssetStore;
|
|
||||||
const bucketAssets: Record<string, TimelineAsset[]> = {
|
|
||||||
'2024-03-01T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
|
|
||||||
deriveLocalDateTimeFromFileCreatedAt({
|
|
||||||
...asset,
|
|
||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-01T00:00:00.000Z'),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
'2024-02-01T00:00:00.000Z': timelineAssetFactory.buildList(6).map((asset) =>
|
|
||||||
deriveLocalDateTimeFromFileCreatedAt({
|
|
||||||
...asset,
|
|
||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-01T00:00:00.000Z'),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
'2024-01-01T00:00:00.000Z': timelineAssetFactory.buildList(3).map((asset) =>
|
|
||||||
deriveLocalDateTimeFromFileCreatedAt({
|
|
||||||
...asset,
|
|
||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-01T00:00:00.000Z'),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
|
|
||||||
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
|
|
||||||
);
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
assetStore = new AssetStore();
|
|
||||||
sdkMock.getTimeBuckets.mockResolvedValue([
|
|
||||||
{ count: 1, timeBucket: '2024-03-01T00:00:00.000Z' },
|
|
||||||
{ count: 6, timeBucket: '2024-02-01T00:00:00.000Z' },
|
|
||||||
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
|
|
||||||
]);
|
|
||||||
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
|
|
||||||
await assetStore.updateViewport({ width: 1588, height: 1000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for invalid assetId', async () => {
|
|
||||||
expect(() => assetStore.getLaterAsset({ id: 'invalid' } as AssetResponseDto)).not.toThrow();
|
|
||||||
expect(await assetStore.getLaterAsset({ id: 'invalid' } as AssetResponseDto)).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns previous assetId', async () => {
|
|
||||||
await assetStore.loadBucket({ year: 2024, month: 1 });
|
|
||||||
const bucket = assetStore.getBucketByDate({ year: 2024, month: 1 });
|
|
||||||
|
|
||||||
const a = bucket!.getAssets()[0];
|
|
||||||
const b = bucket!.getAssets()[1];
|
|
||||||
const previous = await assetStore.getLaterAsset(b);
|
|
||||||
expect(previous).toEqual(a);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns previous assetId spanning multiple buckets', async () => {
|
|
||||||
await assetStore.loadBucket({ year: 2024, month: 2 });
|
|
||||||
await assetStore.loadBucket({ year: 2024, month: 3 });
|
|
||||||
|
|
||||||
const bucket = assetStore.getBucketByDate({ year: 2024, month: 2 });
|
|
||||||
const previousBucket = assetStore.getBucketByDate({ year: 2024, month: 3 });
|
|
||||||
const a = bucket!.getAssets()[0];
|
|
||||||
const b = previousBucket!.getAssets()[0];
|
|
||||||
const previous = await assetStore.getLaterAsset(a);
|
|
||||||
expect(previous).toEqual(b);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('loads previous bucket', async () => {
|
|
||||||
await assetStore.loadBucket({ year: 2024, month: 2 });
|
|
||||||
const bucket = assetStore.getBucketByDate({ year: 2024, month: 2 });
|
|
||||||
const previousBucket = assetStore.getBucketByDate({ year: 2024, month: 3 });
|
|
||||||
const a = bucket!.getFirstAsset();
|
|
||||||
const b = previousBucket!.getFirstAsset();
|
|
||||||
const loadBucketSpy = vi.spyOn(bucket!.loader!, 'execute');
|
|
||||||
const previousBucketSpy = vi.spyOn(previousBucket!.loader!, 'execute');
|
|
||||||
const previous = await assetStore.getLaterAsset(a);
|
|
||||||
expect(previous).toEqual(b);
|
|
||||||
expect(loadBucketSpy).toBeCalledTimes(0);
|
|
||||||
expect(previousBucketSpy).toBeCalledTimes(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skips removed assets', async () => {
|
|
||||||
await assetStore.loadBucket({ year: 2024, month: 1 });
|
|
||||||
await assetStore.loadBucket({ year: 2024, month: 2 });
|
|
||||||
await assetStore.loadBucket({ year: 2024, month: 3 });
|
|
||||||
|
|
||||||
const [assetOne, assetTwo, assetThree] = await getAssets(assetStore);
|
|
||||||
assetStore.removeAssets([assetTwo.id]);
|
|
||||||
expect(await assetStore.getLaterAsset(assetThree)).toEqual(assetOne);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when no more assets', async () => {
|
|
||||||
await assetStore.loadBucket({ year: 2024, month: 3 });
|
|
||||||
expect(await assetStore.getLaterAsset(assetStore.buckets[0].getFirstAsset())).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getBucketIndexByAssetId', () => {
|
|
||||||
let assetStore: AssetStore;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
assetStore = new AssetStore();
|
|
||||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
|
||||||
|
|
||||||
await assetStore.updateViewport({ width: 0, height: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for invalid buckets', () => {
|
|
||||||
expect(assetStore.getBucketByDate({ year: -1, month: -1 })).toBeUndefined();
|
|
||||||
expect(assetStore.getBucketByDate({ year: 2024, month: 3 })).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the bucket index', () => {
|
|
||||||
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
|
||||||
timelineAssetFactory.build({
|
|
||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
|
||||||
timelineAssetFactory.build({
|
|
||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
assetStore.addAssets([assetOne, assetTwo]);
|
|
||||||
|
|
||||||
expect(assetStore.getBucketIndexByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024);
|
|
||||||
expect(assetStore.getBucketIndexByAssetId(assetTwo.id)?.yearMonth.month).toEqual(2);
|
|
||||||
expect(assetStore.getBucketIndexByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
|
|
||||||
expect(assetStore.getBucketIndexByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores removed buckets', () => {
|
|
||||||
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
|
||||||
timelineAssetFactory.build({
|
|
||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
|
||||||
timelineAssetFactory.build({
|
|
||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
assetStore.addAssets([assetOne, assetTwo]);
|
|
||||||
|
|
||||||
assetStore.removeAssets([assetTwo.id]);
|
|
||||||
expect(assetStore.getBucketIndexByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
|
|
||||||
expect(assetStore.getBucketIndexByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,5 +1,5 @@
|
|||||||
import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification';
|
import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification';
|
||||||
import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
import type { StackResponse } from '$lib/utils/asset-utils';
|
import type { StackResponse } from '$lib/utils/asset-utils';
|
||||||
import { AssetVisibility, deleteAssets as deleteBulk, restoreAssets } from '@immich/sdk';
|
import { AssetVisibility, deleteAssets as deleteBulk, restoreAssets } from '@immich/sdk';
|
||||||
@ -63,12 +63,12 @@ const undoDeleteAssets = async (onUndoDelete: OnUndoDelete, assets: TimelineAsse
|
|||||||
* This function updates the stack information so that the icon is shown for the primary asset
|
* This function updates the stack information so that the icon is shown for the primary asset
|
||||||
* and removes any assets from the timeline that are marked for deletion.
|
* and removes any assets from the timeline that are marked for deletion.
|
||||||
*
|
*
|
||||||
* @param {AssetStore} assetStore - The asset store to update.
|
* @param {TimelineManager} timelineManager - The timeline manager to update.
|
||||||
* @param {StackResponse} stackResponse - The stack response containing the stack and assets to delete.
|
* @param {StackResponse} stackResponse - The stack response containing the stack and assets to delete.
|
||||||
*/
|
*/
|
||||||
export function updateStackedAssetInTimeline(assetStore: AssetStore, { stack, toDeleteIds }: StackResponse) {
|
export function updateStackedAssetInTimeline(timelineManager: TimelineManager, { stack, toDeleteIds }: StackResponse) {
|
||||||
if (stack != undefined) {
|
if (stack != undefined) {
|
||||||
assetStore.updateAssetOperation([stack.primaryAssetId], (asset) => {
|
timelineManager.updateAssetOperation([stack.primaryAssetId], (asset) => {
|
||||||
asset.stack = {
|
asset.stack = {
|
||||||
id: stack.id,
|
id: stack.id,
|
||||||
primaryAssetId: stack.primaryAssetId,
|
primaryAssetId: stack.primaryAssetId,
|
||||||
@ -77,20 +77,20 @@ export function updateStackedAssetInTimeline(assetStore: AssetStore, { stack, to
|
|||||||
return { remove: false };
|
return { remove: false };
|
||||||
});
|
});
|
||||||
|
|
||||||
assetStore.removeAssets(toDeleteIds);
|
timelineManager.removeAssets(toDeleteIds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the asset store to reflect the unstacked state of assets.
|
* Update the timeline manager to reflect the unstacked state of assets.
|
||||||
* This function updates the stack property of each asset to undefined, effectively unstacking them.
|
* This function updates the stack property of each asset to undefined, effectively unstacking them.
|
||||||
* It also adds the unstacked assets back to the asset store.
|
* It also adds the unstacked assets back to the timeline manager.
|
||||||
*
|
*
|
||||||
* @param assetStore - The asset store to update.
|
* @param timelineManager - The timeline manager to update.
|
||||||
* @param assets - The array of asset response DTOs to update in the asset store.
|
* @param assets - The array of asset response DTOs to update in the timeline manager.
|
||||||
*/
|
*/
|
||||||
export function updateUnstackedAssetInTimeline(assetStore: AssetStore, assets: TimelineAsset[]) {
|
export function updateUnstackedAssetInTimeline(timelineManager: TimelineManager, assets: TimelineAsset[]) {
|
||||||
assetStore.updateAssetOperation(
|
timelineManager.updateAssetOperation(
|
||||||
assets.map((asset) => asset.id),
|
assets.map((asset) => asset.id),
|
||||||
(asset) => {
|
(asset) => {
|
||||||
asset.stack = null;
|
asset.stack = null;
|
||||||
@ -98,5 +98,5 @@ export function updateUnstackedAssetInTimeline(assetStore: AssetStore, assets: T
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
assetStore.addAssets(assets);
|
timelineManager.addAssets(assets);
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,7 @@ import { notificationController, NotificationType } from '$lib/components/shared
|
|||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } from '$lib/constants';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { downloadManager } from '$lib/managers/download-manager.svelte';
|
import { downloadManager } from '$lib/managers/download-manager.svelte';
|
||||||
import type { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
|
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
@ -484,7 +484,7 @@ export const keepThisDeleteOthers = async (keepAsset: AssetResponseDto, stack: S
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const selectAllAssets = async (assetStore: AssetStore, assetInteraction: AssetInteraction) => {
|
export const selectAllAssets = async (timelineManager: TimelineManager, assetInteraction: AssetInteraction) => {
|
||||||
if (get(isSelectingAllAssets)) {
|
if (get(isSelectingAllAssets)) {
|
||||||
// Selection is already ongoing
|
// Selection is already ongoing
|
||||||
return;
|
return;
|
||||||
@ -492,16 +492,16 @@ export const selectAllAssets = async (assetStore: AssetStore, assetInteraction:
|
|||||||
isSelectingAllAssets.set(true);
|
isSelectingAllAssets.set(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const bucket of assetStore.buckets) {
|
for (const monthGroup of timelineManager.months) {
|
||||||
await assetStore.loadBucket(bucket.yearMonth);
|
await timelineManager.loadMonthGroup(monthGroup.yearMonth);
|
||||||
|
|
||||||
if (!get(isSelectingAllAssets)) {
|
if (!get(isSelectingAllAssets)) {
|
||||||
assetInteraction.clearMultiselect();
|
assetInteraction.clearMultiselect();
|
||||||
break; // Cancelled
|
break; // Cancelled
|
||||||
}
|
}
|
||||||
assetInteraction.selectAssets(assetsSnapshot([...bucket.assetsIterator()]));
|
assetInteraction.selectAssets(assetsSnapshot([...monthGroup.assetsIterator()]));
|
||||||
|
|
||||||
for (const dateGroup of bucket.dateGroups) {
|
for (const dateGroup of monthGroup.dayGroups) {
|
||||||
assetInteraction.addGroupToMultiselectGroup(dateGroup.groupTitle);
|
assetInteraction.addGroupToMultiselectGroup(dateGroup.groupTitle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -23,9 +23,9 @@ export type TimelinePlainDateTime = TimelinePlainDate & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ScrubberListener = (
|
export type ScrubberListener = (
|
||||||
bucketDate: { year: number; month: number },
|
scrubberMonth: { year: number; month: number },
|
||||||
overallScrollPercent: number,
|
overallScrollPercent: number,
|
||||||
bucketScrollPercent: number,
|
scrubberMonthScrollPercent: number,
|
||||||
) => void | Promise<void>;
|
) => void | Promise<void>;
|
||||||
|
|
||||||
// used for AssetResponseDto.dateTimeOriginal, amongst others
|
// used for AssetResponseDto.dateTimeOriginal, amongst others
|
||||||
@ -99,7 +99,7 @@ export const fromTimelinePlainYearMonth = (timelineYearMonth: TimelinePlainYearM
|
|||||||
export const toISOYearMonthUTC = (timelineYearMonth: TimelinePlainYearMonth): string =>
|
export const toISOYearMonthUTC = (timelineYearMonth: TimelinePlainYearMonth): string =>
|
||||||
(fromTimelinePlainYearMonth(timelineYearMonth).setZone('UTC', { keepLocalTime: true }) as DateTime<true>).toISO();
|
(fromTimelinePlainYearMonth(timelineYearMonth).setZone('UTC', { keepLocalTime: true }) as DateTime<true>).toISO();
|
||||||
|
|
||||||
export function formatBucketTitle(_date: DateTime): string {
|
export function formatMonthGroupTitle(_date: DateTime): string {
|
||||||
if (!_date.isValid) {
|
if (!_date.isValid) {
|
||||||
return _date.toString();
|
return _date.toString();
|
||||||
}
|
}
|
||||||
|
@ -36,13 +36,14 @@
|
|||||||
import { AlbumPageViewMode, AppRoute } from '$lib/constants';
|
import { AlbumPageViewMode, AppRoute } from '$lib/constants';
|
||||||
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
||||||
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||||
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte';
|
import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte';
|
||||||
import AlbumUsersModal from '$lib/modals/AlbumUsersModal.svelte';
|
import AlbumUsersModal from '$lib/modals/AlbumUsersModal.svelte';
|
||||||
import QrCodeModal from '$lib/modals/QrCodeModal.svelte';
|
import QrCodeModal from '$lib/modals/QrCodeModal.svelte';
|
||||||
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
|
|
||||||
import { featureFlags } from '$lib/stores/server-config.store';
|
import { featureFlags } from '$lib/stores/server-config.store';
|
||||||
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||||
import { preferences, user } from '$lib/stores/user.store';
|
import { preferences, user } from '$lib/stores/user.store';
|
||||||
@ -87,7 +88,6 @@
|
|||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: PageData;
|
data: PageData;
|
||||||
@ -174,15 +174,15 @@
|
|||||||
const handleStartSlideshow = async () => {
|
const handleStartSlideshow = async () => {
|
||||||
const asset =
|
const asset =
|
||||||
$slideshowNavigation === SlideshowNavigation.Shuffle
|
$slideshowNavigation === SlideshowNavigation.Shuffle
|
||||||
? await assetStore.getRandomAsset()
|
? await timelineManager.getRandomAsset()
|
||||||
: assetStore.buckets[0]?.dateGroups[0]?.intersectingAssets[0]?.asset;
|
: timelineManager.months[0]?.dayGroups[0]?.viewerAssets[0]?.asset;
|
||||||
if (asset) {
|
if (asset) {
|
||||||
handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)));
|
handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEscape = async () => {
|
const handleEscape = async () => {
|
||||||
assetStore.suspendTransitions = true;
|
timelineManager.suspendTransitions = true;
|
||||||
if (viewMode === AlbumPageViewMode.SELECT_THUMBNAIL) {
|
if (viewMode === AlbumPageViewMode.SELECT_THUMBNAIL) {
|
||||||
viewMode = AlbumPageViewMode.VIEW;
|
viewMode = AlbumPageViewMode.VIEW;
|
||||||
return;
|
return;
|
||||||
@ -234,7 +234,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setModeToView = async () => {
|
const setModeToView = async () => {
|
||||||
assetStore.suspendTransitions = true;
|
timelineManager.suspendTransitions = true;
|
||||||
viewMode = AlbumPageViewMode.VIEW;
|
viewMode = AlbumPageViewMode.VIEW;
|
||||||
await navigate(
|
await navigate(
|
||||||
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: { at: oldAt?.at } },
|
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: { at: oldAt?.at } },
|
||||||
@ -309,17 +309,17 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSetVisibility = (assetIds: string[]) => {
|
const handleSetVisibility = (assetIds: string[]) => {
|
||||||
assetStore.removeAssets(assetIds);
|
timelineManager.removeAssets(assetIds);
|
||||||
assetInteraction.clearMultiselect();
|
assetInteraction.clearMultiselect();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveAssets = async (assetIds: string[]) => {
|
const handleRemoveAssets = async (assetIds: string[]) => {
|
||||||
assetStore.removeAssets(assetIds);
|
timelineManager.removeAssets(assetIds);
|
||||||
await refreshAlbum();
|
await refreshAlbum();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUndoRemoveAssets = async (assets: TimelineAsset[]) => {
|
const handleUndoRemoveAssets = async (assets: TimelineAsset[]) => {
|
||||||
assetStore.addAssets(assets);
|
timelineManager.addAssets(assets);
|
||||||
await refreshAlbum();
|
await refreshAlbum();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -374,13 +374,13 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let assetStore = new AssetStore();
|
let timelineManager = new TimelineManager();
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (viewMode === AlbumPageViewMode.VIEW) {
|
if (viewMode === AlbumPageViewMode.VIEW) {
|
||||||
void assetStore.updateOptions({ albumId, order: albumOrder });
|
void timelineManager.updateOptions({ albumId, order: albumOrder });
|
||||||
} else if (viewMode === AlbumPageViewMode.SELECT_ASSETS) {
|
} else if (viewMode === AlbumPageViewMode.SELECT_ASSETS) {
|
||||||
void assetStore.updateOptions({
|
void timelineManager.updateOptions({
|
||||||
visibility: AssetVisibility.Timeline,
|
visibility: AssetVisibility.Timeline,
|
||||||
withPartners: true,
|
withPartners: true,
|
||||||
timelineAlbumId: albumId,
|
timelineAlbumId: albumId,
|
||||||
@ -395,7 +395,7 @@
|
|||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
activityManager.reset();
|
activityManager.reset();
|
||||||
assetStore.destroy();
|
timelineManager.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
let isOwned = $derived($user.id == album.ownerId);
|
let isOwned = $derived($user.id == album.ownerId);
|
||||||
@ -470,7 +470,7 @@
|
|||||||
<AssetGrid
|
<AssetGrid
|
||||||
enableRouting={viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : true}
|
enableRouting={viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : true}
|
||||||
{album}
|
{album}
|
||||||
{assetStore}
|
{timelineManager}
|
||||||
assetInteraction={currentAssetIntersection}
|
assetInteraction={currentAssetIntersection}
|
||||||
{isShared}
|
{isShared}
|
||||||
{isSelectionMode}
|
{isSelectionMode}
|
||||||
@ -590,7 +590,7 @@
|
|||||||
clearSelect={() => assetInteraction.clearMultiselect()}
|
clearSelect={() => assetInteraction.clearMultiselect()}
|
||||||
>
|
>
|
||||||
<CreateSharedLink />
|
<CreateSharedLink />
|
||||||
<SelectAllAssets {assetStore} {assetInteraction} />
|
<SelectAllAssets {timelineManager} {assetInteraction} />
|
||||||
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||||
<AddToAlbum />
|
<AddToAlbum />
|
||||||
<AddToAlbum shared />
|
<AddToAlbum shared />
|
||||||
@ -599,7 +599,7 @@
|
|||||||
<FavoriteAction
|
<FavoriteAction
|
||||||
removeFavorite={assetInteraction.isAllFavorite}
|
removeFavorite={assetInteraction.isAllFavorite}
|
||||||
onFavorite={(ids, isFavorite) =>
|
onFavorite={(ids, isFavorite) =>
|
||||||
assetStore.updateAssetOperation(ids, (asset) => {
|
timelineManager.updateAssetOperation(ids, (asset) => {
|
||||||
asset.isFavorite = isFavorite;
|
asset.isFavorite = isFavorite;
|
||||||
return { remove: false };
|
return { remove: false };
|
||||||
})}
|
})}
|
||||||
@ -647,7 +647,7 @@
|
|||||||
color="secondary"
|
color="secondary"
|
||||||
aria-label={$t('add_photos')}
|
aria-label={$t('add_photos')}
|
||||||
onclick={async () => {
|
onclick={async () => {
|
||||||
assetStore.suspendTransitions = true;
|
timelineManager.suspendTransitions = true;
|
||||||
viewMode = AlbumPageViewMode.SELECT_ASSETS;
|
viewMode = AlbumPageViewMode.SELECT_ASSETS;
|
||||||
oldAt = { at: $gridScrollTarget?.at };
|
oldAt = { at: $gridScrollTarget?.at };
|
||||||
await navigate(
|
await navigate(
|
||||||
|
@ -14,8 +14,8 @@
|
|||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
|
|
||||||
import SetVisibilityAction from '$lib/components/photos-page/actions/set-visibility-action.svelte';
|
import SetVisibilityAction from '$lib/components/photos-page/actions/set-visibility-action.svelte';
|
||||||
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
|
|
||||||
import { AssetVisibility } from '@immich/sdk';
|
import { AssetVisibility } from '@immich/sdk';
|
||||||
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
|
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
@ -27,9 +27,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let { data }: Props = $props();
|
let { data }: Props = $props();
|
||||||
const assetStore = new AssetStore();
|
const timelineManager = new TimelineManager();
|
||||||
void assetStore.updateOptions({ visibility: AssetVisibility.Archive });
|
void timelineManager.updateOptions({ visibility: AssetVisibility.Archive });
|
||||||
onDestroy(() => assetStore.destroy());
|
onDestroy(() => timelineManager.destroy());
|
||||||
|
|
||||||
const assetInteraction = new AssetInteraction();
|
const assetInteraction = new AssetInteraction();
|
||||||
|
|
||||||
@ -41,7 +41,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSetVisibility = (assetIds: string[]) => {
|
const handleSetVisibility = (assetIds: string[]) => {
|
||||||
assetStore.removeAssets(assetIds);
|
timelineManager.removeAssets(assetIds);
|
||||||
assetInteraction.clearMultiselect();
|
assetInteraction.clearMultiselect();
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@ -49,7 +49,7 @@
|
|||||||
<UserPageLayout hideNavbar={assetInteraction.selectionActive} title={data.meta.title} scrollbar={false}>
|
<UserPageLayout hideNavbar={assetInteraction.selectionActive} title={data.meta.title} scrollbar={false}>
|
||||||
<AssetGrid
|
<AssetGrid
|
||||||
enableRouting={true}
|
enableRouting={true}
|
||||||
{assetStore}
|
{timelineManager}
|
||||||
{assetInteraction}
|
{assetInteraction}
|
||||||
removeAction={AssetAction.UNARCHIVE}
|
removeAction={AssetAction.UNARCHIVE}
|
||||||
onEscape={handleEscape}
|
onEscape={handleEscape}
|
||||||
@ -68,13 +68,13 @@
|
|||||||
<ArchiveAction
|
<ArchiveAction
|
||||||
unarchive
|
unarchive
|
||||||
onArchive={(ids, visibility) =>
|
onArchive={(ids, visibility) =>
|
||||||
assetStore.updateAssetOperation(ids, (asset) => {
|
timelineManager.updateAssetOperation(ids, (asset) => {
|
||||||
asset.visibility = visibility;
|
asset.visibility = visibility;
|
||||||
return { remove: false };
|
return { remove: false };
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<CreateSharedLink />
|
<CreateSharedLink />
|
||||||
<SelectAllAssets {assetStore} {assetInteraction} />
|
<SelectAllAssets {timelineManager} {assetInteraction} />
|
||||||
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||||
<AddToAlbum />
|
<AddToAlbum />
|
||||||
<AddToAlbum shared />
|
<AddToAlbum shared />
|
||||||
@ -82,7 +82,7 @@
|
|||||||
<FavoriteAction
|
<FavoriteAction
|
||||||
removeFavorite={assetInteraction.isAllFavorite}
|
removeFavorite={assetInteraction.isAllFavorite}
|
||||||
onFavorite={(ids, isFavorite) =>
|
onFavorite={(ids, isFavorite) =>
|
||||||
assetStore.updateAssetOperation(ids, (asset) => {
|
timelineManager.updateAssetOperation(ids, (asset) => {
|
||||||
asset.isFavorite = isFavorite;
|
asset.isFavorite = isFavorite;
|
||||||
return { remove: false };
|
return { remove: false };
|
||||||
})}
|
})}
|
||||||
@ -90,7 +90,7 @@
|
|||||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||||
<DownloadAction menuItem />
|
<DownloadAction menuItem />
|
||||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||||
<DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
|
<DeleteAssets menuItem onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)} />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -17,8 +17,8 @@
|
|||||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
|
|
||||||
import { preferences } from '$lib/stores/user.store';
|
import { preferences } from '$lib/stores/user.store';
|
||||||
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
|
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
@ -31,9 +31,9 @@
|
|||||||
|
|
||||||
let { data }: Props = $props();
|
let { data }: Props = $props();
|
||||||
|
|
||||||
const assetStore = new AssetStore();
|
const timelineManager = new TimelineManager();
|
||||||
void assetStore.updateOptions({ isFavorite: true, withStacked: true });
|
void timelineManager.updateOptions({ isFavorite: true, withStacked: true });
|
||||||
onDestroy(() => assetStore.destroy());
|
onDestroy(() => timelineManager.destroy());
|
||||||
|
|
||||||
const assetInteraction = new AssetInteraction();
|
const assetInteraction = new AssetInteraction();
|
||||||
|
|
||||||
@ -45,7 +45,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSetVisibility = (assetIds: string[]) => {
|
const handleSetVisibility = (assetIds: string[]) => {
|
||||||
assetStore.removeAssets(assetIds);
|
timelineManager.removeAssets(assetIds);
|
||||||
assetInteraction.clearMultiselect();
|
assetInteraction.clearMultiselect();
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@ -54,7 +54,7 @@
|
|||||||
<AssetGrid
|
<AssetGrid
|
||||||
enableRouting={true}
|
enableRouting={true}
|
||||||
withStacked={true}
|
withStacked={true}
|
||||||
{assetStore}
|
{timelineManager}
|
||||||
{assetInteraction}
|
{assetInteraction}
|
||||||
removeAction={AssetAction.UNFAVORITE}
|
removeAction={AssetAction.UNFAVORITE}
|
||||||
onEscape={handleEscape}
|
onEscape={handleEscape}
|
||||||
@ -71,9 +71,9 @@
|
|||||||
assets={assetInteraction.selectedAssets}
|
assets={assetInteraction.selectedAssets}
|
||||||
clearSelect={() => assetInteraction.clearMultiselect()}
|
clearSelect={() => assetInteraction.clearMultiselect()}
|
||||||
>
|
>
|
||||||
<FavoriteAction removeFavorite onFavorite={(assetIds) => assetStore.removeAssets(assetIds)} />
|
<FavoriteAction removeFavorite onFavorite={(assetIds) => timelineManager.removeAssets(assetIds)} />
|
||||||
<CreateSharedLink />
|
<CreateSharedLink />
|
||||||
<SelectAllAssets {assetStore} {assetInteraction} />
|
<SelectAllAssets {timelineManager} {assetInteraction} />
|
||||||
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||||
<AddToAlbum />
|
<AddToAlbum />
|
||||||
<AddToAlbum shared />
|
<AddToAlbum shared />
|
||||||
@ -86,7 +86,7 @@
|
|||||||
<ArchiveAction
|
<ArchiveAction
|
||||||
menuItem
|
menuItem
|
||||||
unarchive={assetInteraction.isAllArchived}
|
unarchive={assetInteraction.isAllArchived}
|
||||||
onArchive={(assetIds) => assetStore.removeAssets(assetIds)}
|
onArchive={(assetIds) => timelineManager.removeAssets(assetIds)}
|
||||||
/>
|
/>
|
||||||
{#if $preferences.tags.enabled}
|
{#if $preferences.tags.enabled}
|
||||||
<TagAction menuItem />
|
<TagAction menuItem />
|
||||||
@ -94,8 +94,8 @@
|
|||||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||||
<DeleteAssets
|
<DeleteAssets
|
||||||
menuItem
|
menuItem
|
||||||
onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)}
|
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
|
||||||
onUndoDelete={(assets) => assetStore.addAssets(assets)}
|
onUndoDelete={(assets) => timelineManager.addAssets(assets)}
|
||||||
/>
|
/>
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
|
@ -12,8 +12,8 @@
|
|||||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||||
import { AppRoute, AssetAction } from '$lib/constants';
|
import { AppRoute, AssetAction } from '$lib/constants';
|
||||||
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
|
|
||||||
import { AssetVisibility, lockAuthSession } from '@immich/sdk';
|
import { AssetVisibility, lockAuthSession } from '@immich/sdk';
|
||||||
import { Button } from '@immich/ui';
|
import { Button } from '@immich/ui';
|
||||||
import { mdiDotsVertical, mdiLockOutline } from '@mdi/js';
|
import { mdiDotsVertical, mdiLockOutline } from '@mdi/js';
|
||||||
@ -27,9 +27,9 @@
|
|||||||
|
|
||||||
let { data }: Props = $props();
|
let { data }: Props = $props();
|
||||||
|
|
||||||
const assetStore = new AssetStore();
|
const timelineManager = new TimelineManager();
|
||||||
void assetStore.updateOptions({ visibility: AssetVisibility.Locked });
|
void timelineManager.updateOptions({ visibility: AssetVisibility.Locked });
|
||||||
onDestroy(() => assetStore.destroy());
|
onDestroy(() => timelineManager.destroy());
|
||||||
|
|
||||||
const assetInteraction = new AssetInteraction();
|
const assetInteraction = new AssetInteraction();
|
||||||
|
|
||||||
@ -42,7 +42,7 @@
|
|||||||
|
|
||||||
const handleMoveOffLockedFolder = (assetIds: string[]) => {
|
const handleMoveOffLockedFolder = (assetIds: string[]) => {
|
||||||
assetInteraction.clearMultiselect();
|
assetInteraction.clearMultiselect();
|
||||||
assetStore.removeAssets(assetIds);
|
timelineManager.removeAssets(assetIds);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLock = async () => {
|
const handleLock = async () => {
|
||||||
@ -60,7 +60,7 @@
|
|||||||
|
|
||||||
<AssetGrid
|
<AssetGrid
|
||||||
enableRouting={true}
|
enableRouting={true}
|
||||||
{assetStore}
|
{timelineManager}
|
||||||
{assetInteraction}
|
{assetInteraction}
|
||||||
onEscape={handleEscape}
|
onEscape={handleEscape}
|
||||||
removeAction={AssetAction.SET_VISIBILITY_TIMELINE}
|
removeAction={AssetAction.SET_VISIBILITY_TIMELINE}
|
||||||
@ -77,13 +77,13 @@
|
|||||||
assets={assetInteraction.selectedAssets}
|
assets={assetInteraction.selectedAssets}
|
||||||
clearSelect={() => assetInteraction.clearMultiselect()}
|
clearSelect={() => assetInteraction.clearMultiselect()}
|
||||||
>
|
>
|
||||||
<SelectAllAssets withText {assetStore} {assetInteraction} />
|
<SelectAllAssets withText {timelineManager} {assetInteraction} />
|
||||||
<SetVisibilityAction unlock onVisibilitySet={handleMoveOffLockedFolder} />
|
<SetVisibilityAction unlock onVisibilitySet={handleMoveOffLockedFolder} />
|
||||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||||
<DownloadAction menuItem />
|
<DownloadAction menuItem />
|
||||||
<ChangeDate menuItem />
|
<ChangeDate menuItem />
|
||||||
<ChangeLocation menuItem />
|
<ChangeLocation menuItem />
|
||||||
<DeleteAssets menuItem force onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
|
<DeleteAssets menuItem force onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)} />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -8,8 +8,8 @@
|
|||||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||||
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } from '$lib/constants';
|
||||||
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
|
|
||||||
import { AssetVisibility } from '@immich/sdk';
|
import { AssetVisibility } from '@immich/sdk';
|
||||||
import { mdiArrowLeft, mdiPlus } from '@mdi/js';
|
import { mdiArrowLeft, mdiPlus } from '@mdi/js';
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
@ -22,16 +22,16 @@
|
|||||||
|
|
||||||
let { data }: Props = $props();
|
let { data }: Props = $props();
|
||||||
|
|
||||||
const assetStore = new AssetStore();
|
const timelineManager = new TimelineManager();
|
||||||
$effect(
|
$effect(
|
||||||
() =>
|
() =>
|
||||||
void assetStore.updateOptions({
|
void timelineManager.updateOptions({
|
||||||
userId: data.partner.id,
|
userId: data.partner.id,
|
||||||
visibility: AssetVisibility.Timeline,
|
visibility: AssetVisibility.Timeline,
|
||||||
withStacked: true,
|
withStacked: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
onDestroy(() => assetStore.destroy());
|
onDestroy(() => timelineManager.destroy());
|
||||||
const assetInteraction = new AssetInteraction();
|
const assetInteraction = new AssetInteraction();
|
||||||
|
|
||||||
const handleEscape = () => {
|
const handleEscape = () => {
|
||||||
@ -43,7 +43,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="grid h-dvh pt-18">
|
<main class="grid h-dvh pt-18">
|
||||||
<AssetGrid enableRouting={true} {assetStore} {assetInteraction} onEscape={handleEscape} />
|
<AssetGrid enableRouting={true} {timelineManager} {assetInteraction} onEscape={handleEscape} />
|
||||||
|
|
||||||
{#if assetInteraction.selectionActive}
|
{#if assetInteraction.selectionActive}
|
||||||
<AssetSelectControlBar
|
<AssetSelectControlBar
|
||||||
|
@ -32,12 +32,12 @@
|
|||||||
} from '$lib/components/shared-components/notification/notification';
|
} from '$lib/components/shared-components/notification/notification';
|
||||||
import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants';
|
import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants';
|
||||||
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||||
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
import PersonEditBirthDateModal from '$lib/modals/PersonEditBirthDateModal.svelte';
|
import PersonEditBirthDateModal from '$lib/modals/PersonEditBirthDateModal.svelte';
|
||||||
import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte';
|
import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte';
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
|
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { preferences } from '$lib/stores/user.store';
|
import { preferences } from '$lib/stores/user.store';
|
||||||
import { websocketEvents } from '$lib/stores/websocket';
|
import { websocketEvents } from '$lib/stores/websocket';
|
||||||
@ -77,9 +77,9 @@
|
|||||||
let numberOfAssets = $state(data.statistics.assets);
|
let numberOfAssets = $state(data.statistics.assets);
|
||||||
let { isViewing: showAssetViewer } = assetViewingStore;
|
let { isViewing: showAssetViewer } = assetViewingStore;
|
||||||
|
|
||||||
const assetStore = new AssetStore();
|
const timelineManager = new TimelineManager();
|
||||||
$effect(() => void assetStore.updateOptions({ visibility: AssetVisibility.Timeline, personId: data.person.id }));
|
$effect(() => void timelineManager.updateOptions({ visibility: AssetVisibility.Timeline, personId: data.person.id }));
|
||||||
onDestroy(() => assetStore.destroy());
|
onDestroy(() => timelineManager.destroy());
|
||||||
|
|
||||||
const assetInteraction = new AssetInteraction();
|
const assetInteraction = new AssetInteraction();
|
||||||
|
|
||||||
@ -152,7 +152,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleUnmerge = () => {
|
const handleUnmerge = () => {
|
||||||
assetStore.removeAssets(assetInteraction.selectedAssets.map((a) => a.id));
|
timelineManager.removeAssets(assetInteraction.selectedAssets.map((a) => a.id));
|
||||||
assetInteraction.clearMultiselect();
|
assetInteraction.clearMultiselect();
|
||||||
viewMode = PersonPageViewMode.VIEW_ASSETS;
|
viewMode = PersonPageViewMode.VIEW_ASSETS;
|
||||||
};
|
};
|
||||||
@ -348,12 +348,12 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteAssets = async (assetIds: string[]) => {
|
const handleDeleteAssets = async (assetIds: string[]) => {
|
||||||
assetStore.removeAssets(assetIds);
|
timelineManager.removeAssets(assetIds);
|
||||||
await updateAssetCount();
|
await updateAssetCount();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUndoDeleteAssets = async (assets: TimelineAsset[]) => {
|
const handleUndoDeleteAssets = async (assets: TimelineAsset[]) => {
|
||||||
assetStore.addAssets(assets);
|
timelineManager.addAssets(assets);
|
||||||
await updateAssetCount();
|
await updateAssetCount();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -368,7 +368,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleSetVisibility = (assetIds: string[]) => {
|
const handleSetVisibility = (assetIds: string[]) => {
|
||||||
assetStore.removeAssets(assetIds);
|
timelineManager.removeAssets(assetIds);
|
||||||
assetInteraction.clearMultiselect();
|
assetInteraction.clearMultiselect();
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@ -386,7 +386,7 @@
|
|||||||
<AssetGrid
|
<AssetGrid
|
||||||
enableRouting={true}
|
enableRouting={true}
|
||||||
{person}
|
{person}
|
||||||
{assetStore}
|
{timelineManager}
|
||||||
{assetInteraction}
|
{assetInteraction}
|
||||||
isSelectionMode={viewMode === PersonPageViewMode.SELECT_PERSON}
|
isSelectionMode={viewMode === PersonPageViewMode.SELECT_PERSON}
|
||||||
singleSelect={viewMode === PersonPageViewMode.SELECT_PERSON}
|
singleSelect={viewMode === PersonPageViewMode.SELECT_PERSON}
|
||||||
@ -506,7 +506,7 @@
|
|||||||
clearSelect={() => assetInteraction.clearMultiselect()}
|
clearSelect={() => assetInteraction.clearMultiselect()}
|
||||||
>
|
>
|
||||||
<CreateSharedLink />
|
<CreateSharedLink />
|
||||||
<SelectAllAssets {assetStore} {assetInteraction} />
|
<SelectAllAssets {timelineManager} {assetInteraction} />
|
||||||
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||||
<AddToAlbum />
|
<AddToAlbum />
|
||||||
<AddToAlbum shared />
|
<AddToAlbum shared />
|
||||||
@ -514,7 +514,7 @@
|
|||||||
<FavoriteAction
|
<FavoriteAction
|
||||||
removeFavorite={assetInteraction.isAllFavorite}
|
removeFavorite={assetInteraction.isAllFavorite}
|
||||||
onFavorite={(ids, isFavorite) =>
|
onFavorite={(ids, isFavorite) =>
|
||||||
assetStore.updateAssetOperation(ids, (asset) => {
|
timelineManager.updateAssetOperation(ids, (asset) => {
|
||||||
asset.isFavorite = isFavorite;
|
asset.isFavorite = isFavorite;
|
||||||
return { remove: false };
|
return { remove: false };
|
||||||
})}
|
})}
|
||||||
@ -532,7 +532,7 @@
|
|||||||
<ArchiveAction
|
<ArchiveAction
|
||||||
menuItem
|
menuItem
|
||||||
unarchive={assetInteraction.isAllArchived}
|
unarchive={assetInteraction.isAllArchived}
|
||||||
onArchive={(assetIds) => assetStore.removeAssets(assetIds)}
|
onArchive={(assetIds) => timelineManager.removeAssets(assetIds)}
|
||||||
/>
|
/>
|
||||||
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
||||||
<TagAction menuItem />
|
<TagAction menuItem />
|
||||||
|
@ -22,9 +22,9 @@
|
|||||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
|
|
||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import { preferences, user } from '$lib/stores/user.store';
|
import { preferences, user } from '$lib/stores/user.store';
|
||||||
import {
|
import {
|
||||||
@ -41,9 +41,9 @@
|
|||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
let { isViewing: showAssetViewer } = assetViewingStore;
|
let { isViewing: showAssetViewer } = assetViewingStore;
|
||||||
const assetStore = new AssetStore();
|
const timelineManager = new TimelineManager();
|
||||||
void assetStore.updateOptions({ visibility: AssetVisibility.Timeline, withStacked: true, withPartners: true });
|
void timelineManager.updateOptions({ visibility: AssetVisibility.Timeline, withStacked: true, withPartners: true });
|
||||||
onDestroy(() => assetStore.destroy());
|
onDestroy(() => timelineManager.destroy());
|
||||||
|
|
||||||
const assetInteraction = new AssetInteraction();
|
const assetInteraction = new AssetInteraction();
|
||||||
|
|
||||||
@ -69,17 +69,17 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleLink: OnLink = ({ still, motion }) => {
|
const handleLink: OnLink = ({ still, motion }) => {
|
||||||
assetStore.removeAssets([motion.id]);
|
timelineManager.removeAssets([motion.id]);
|
||||||
assetStore.updateAssets([still]);
|
timelineManager.updateAssets([still]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUnlink: OnUnlink = ({ still, motion }) => {
|
const handleUnlink: OnUnlink = ({ still, motion }) => {
|
||||||
assetStore.addAssets([motion]);
|
timelineManager.addAssets([motion]);
|
||||||
assetStore.updateAssets([still]);
|
timelineManager.updateAssets([still]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSetVisibility = (assetIds: string[]) => {
|
const handleSetVisibility = (assetIds: string[]) => {
|
||||||
assetStore.removeAssets(assetIds);
|
timelineManager.removeAssets(assetIds);
|
||||||
assetInteraction.clearMultiselect();
|
assetInteraction.clearMultiselect();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -91,7 +91,7 @@
|
|||||||
<UserPageLayout hideNavbar={assetInteraction.selectionActive} showUploadButton scrollbar={false}>
|
<UserPageLayout hideNavbar={assetInteraction.selectionActive} showUploadButton scrollbar={false}>
|
||||||
<AssetGrid
|
<AssetGrid
|
||||||
enableRouting={true}
|
enableRouting={true}
|
||||||
{assetStore}
|
{timelineManager}
|
||||||
{assetInteraction}
|
{assetInteraction}
|
||||||
removeAction={AssetAction.ARCHIVE}
|
removeAction={AssetAction.ARCHIVE}
|
||||||
onEscape={handleEscape}
|
onEscape={handleEscape}
|
||||||
@ -113,7 +113,7 @@
|
|||||||
clearSelect={() => assetInteraction.clearMultiselect()}
|
clearSelect={() => assetInteraction.clearMultiselect()}
|
||||||
>
|
>
|
||||||
<CreateSharedLink />
|
<CreateSharedLink />
|
||||||
<SelectAllAssets {assetStore} {assetInteraction} />
|
<SelectAllAssets {timelineManager} {assetInteraction} />
|
||||||
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||||
<AddToAlbum />
|
<AddToAlbum />
|
||||||
<AddToAlbum shared />
|
<AddToAlbum shared />
|
||||||
@ -121,7 +121,7 @@
|
|||||||
<FavoriteAction
|
<FavoriteAction
|
||||||
removeFavorite={assetInteraction.isAllFavorite}
|
removeFavorite={assetInteraction.isAllFavorite}
|
||||||
onFavorite={(ids, isFavorite) =>
|
onFavorite={(ids, isFavorite) =>
|
||||||
assetStore.updateAssetOperation(ids, (asset) => {
|
timelineManager.updateAssetOperation(ids, (asset) => {
|
||||||
asset.isFavorite = isFavorite;
|
asset.isFavorite = isFavorite;
|
||||||
return { remove: false };
|
return { remove: false };
|
||||||
})}
|
})}
|
||||||
@ -131,8 +131,8 @@
|
|||||||
{#if assetInteraction.selectedAssets.length > 1 || isAssetStackSelected}
|
{#if assetInteraction.selectedAssets.length > 1 || isAssetStackSelected}
|
||||||
<StackAction
|
<StackAction
|
||||||
unstack={isAssetStackSelected}
|
unstack={isAssetStackSelected}
|
||||||
onStack={(result) => updateStackedAssetInTimeline(assetStore, result)}
|
onStack={(result) => updateStackedAssetInTimeline(timelineManager, result)}
|
||||||
onUnstack={(assets) => updateUnstackedAssetInTimeline(assetStore, assets)}
|
onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager, assets)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{#if isLinkActionAvailable}
|
{#if isLinkActionAvailable}
|
||||||
@ -146,14 +146,14 @@
|
|||||||
<ChangeDate menuItem />
|
<ChangeDate menuItem />
|
||||||
<ChangeDescription menuItem />
|
<ChangeDescription menuItem />
|
||||||
<ChangeLocation menuItem />
|
<ChangeLocation menuItem />
|
||||||
<ArchiveAction menuItem onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
|
<ArchiveAction menuItem onArchive={(assetIds) => timelineManager.removeAssets(assetIds)} />
|
||||||
{#if $preferences.tags.enabled}
|
{#if $preferences.tags.enabled}
|
||||||
<TagAction menuItem />
|
<TagAction menuItem />
|
||||||
{/if}
|
{/if}
|
||||||
<DeleteAssets
|
<DeleteAssets
|
||||||
menuItem
|
menuItem
|
||||||
onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)}
|
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
|
||||||
onUndoDelete={(assets) => assetStore.addAssets(assets)}
|
onUndoDelete={(assets) => timelineManager.addAssets(assets)}
|
||||||
/>
|
/>
|
||||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||||
<hr />
|
<hr />
|
||||||
|
@ -23,10 +23,10 @@
|
|||||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||||
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
|
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
|
||||||
import { AppRoute, QueryParameter } from '$lib/constants';
|
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 { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
|
|
||||||
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
|
||||||
import { lang, locale } from '$lib/stores/preferences.store';
|
import { lang, locale } from '$lib/stores/preferences.store';
|
||||||
import { featureFlags } from '$lib/stores/server-config.store';
|
import { featureFlags } from '$lib/stores/server-config.store';
|
||||||
import { preferences } from '$lib/stores/user.store';
|
import { preferences } from '$lib/stores/user.store';
|
||||||
@ -81,7 +81,7 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
let assetStore = new AssetStore();
|
let timelineManager = new TimelineManager();
|
||||||
|
|
||||||
const onEscape = () => {
|
const onEscape = () => {
|
||||||
if ($showAssetViewer) {
|
if ($showAssetViewer) {
|
||||||
@ -131,7 +131,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSetVisibility = (assetIds: string[]) => {
|
const handleSetVisibility = (assetIds: string[]) => {
|
||||||
assetStore.removeAssets(assetIds);
|
timelineManager.removeAssets(assetIds);
|
||||||
assetInteraction.clearMultiselect();
|
assetInteraction.clearMultiselect();
|
||||||
onAssetDelete(assetIds);
|
onAssetDelete(assetIds);
|
||||||
};
|
};
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
import Sidebar from '$lib/components/sidebar/sidebar.svelte';
|
import Sidebar from '$lib/components/sidebar/sidebar.svelte';
|
||||||
import { AppRoute, AssetAction, QueryParameter, SettingInputFieldType } from '$lib/constants';
|
import { AppRoute, AssetAction, QueryParameter, SettingInputFieldType } from '$lib/constants';
|
||||||
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||||
import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { joinPaths, TreeNode } from '$lib/utils/tree-utils';
|
import { joinPaths, TreeNode } from '$lib/utils/tree-utils';
|
||||||
import { deleteTag, getAllTags, updateTag, upsertTags, type TagResponseDto } from '@immich/sdk';
|
import { deleteTag, getAllTags, updateTag, upsertTags, type TagResponseDto } from '@immich/sdk';
|
||||||
@ -32,9 +32,9 @@
|
|||||||
|
|
||||||
const assetInteraction = new AssetInteraction();
|
const assetInteraction = new AssetInteraction();
|
||||||
|
|
||||||
const assetStore = new AssetStore();
|
const timelineManager = new TimelineManager();
|
||||||
$effect(() => void assetStore.updateOptions({ deferInit: !tag, tagId: tag.id }));
|
$effect(() => void timelineManager.updateOptions({ deferInit: !tag, tagId: tag?.id }));
|
||||||
onDestroy(() => assetStore.destroy());
|
onDestroy(() => timelineManager.destroy());
|
||||||
|
|
||||||
let tags = $derived<TagResponseDto[]>(data.tags);
|
let tags = $derived<TagResponseDto[]>(data.tags);
|
||||||
const tree = $derived(TreeNode.fromTags(tags));
|
const tree = $derived(TreeNode.fromTags(tags));
|
||||||
@ -157,7 +157,7 @@
|
|||||||
|
|
||||||
<section class="mt-2 h-[calc(100%-(--spacing(20)))] overflow-auto immich-scrollbar">
|
<section class="mt-2 h-[calc(100%-(--spacing(20)))] overflow-auto immich-scrollbar">
|
||||||
{#if tag.hasAssets}
|
{#if tag.hasAssets}
|
||||||
<AssetGrid enableRouting={true} {assetStore} {assetInteraction} removeAction={AssetAction.UNARCHIVE}>
|
<AssetGrid enableRouting={true} {timelineManager} {assetInteraction} removeAction={AssetAction.UNARCHIVE}>
|
||||||
{#snippet empty()}
|
{#snippet empty()}
|
||||||
<TreeItemThumbnails items={tag.children} icon={mdiTag} onClick={handleNavigation} />
|
<TreeItemThumbnails items={tag.children} icon={mdiTag} onClick={handleNavigation} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
@ -14,8 +14,8 @@
|
|||||||
} from '$lib/components/shared-components/notification/notification';
|
} from '$lib/components/shared-components/notification/notification';
|
||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } from '$lib/constants';
|
||||||
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||||
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
|
|
||||||
import { featureFlags, serverConfig } from '$lib/stores/server-config.store';
|
import { featureFlags, serverConfig } from '$lib/stores/server-config.store';
|
||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
@ -36,9 +36,9 @@
|
|||||||
handlePromiseError(goto(AppRoute.PHOTOS));
|
handlePromiseError(goto(AppRoute.PHOTOS));
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetStore = new AssetStore();
|
const timelineManager = new TimelineManager();
|
||||||
void assetStore.updateOptions({ isTrashed: true });
|
void timelineManager.updateOptions({ isTrashed: true });
|
||||||
onDestroy(() => assetStore.destroy());
|
onDestroy(() => timelineManager.destroy());
|
||||||
|
|
||||||
const assetInteraction = new AssetInteraction();
|
const assetInteraction = new AssetInteraction();
|
||||||
|
|
||||||
@ -75,8 +75,8 @@
|
|||||||
// reset asset grid (TODO fix in asset store that it should reset when it is empty)
|
// reset asset grid (TODO fix in asset store that it should reset when it is empty)
|
||||||
// note - this is still a problem, but updateOptions with the same value will not
|
// note - this is still a problem, but updateOptions with the same value will not
|
||||||
// do anything, so need to flip it for it to reload/reinit
|
// do anything, so need to flip it for it to reload/reinit
|
||||||
// await assetStore.updateOptions({ deferInit: true, isTrashed: true });
|
// await timelineManager.updateOptions({ deferInit: true, isTrashed: true });
|
||||||
// await assetStore.updateOptions({ deferInit: false, isTrashed: true });
|
// await timelineManager.updateOptions({ deferInit: false, isTrashed: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.unable_to_restore_trash'));
|
handleError(error, $t('errors.unable_to_restore_trash'));
|
||||||
}
|
}
|
||||||
@ -117,7 +117,7 @@
|
|||||||
</HStack>
|
</HStack>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<AssetGrid enableRouting={true} {assetStore} {assetInteraction} onEscape={handleEscape}>
|
<AssetGrid enableRouting={true} {timelineManager} {assetInteraction} onEscape={handleEscape}>
|
||||||
<p class="font-medium text-gray-500/60 dark:text-gray-300/60 p-4">
|
<p class="font-medium text-gray-500/60 dark:text-gray-300/60 p-4">
|
||||||
{$t('trashed_items_will_be_permanently_deleted_after', { values: { days: $serverConfig.trashDays } })}
|
{$t('trashed_items_will_be_permanently_deleted_after', { values: { days: $serverConfig.trashDays } })}
|
||||||
</p>
|
</p>
|
||||||
@ -133,8 +133,8 @@
|
|||||||
assets={assetInteraction.selectedAssets}
|
assets={assetInteraction.selectedAssets}
|
||||||
clearSelect={() => assetInteraction.clearMultiselect()}
|
clearSelect={() => assetInteraction.clearMultiselect()}
|
||||||
>
|
>
|
||||||
<SelectAllAssets {assetStore} {assetInteraction} />
|
<SelectAllAssets {timelineManager} {assetInteraction} />
|
||||||
<DeleteAssets force onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
|
<DeleteAssets force onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)} />
|
||||||
<RestoreAssets onRestore={(assetIds) => assetStore.removeAssets(assetIds)} />
|
<RestoreAssets onRestore={(assetIds) => timelineManager.removeAssets(assetIds)} />
|
||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
{/if}
|
{/if}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user