mirror of
https://github.com/immich-app/immich.git
synced 2025-05-24 01:12:58 -04:00
feat: timeline performance (#16446)
* Squash - feature complete * remove need to init assetstore * More optimizations. No need to init. Fix tests * lint * add missing selector for e2e * e2e selectors again * Update: fully reactive store, some transitions, bugfixes * merge fallout * Test fallout * safari quirk * security * lint * lint * Bug fixes * lint/format * accidental commit * lock * null check, more throttle * revert long duration * Fix intersection bounds * Fix bugs in intersection calculation * lint, tweak scrubber ui a tiny bit * bugfix - deselecting asset doesnt work * fix not loading bucket, scroll off-by-1 error, jsdoc, naming
This commit is contained in:
parent
dd263b010c
commit
e96ffd43e7
@ -45,7 +45,7 @@ test.describe('Shared Links', () => {
|
|||||||
await page.goto(`/share/${sharedLink.key}`);
|
await page.goto(`/share/${sharedLink.key}`);
|
||||||
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
|
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
|
||||||
await page.locator(`[data-asset-id="${asset.id}"]`).hover();
|
await page.locator(`[data-asset-id="${asset.id}"]`).hover();
|
||||||
await page.waitForSelector('#asset-group-by-date svg');
|
await page.waitForSelector('[data-group] svg');
|
||||||
await page.getByRole('checkbox').click();
|
await page.getByRole('checkbox').click();
|
||||||
await page.getByRole('button', { name: 'Download' }).click();
|
await page.getByRole('button', { name: 'Download' }).click();
|
||||||
await page.getByText('DOWNLOADING', { exact: true }).waitFor();
|
await page.getByText('DOWNLOADING', { exact: true }).waitFor();
|
||||||
|
10
web/package-lock.json
generated
10
web/package-lock.json
generated
@ -70,8 +70,8 @@
|
|||||||
"prettier-plugin-sort-json": "^4.1.1",
|
"prettier-plugin-sort-json": "^4.1.1",
|
||||||
"prettier-plugin-svelte": "^3.3.3",
|
"prettier-plugin-svelte": "^3.3.3",
|
||||||
"rollup-plugin-visualizer": "^5.14.0",
|
"rollup-plugin-visualizer": "^5.14.0",
|
||||||
"svelte": "^5.17.4",
|
"svelte": "^5.22.6",
|
||||||
"svelte-check": "^4.1.4",
|
"svelte-check": "^4.1.5",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"tslib": "^2.6.2",
|
"tslib": "^2.6.2",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
@ -9579,9 +9579,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.2.1",
|
"version": "6.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.0.tgz",
|
||||||
"integrity": "sha512-n2GnqDb6XPhlt9B8olZPrgMD/es/Nd1RdChF6CBD/fHW6pUyUTt2sQW2fPRX5GiD9XEa6+8A6A4f2vT6pSsE7Q==",
|
"integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
"build:stats": "BUILD_STATS=true vite build",
|
"build:stats": "BUILD_STATS=true vite build",
|
||||||
"package": "svelte-kit package",
|
"package": "svelte-kit package",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check:svelte": "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'reactive_declaration_non_reactive_property:ignore'",
|
"check:svelte": "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'reactive_declaration_non_reactive_property:ignore' --ignore src/lib/components/photos-page/asset-grid.svelte",
|
||||||
"check:typescript": "tsc --noEmit",
|
"check:typescript": "tsc --noEmit",
|
||||||
"check:watch": "npm run check:svelte -- --watch",
|
"check:watch": "npm run check:svelte -- --watch",
|
||||||
"check:code": "npm run format && npm run lint && npm run check:svelte && npm run check:typescript",
|
"check:code": "npm run format && npm run lint && npm run check:svelte && npm run check:typescript",
|
||||||
@ -56,8 +56,8 @@
|
|||||||
"prettier-plugin-sort-json": "^4.1.1",
|
"prettier-plugin-sort-json": "^4.1.1",
|
||||||
"prettier-plugin-svelte": "^3.3.3",
|
"prettier-plugin-svelte": "^3.3.3",
|
||||||
"rollup-plugin-visualizer": "^5.14.0",
|
"rollup-plugin-visualizer": "^5.14.0",
|
||||||
"svelte": "^5.17.4",
|
"svelte": "^5.22.6",
|
||||||
"svelte-check": "^4.1.4",
|
"svelte-check": "^4.1.5",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"tslib": "^2.6.2",
|
"tslib": "^2.6.2",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
|
@ -135,32 +135,13 @@ input:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* width */
|
/* width */
|
||||||
.immich-scrollbar::-webkit-scrollbar {
|
.immich-scrollbar {
|
||||||
width: 8px;
|
scrollbar-width: thin;
|
||||||
}
|
|
||||||
|
|
||||||
/* Track */
|
|
||||||
.immich-scrollbar::-webkit-scrollbar-track {
|
|
||||||
background: #f1f1f1;
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Handle */
|
|
||||||
.immich-scrollbar::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(85, 86, 87, 0.408);
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Handle on hover */
|
|
||||||
.immich-scrollbar::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #4250afad;
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hidden scrollbar */
|
/* Hidden scrollbar */
|
||||||
/* width */
|
/* width */
|
||||||
.scrollbar-hidden::-webkit-scrollbar {
|
.scrollbar-hidden {
|
||||||
display: none;
|
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -13,6 +13,7 @@ type OnIntersectCallback = (entryOrElement: IntersectionObserverEntry | HTMLElem
|
|||||||
type OnSeparateCallback = (element: HTMLElement) => unknown;
|
type OnSeparateCallback = (element: HTMLElement) => unknown;
|
||||||
type IntersectionObserverActionProperties = {
|
type IntersectionObserverActionProperties = {
|
||||||
key?: string;
|
key?: string;
|
||||||
|
disabled?: boolean;
|
||||||
/** Function to execute when the element leaves the viewport */
|
/** Function to execute when the element leaves the viewport */
|
||||||
onSeparate?: OnSeparateCallback;
|
onSeparate?: OnSeparateCallback;
|
||||||
/** Function to execute when the element enters the viewport */
|
/** Function to execute when the element enters the viewport */
|
||||||
@ -83,9 +84,16 @@ const observe = (key: HTMLElement | string, target: HTMLElement, properties: Int
|
|||||||
};
|
};
|
||||||
|
|
||||||
function configure(key: HTMLElement | string, element: HTMLElement, properties: IntersectionObserverActionProperties) {
|
function configure(key: HTMLElement | string, element: HTMLElement, properties: IntersectionObserverActionProperties) {
|
||||||
|
if (properties.disabled) {
|
||||||
|
const config = elementToConfig.get(key);
|
||||||
|
const { observer } = config || {};
|
||||||
|
observer?.unobserve(element);
|
||||||
|
elementToConfig.delete(key);
|
||||||
|
} else {
|
||||||
elementToConfig.set(key, properties);
|
elementToConfig.set(key, properties);
|
||||||
observe(key, element, properties);
|
observe(key, element, properties);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function _intersectionObserver(
|
function _intersectionObserver(
|
||||||
key: HTMLElement | string,
|
key: HTMLElement | string,
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
type OnResizeCallback = (resizeEvent: { target: HTMLElement; width: number; height: number }) => void;
|
export type OnResizeCallback = (resizeEvent: { target: HTMLElement; width: number; height: number }) => void;
|
||||||
|
|
||||||
let observer: ResizeObserver;
|
let observer: ResizeObserver;
|
||||||
let callbacks: WeakMap<HTMLElement, OnResizeCallback>;
|
let callbacks: WeakMap<HTMLElement, OnResizeCallback>;
|
||||||
|
@ -33,7 +33,10 @@
|
|||||||
|
|
||||||
let { isViewing: showAssetViewer } = assetViewingStore;
|
let { isViewing: showAssetViewer } = assetViewingStore;
|
||||||
|
|
||||||
const assetStore = new AssetStore({ albumId: album.id, order: album.order });
|
const assetStore = new AssetStore();
|
||||||
|
$effect(() => void assetStore.updateOptions({ albumId: album.id, order: album.order }));
|
||||||
|
onDestroy(() => assetStore.destroy());
|
||||||
|
|
||||||
const assetInteraction = new AssetInteraction();
|
const assetInteraction = new AssetInteraction();
|
||||||
|
|
||||||
dragAndDropFilesStore.subscribe((value) => {
|
dragAndDropFilesStore.subscribe((value) => {
|
||||||
@ -42,9 +45,6 @@
|
|||||||
dragAndDropFilesStore.set({ isDragging: false, files: [] });
|
dragAndDropFilesStore.set({ isDragging: false, files: [] });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
onDestroy(() => {
|
|
||||||
assetStore.destroy();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window
|
<svelte:window
|
||||||
|
@ -64,7 +64,7 @@
|
|||||||
onClose: (dto: { asset: AssetResponseDto }) => void;
|
onClose: (dto: { asset: AssetResponseDto }) => void;
|
||||||
onNext: () => Promise<HasAsset>;
|
onNext: () => Promise<HasAsset>;
|
||||||
onPrevious: () => Promise<HasAsset>;
|
onPrevious: () => Promise<HasAsset>;
|
||||||
onRandom: () => Promise<AssetResponseDto | null>;
|
onRandom: () => Promise<AssetResponseDto | undefined>;
|
||||||
copyImage?: () => Promise<void>;
|
copyImage?: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,7 +4,6 @@
|
|||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import { TUNABLES } from '$lib/utils/tunables';
|
import { TUNABLES } from '$lib/utils/tunables';
|
||||||
import { mdiEyeOffOutline } from '@mdi/js';
|
import { mdiEyeOffOutline } from '@mdi/js';
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -37,7 +36,6 @@
|
|||||||
circle = false,
|
circle = false,
|
||||||
hidden = false,
|
hidden = false,
|
||||||
border = false,
|
border = false,
|
||||||
preload = true,
|
|
||||||
hiddenIconClass = 'text-white',
|
hiddenIconClass = 'text-white',
|
||||||
onComplete = undefined,
|
onComplete = undefined,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@ -49,8 +47,6 @@
|
|||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
let errored = $state(false);
|
let errored = $state(false);
|
||||||
|
|
||||||
let img = $state<HTMLImageElement>();
|
|
||||||
|
|
||||||
const setLoaded = () => {
|
const setLoaded = () => {
|
||||||
loaded = true;
|
loaded = true;
|
||||||
onComplete?.();
|
onComplete?.();
|
||||||
@ -59,11 +55,13 @@
|
|||||||
errored = true;
|
errored = true;
|
||||||
onComplete?.();
|
onComplete?.();
|
||||||
};
|
};
|
||||||
onMount(() => {
|
|
||||||
if (img?.complete) {
|
function mount(elem: HTMLImageElement) {
|
||||||
setLoaded();
|
if (elem.complete) {
|
||||||
|
loaded = true;
|
||||||
|
onComplete?.();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
let optionalClasses = $derived(
|
let optionalClasses = $derived(
|
||||||
[
|
[
|
||||||
@ -82,10 +80,9 @@
|
|||||||
<BrokenAsset class={optionalClasses} width={widthStyle} height={heightStyle} />
|
<BrokenAsset class={optionalClasses} width={widthStyle} height={heightStyle} />
|
||||||
{:else}
|
{:else}
|
||||||
<img
|
<img
|
||||||
bind:this={img}
|
use:mount
|
||||||
onload={setLoaded}
|
onload={setLoaded}
|
||||||
onerror={setErrored}
|
onerror={setErrored}
|
||||||
loading={preload ? 'eager' : 'lazy'}
|
|
||||||
style:width={widthStyle}
|
style:width={widthStyle}
|
||||||
style:height={heightStyle}
|
style:height={heightStyle}
|
||||||
style:filter={hidden ? 'grayscale(50%)' : 'none'}
|
style:filter={hidden ? 'grayscale(50%)' : 'none'}
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { intersectionObserver } from '$lib/actions/intersection-observer';
|
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import { ProjectionType } from '$lib/constants';
|
import { ProjectionType } from '$lib/constants';
|
||||||
import { getAssetThumbnailUrl, isSharedLink } from '$lib/utils';
|
import { getAssetThumbnailUrl, isSharedLink } from '$lib/utils';
|
||||||
@ -22,19 +21,11 @@
|
|||||||
import ImageThumbnail from './image-thumbnail.svelte';
|
import ImageThumbnail from './image-thumbnail.svelte';
|
||||||
import VideoThumbnail from './video-thumbnail.svelte';
|
import VideoThumbnail from './video-thumbnail.svelte';
|
||||||
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
|
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
|
||||||
import { AssetStore } from '$lib/stores/assets-store.svelte';
|
|
||||||
|
|
||||||
import type { DateGroup } from '$lib/utils/timeline-util';
|
|
||||||
|
|
||||||
import { generateId } from '$lib/utils/generate-id';
|
|
||||||
import { onDestroy } from 'svelte';
|
|
||||||
import { TUNABLES } from '$lib/utils/tunables';
|
import { TUNABLES } from '$lib/utils/tunables';
|
||||||
import { thumbhash } from '$lib/actions/thumbhash';
|
import { thumbhash } from '$lib/actions/thumbhash';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
asset: AssetResponseDto;
|
asset: AssetResponseDto;
|
||||||
dateGroup?: DateGroup | undefined;
|
|
||||||
assetStore?: AssetStore | undefined;
|
|
||||||
groupIndex?: number;
|
groupIndex?: number;
|
||||||
thumbnailSize?: number | undefined;
|
thumbnailSize?: number | undefined;
|
||||||
thumbnailWidth?: number | undefined;
|
thumbnailWidth?: number | undefined;
|
||||||
@ -47,29 +38,16 @@
|
|||||||
showArchiveIcon?: boolean;
|
showArchiveIcon?: boolean;
|
||||||
showStackedIcon?: boolean;
|
showStackedIcon?: boolean;
|
||||||
disableMouseOver?: boolean;
|
disableMouseOver?: boolean;
|
||||||
intersectionConfig?: {
|
|
||||||
root?: HTMLElement;
|
|
||||||
bottom?: string;
|
|
||||||
top?: string;
|
|
||||||
left?: string;
|
|
||||||
priority?: number;
|
|
||||||
disabled?: boolean;
|
|
||||||
};
|
|
||||||
retrieveElement?: boolean;
|
|
||||||
onIntersected?: (() => void) | undefined;
|
|
||||||
onClick?: ((asset: AssetResponseDto) => void) | undefined;
|
onClick?: ((asset: AssetResponseDto) => void) | undefined;
|
||||||
onRetrieveElement?: ((elment: HTMLElement) => void) | undefined;
|
|
||||||
onSelect?: ((asset: AssetResponseDto) => void) | undefined;
|
onSelect?: ((asset: AssetResponseDto) => void) | undefined;
|
||||||
onMouseEvent?: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined;
|
onMouseEvent?: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined;
|
||||||
handleFocus?: (() => void) | undefined;
|
handleFocus?: (() => void) | undefined;
|
||||||
class?: string;
|
class?: string;
|
||||||
overrideDisplayForTest?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
asset,
|
asset = $bindable(),
|
||||||
dateGroup = undefined,
|
|
||||||
assetStore = undefined,
|
|
||||||
groupIndex = 0,
|
groupIndex = 0,
|
||||||
thumbnailSize = undefined,
|
thumbnailSize = undefined,
|
||||||
thumbnailWidth = undefined,
|
thumbnailWidth = undefined,
|
||||||
@ -82,42 +60,21 @@
|
|||||||
showArchiveIcon = false,
|
showArchiveIcon = false,
|
||||||
showStackedIcon = true,
|
showStackedIcon = true,
|
||||||
disableMouseOver = false,
|
disableMouseOver = false,
|
||||||
intersectionConfig = {},
|
|
||||||
retrieveElement = false,
|
|
||||||
onIntersected = undefined,
|
|
||||||
onClick = undefined,
|
onClick = undefined,
|
||||||
onRetrieveElement = undefined,
|
|
||||||
onSelect = undefined,
|
onSelect = undefined,
|
||||||
onMouseEvent = undefined,
|
onMouseEvent = undefined,
|
||||||
handleFocus = undefined,
|
handleFocus = undefined,
|
||||||
class: className = '',
|
class: className = '',
|
||||||
overrideDisplayForTest = false,
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let {
|
let {
|
||||||
IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION },
|
IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION },
|
||||||
} = TUNABLES;
|
} = TUNABLES;
|
||||||
|
|
||||||
const componentId = generateId();
|
|
||||||
let element: HTMLElement | undefined = $state();
|
|
||||||
let focussableElement: HTMLElement | undefined = $state();
|
let focussableElement: HTMLElement | undefined = $state();
|
||||||
let mouseOver = $state(false);
|
let mouseOver = $state(false);
|
||||||
let intersecting = $state(false);
|
|
||||||
let lastRetrievedElement: HTMLElement | undefined = $state();
|
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!retrieveElement) {
|
|
||||||
lastRetrievedElement = undefined;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$effect(() => {
|
|
||||||
if (retrieveElement && element && lastRetrievedElement !== element) {
|
|
||||||
lastRetrievedElement = element;
|
|
||||||
onRetrieveElement?.(element);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (focussed && document.activeElement !== focussableElement) {
|
if (focussed && document.activeElement !== focussableElement) {
|
||||||
focussableElement?.focus();
|
focussableElement?.focus();
|
||||||
@ -126,13 +83,12 @@
|
|||||||
|
|
||||||
let width = $derived(thumbnailSize || thumbnailWidth || 235);
|
let width = $derived(thumbnailSize || thumbnailWidth || 235);
|
||||||
let height = $derived(thumbnailSize || thumbnailHeight || 235);
|
let height = $derived(thumbnailSize || thumbnailHeight || 235);
|
||||||
let display = $derived(intersecting);
|
|
||||||
|
|
||||||
const onIconClickedHandler = (e?: MouseEvent) => {
|
const onIconClickedHandler = (e?: MouseEvent) => {
|
||||||
e?.stopPropagation();
|
e?.stopPropagation();
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
if (!disabled) {
|
if (!disabled) {
|
||||||
onSelect?.(asset);
|
onSelect?.($state.snapshot(asset));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -141,7 +97,7 @@
|
|||||||
onIconClickedHandler();
|
onIconClickedHandler();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onClick?.(asset);
|
onClick?.($state.snapshot(asset));
|
||||||
};
|
};
|
||||||
const handleClick = (e: MouseEvent) => {
|
const handleClick = (e: MouseEvent) => {
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
@ -152,68 +108,18 @@
|
|||||||
callClickHandlers();
|
callClickHandlers();
|
||||||
};
|
};
|
||||||
|
|
||||||
const _onMouseEnter = () => {
|
const onMouseEnter = () => {
|
||||||
mouseOver = true;
|
mouseOver = true;
|
||||||
onMouseEvent?.({ isMouseOver: true, selectedGroupIndex: groupIndex });
|
onMouseEvent?.({ isMouseOver: true, selectedGroupIndex: groupIndex });
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMouseEnter = () => {
|
|
||||||
if (dateGroup && assetStore) {
|
|
||||||
assetStore.taskManager.queueScrollSensitiveTask({ componentId, task: () => _onMouseEnter() });
|
|
||||||
} else {
|
|
||||||
_onMouseEnter();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onMouseLeave = () => {
|
const onMouseLeave = () => {
|
||||||
if (dateGroup && assetStore) {
|
|
||||||
assetStore.taskManager.queueScrollSensitiveTask({ componentId, task: () => (mouseOver = false) });
|
|
||||||
} else {
|
|
||||||
mouseOver = false;
|
mouseOver = false;
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const _onIntersect = () => {
|
|
||||||
intersecting = true;
|
|
||||||
onIntersected?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onIntersect = () => {
|
|
||||||
if (intersecting === true) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (dateGroup && assetStore) {
|
|
||||||
assetStore.taskManager.intersectedThumbnail(componentId, dateGroup, asset, () => void _onIntersect());
|
|
||||||
} else {
|
|
||||||
void _onIntersect();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSeparate = () => {
|
|
||||||
if (intersecting === false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (dateGroup && assetStore) {
|
|
||||||
assetStore.taskManager.separatedThumbnail(componentId, dateGroup, asset, () => (intersecting = false));
|
|
||||||
} else {
|
|
||||||
intersecting = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
assetStore?.taskManager.removeAllTasksForComponent(componentId);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={element}
|
|
||||||
use:intersectionObserver={{
|
|
||||||
...intersectionConfig,
|
|
||||||
onIntersect,
|
|
||||||
onSeparate,
|
|
||||||
}}
|
|
||||||
data-asset={asset.id}
|
data-asset={asset.id}
|
||||||
data-int={intersecting}
|
|
||||||
style:width="{width}px"
|
style:width="{width}px"
|
||||||
style:height="{height}px"
|
style:height="{height}px"
|
||||||
class="focus-visible:outline-none flex overflow-hidden {disabled
|
class="focus-visible:outline-none flex overflow-hidden {disabled
|
||||||
@ -230,11 +136,12 @@
|
|||||||
></canvas>
|
></canvas>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if display || overrideDisplayForTest}
|
|
||||||
<!-- svelte queries for all links on afterNavigate, leading to performance problems in asset-grid which updates
|
<!-- svelte queries for all links on afterNavigate, leading to performance problems in asset-grid which updates
|
||||||
the navigation url on scroll. Replace this with button for now. -->
|
the navigation url on scroll. Replace this with button for now. -->
|
||||||
<div
|
<div
|
||||||
class="group"
|
class="group"
|
||||||
|
style:width="{width}px"
|
||||||
|
style:height="{height}px"
|
||||||
class:cursor-not-allowed={disabled}
|
class:cursor-not-allowed={disabled}
|
||||||
class:cursor-pointer={!disabled}
|
class:cursor-pointer={!disabled}
|
||||||
onmouseenter={onMouseEnter}
|
onmouseenter={onMouseEnter}
|
||||||
@ -359,7 +266,6 @@
|
|||||||
{#if asset.type === AssetTypeEnum.Video}
|
{#if asset.type === AssetTypeEnum.Video}
|
||||||
<div class="absolute top-0 h-full w-full">
|
<div class="absolute top-0 h-full w-full">
|
||||||
<VideoThumbnail
|
<VideoThumbnail
|
||||||
{assetStore}
|
|
||||||
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
|
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
|
||||||
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
||||||
curve={selected}
|
curve={selected}
|
||||||
@ -372,7 +278,6 @@
|
|||||||
{#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
|
{#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
|
||||||
<div class="absolute top-0 h-full w-full">
|
<div class="absolute top-0 h-full w-full">
|
||||||
<VideoThumbnail
|
<VideoThumbnail
|
||||||
{assetStore}
|
|
||||||
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
|
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
|
||||||
pauseIcon={mdiMotionPauseOutline}
|
pauseIcon={mdiMotionPauseOutline}
|
||||||
playIcon={mdiMotionPlayOutline}
|
playIcon={mdiMotionPlayOutline}
|
||||||
@ -391,5 +296,4 @@
|
|||||||
></div>
|
></div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
@ -3,12 +3,8 @@
|
|||||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||||
import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js';
|
import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js';
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import { AssetStore } from '$lib/stores/assets-store.svelte';
|
|
||||||
import { generateId } from '$lib/utils/generate-id';
|
|
||||||
import { onDestroy } from 'svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
assetStore?: AssetStore | undefined;
|
|
||||||
url: string;
|
url: string;
|
||||||
durationInSeconds?: number;
|
durationInSeconds?: number;
|
||||||
enablePlayback?: boolean;
|
enablePlayback?: boolean;
|
||||||
@ -20,7 +16,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
assetStore = undefined,
|
|
||||||
url,
|
url,
|
||||||
durationInSeconds = 0,
|
durationInSeconds = 0,
|
||||||
enablePlayback = $bindable(false),
|
enablePlayback = $bindable(false),
|
||||||
@ -31,7 +26,6 @@
|
|||||||
pauseIcon = mdiPauseCircleOutline,
|
pauseIcon = mdiPauseCircleOutline,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const componentId = generateId();
|
|
||||||
let remainingSeconds = $state(durationInSeconds);
|
let remainingSeconds = $state(durationInSeconds);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state(false);
|
let error = $state(false);
|
||||||
@ -49,42 +43,16 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
const onMouseEnter = () => {
|
const onMouseEnter = () => {
|
||||||
if (assetStore) {
|
|
||||||
assetStore.taskManager.queueScrollSensitiveTask({
|
|
||||||
componentId,
|
|
||||||
task: () => {
|
|
||||||
if (playbackOnIconHover) {
|
if (playbackOnIconHover) {
|
||||||
enablePlayback = true;
|
enablePlayback = true;
|
||||||
}
|
}
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (playbackOnIconHover) {
|
|
||||||
enablePlayback = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMouseLeave = () => {
|
const onMouseLeave = () => {
|
||||||
if (assetStore) {
|
|
||||||
assetStore.taskManager.queueScrollSensitiveTask({
|
|
||||||
componentId,
|
|
||||||
task: () => {
|
|
||||||
if (playbackOnIconHover) {
|
if (playbackOnIconHover) {
|
||||||
enablePlayback = false;
|
enablePlayback = false;
|
||||||
}
|
}
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (playbackOnIconHover) {
|
|
||||||
enablePlayback = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
assetStore?.taskManager.removeAllTasksForComponent(componentId);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="absolute right-0 top-0 z-20 flex place-items-center gap-1 text-xs font-medium text-white">
|
<div class="absolute right-0 top-0 z-20 flex place-items-center gap-1 text-xs font-medium text-white">
|
||||||
|
@ -1,56 +1,51 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { intersectionObserver } from '$lib/actions/intersection-observer';
|
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
|
import { AssetBucket } from '$lib/stores/assets-store.svelte';
|
||||||
import { AssetBucket, type AssetStore, type Viewport } from '$lib/stores/assets-store.svelte';
|
|
||||||
import { navigate } from '$lib/utils/navigation';
|
import { navigate } from '$lib/utils/navigation';
|
||||||
import {
|
import { getDateLocaleString } from '$lib/utils/timeline-util';
|
||||||
findTotalOffset,
|
|
||||||
type DateGroup,
|
|
||||||
type ScrollTargetListener,
|
|
||||||
getDateLocaleString,
|
|
||||||
} from '$lib/utils/timeline-util';
|
|
||||||
import type { AssetResponseDto } from '@immich/sdk';
|
import type { AssetResponseDto } from '@immich/sdk';
|
||||||
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
||||||
import { onDestroy } from 'svelte';
|
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
||||||
import { TUNABLES } from '$lib/utils/tunables';
|
|
||||||
import { generateId } from '$lib/utils/generate-id';
|
|
||||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
|
import { scale } from 'svelte/transition';
|
||||||
|
|
||||||
export let element: HTMLElement | undefined = undefined;
|
import { flip } from 'svelte/animate';
|
||||||
export let isSelectionMode = false;
|
|
||||||
export let viewport: Viewport;
|
|
||||||
export let singleSelect = false;
|
|
||||||
export let withStacked = false;
|
|
||||||
export let showArchiveIcon = false;
|
|
||||||
export let assetGridElement: HTMLElement | undefined = undefined;
|
|
||||||
export let renderThumbsAtBottomMargin: string | undefined = undefined;
|
|
||||||
export let renderThumbsAtTopMargin: string | undefined = undefined;
|
|
||||||
export let assetStore: AssetStore;
|
|
||||||
export let bucket: AssetBucket;
|
|
||||||
export let assetInteraction: AssetInteraction;
|
|
||||||
|
|
||||||
export let onScrollTarget: ScrollTargetListener | undefined = undefined;
|
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||||
export let onAssetInGrid: ((asset: AssetResponseDto) => void) | undefined = undefined;
|
|
||||||
export let onSelect: ({ title, assets }: { title: string; assets: AssetResponseDto[] }) => void;
|
|
||||||
export let onSelectAssets: (asset: AssetResponseDto) => void;
|
|
||||||
export let onSelectAssetCandidates: (asset: AssetResponseDto | null) => void;
|
|
||||||
|
|
||||||
const componentId = generateId();
|
let { isUploading } = uploadAssetsStore;
|
||||||
$: bucketDate = bucket.bucketDate;
|
|
||||||
$: dateGroups = bucket.dateGroups;
|
|
||||||
|
|
||||||
const {
|
interface Props {
|
||||||
DATEGROUP: { INTERSECTION_ROOT_TOP, INTERSECTION_ROOT_BOTTOM },
|
isSelectionMode: boolean;
|
||||||
} = TUNABLES;
|
singleSelect: boolean;
|
||||||
/* TODO figure out a way to calculate this*/
|
withStacked: boolean;
|
||||||
const TITLE_HEIGHT = 51;
|
showArchiveIcon: boolean;
|
||||||
|
bucket: AssetBucket;
|
||||||
|
assetInteraction: AssetInteraction;
|
||||||
|
|
||||||
let isMouseOverGroup = false;
|
onSelect: ({ title, assets }: { title: string; assets: AssetResponseDto[] }) => void;
|
||||||
let hoveredDateGroup = '';
|
onSelectAssets: (asset: AssetResponseDto) => void;
|
||||||
|
onSelectAssetCandidates: (asset: AssetResponseDto | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
isSelectionMode,
|
||||||
|
singleSelect,
|
||||||
|
withStacked,
|
||||||
|
showArchiveIcon,
|
||||||
|
bucket = $bindable(),
|
||||||
|
assetInteraction,
|
||||||
|
onSelect,
|
||||||
|
onSelectAssets,
|
||||||
|
onSelectAssetCandidates,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let isMouseOverGroup = $state(false);
|
||||||
|
let hoveredDateGroup = $state();
|
||||||
|
|
||||||
|
const transitionDuration = $derived.by(() => (bucket.store.suspendTransitions && !$isUploading ? 0 : 150));
|
||||||
|
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
||||||
const onClick = (assets: AssetResponseDto[], groupTitle: string, asset: AssetResponseDto) => {
|
const onClick = (assets: AssetResponseDto[], groupTitle: string, asset: AssetResponseDto) => {
|
||||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||||
assetSelectHandler(asset, assets, groupTitle);
|
assetSelectHandler(asset, assets, groupTitle);
|
||||||
@ -59,13 +54,6 @@
|
|||||||
void navigate({ targetRoute: 'current', assetId: asset.id });
|
void navigate({ targetRoute: 'current', assetId: asset.id });
|
||||||
};
|
};
|
||||||
|
|
||||||
const onRetrieveElement = (dateGroup: DateGroup, asset: AssetResponseDto, element: HTMLElement) => {
|
|
||||||
if (assetGridElement && onScrollTarget) {
|
|
||||||
const offset = findTotalOffset(element, assetGridElement) - TITLE_HEIGHT;
|
|
||||||
onScrollTarget({ bucket, dateGroup, asset, offset });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => onSelect({ title, assets });
|
const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => onSelect({ title, assets });
|
||||||
|
|
||||||
const assetSelectHandler = (asset: AssetResponseDto, assetsInDateGroup: AssetResponseDto[], groupTitle: string) => {
|
const assetSelectHandler = (asset: AssetResponseDto, assetsInDateGroup: AssetResponseDto[], groupTitle: string) => {
|
||||||
@ -73,7 +61,7 @@
|
|||||||
|
|
||||||
// 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 = assetsInDateGroup.filter((asset) =>
|
||||||
assetInteraction.selectedAssets.has(asset),
|
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
|
||||||
@ -83,7 +71,9 @@
|
|||||||
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
|
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const snapshotAssetArray = (assets: AssetResponseDto[]) => {
|
||||||
|
return assets.map((a) => $state.snapshot(a));
|
||||||
|
};
|
||||||
const assetMouseEventHandler = (groupTitle: string, asset: AssetResponseDto | null) => {
|
const assetMouseEventHandler = (groupTitle: string, asset: AssetResponseDto | null) => {
|
||||||
// Show multi select icon on hover on date group
|
// Show multi select icon on hover on date group
|
||||||
hoveredDateGroup = groupTitle;
|
hoveredDateGroup = groupTitle;
|
||||||
@ -96,77 +86,43 @@
|
|||||||
const assetOnFocusHandler = (asset: AssetResponseDto) => {
|
const assetOnFocusHandler = (asset: AssetResponseDto) => {
|
||||||
assetInteraction.focussedAssetId = asset.id;
|
assetInteraction.focussedAssetId = asset.id;
|
||||||
};
|
};
|
||||||
|
function filterIntersecting<R extends { intersecting: boolean }>(intersectable: R[]) {
|
||||||
onDestroy(() => {
|
return intersectable.filter((int) => int.intersecting);
|
||||||
assetStore.taskManager.removeAllTasksForComponent(componentId);
|
}
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section id="asset-group-by-date" class="flex flex-wrap gap-x-12" data-bucket-date={bucketDate} bind:this={element}>
|
{#each filterIntersecting(bucket.dateGroups) as dateGroup, groupIndex (dateGroup.date)}
|
||||||
{#each dateGroups as dateGroup, groupIndex (dateGroup.date)}
|
{@const absoluteWidth = dateGroup.left}
|
||||||
{@const display =
|
|
||||||
dateGroup.intersecting || !!dateGroup.assets.some((asset) => asset.id === assetStore.pendingScrollAssetId)}
|
|
||||||
{@const geometry = dateGroup.geometry!}
|
|
||||||
|
|
||||||
<div
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
id="date-group"
|
<section
|
||||||
use:intersectionObserver={{
|
class={[
|
||||||
onIntersect: () => {
|
{ 'transition-all': !bucket.store.suspendTransitions },
|
||||||
assetStore.taskManager.intersectedDateGroup(componentId, dateGroup, () =>
|
!bucket.store.suspendTransitions && `delay-${transitionDuration}`,
|
||||||
assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: true }),
|
]}
|
||||||
);
|
data-group
|
||||||
},
|
style:position="absolute"
|
||||||
onSeparate: () => {
|
style:transform={`translate3d(${absoluteWidth}px,${dateGroup.top}px,0)`}
|
||||||
assetStore.taskManager.separatedDateGroup(componentId, dateGroup, () =>
|
onmouseenter={() => {
|
||||||
assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: false }),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
top: INTERSECTION_ROOT_TOP,
|
|
||||||
bottom: INTERSECTION_ROOT_BOTTOM,
|
|
||||||
root: assetGridElement,
|
|
||||||
}}
|
|
||||||
data-display={display}
|
|
||||||
data-date-group={dateGroup.date}
|
|
||||||
style:height={dateGroup.height + 'px'}
|
|
||||||
style:width={geometry.containerWidth + 'px'}
|
|
||||||
style:overflow="clip"
|
|
||||||
>
|
|
||||||
{#if !display}
|
|
||||||
<Skeleton height={dateGroup.height + 'px'} title={dateGroup.groupTitle} />
|
|
||||||
{/if}
|
|
||||||
{#if display}
|
|
||||||
<!-- Asset Group By Date -->
|
|
||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
|
||||||
<div
|
|
||||||
on:mouseenter={() =>
|
|
||||||
assetStore.taskManager.queueScrollSensitiveTask({
|
|
||||||
componentId,
|
|
||||||
task: () => {
|
|
||||||
isMouseOverGroup = true;
|
isMouseOverGroup = true;
|
||||||
assetMouseEventHandler(dateGroup.groupTitle, null);
|
assetMouseEventHandler(dateGroup.groupTitle, null);
|
||||||
},
|
}}
|
||||||
})}
|
onmouseleave={() => {
|
||||||
on:mouseleave={() => {
|
|
||||||
assetStore.taskManager.queueScrollSensitiveTask({
|
|
||||||
componentId,
|
|
||||||
task: () => {
|
|
||||||
isMouseOverGroup = false;
|
isMouseOverGroup = false;
|
||||||
assetMouseEventHandler(dateGroup.groupTitle, null);
|
assetMouseEventHandler(dateGroup.groupTitle, null);
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<!-- Date group title -->
|
<!-- Date group title -->
|
||||||
<div
|
<div
|
||||||
class="flex z-[100] sticky top-[-1px] pt-[calc(1.75rem+1px)] pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
|
class="flex z-[100] pt-[calc(1.75rem+1px)] pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
|
||||||
style:width={geometry.containerWidth + 'px'}
|
style:width={dateGroup.width + 'px'}
|
||||||
>
|
>
|
||||||
{#if !singleSelect && ((hoveredDateGroup == dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dateGroup.groupTitle))}
|
{#if !singleSelect && ((hoveredDateGroup === dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dateGroup.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"
|
||||||
on:click={() => handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)}
|
onclick={() => handleSelectGroup(dateGroup.groupTitle, snapshotAssetArray(dateGroup.getAssets()))}
|
||||||
on:keydown={() => handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)}
|
onkeydown={() => handleSelectGroup(dateGroup.groupTitle, snapshotAssetArray(dateGroup.getAssets()))}
|
||||||
>
|
>
|
||||||
{#if assetInteraction.selectedGroup.has(dateGroup.groupTitle)}
|
{#if assetInteraction.selectedGroup.has(dateGroup.groupTitle)}
|
||||||
<Icon path={mdiCheckCircle} size="24" color="#4250af" />
|
<Icon path={mdiCheckCircle} size="24" color="#4250af" />
|
||||||
@ -182,69 +138,48 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Image grid -->
|
<!-- Image grid -->
|
||||||
|
<div class="relative overflow-clip" style:height={dateGroup.height + 'px'} style:width={dateGroup.width + 'px'}>
|
||||||
|
{#each filterIntersecting(dateGroup.intersetingAssets) as intersectingAsset (intersectingAsset.id)}
|
||||||
|
{@const position = intersectingAsset.position!}
|
||||||
|
{@const asset = intersectingAsset.asset!}
|
||||||
|
|
||||||
|
<!-- {#if intersectingAsset.intersecting} -->
|
||||||
|
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
|
||||||
<div
|
<div
|
||||||
class="relative overflow-clip"
|
|
||||||
style:height={geometry.containerHeight + 'px'}
|
|
||||||
style:width={geometry.containerWidth + 'px'}
|
|
||||||
>
|
|
||||||
{#each dateGroup.assets as asset, i (asset.id)}
|
|
||||||
<!-- getting these together here in this order is very cache-efficient -->
|
|
||||||
{@const top = geometry.getTop(i)}
|
|
||||||
{@const left = geometry.getLeft(i)}
|
|
||||||
{@const width = geometry.getWidth(i)}
|
|
||||||
{@const height = geometry.getHeight(i)}
|
|
||||||
<!-- update ASSET_GRID_PADDING-->
|
|
||||||
<div
|
|
||||||
use:intersectionObserver={{
|
|
||||||
onIntersect: () => onAssetInGrid?.(asset),
|
|
||||||
top: `${-TITLE_HEIGHT}px`,
|
|
||||||
bottom: `${-(viewport.height - TITLE_HEIGHT - 1)}px`,
|
|
||||||
right: `${-(viewport.width - 1)}px`,
|
|
||||||
root: assetGridElement,
|
|
||||||
}}
|
|
||||||
data-asset-id={asset.id}
|
data-asset-id={asset.id}
|
||||||
class="absolute"
|
class="absolute"
|
||||||
style:top={top + 'px'}
|
style:top={position.top + 'px'}
|
||||||
style:left={left + 'px'}
|
style:left={position.left + 'px'}
|
||||||
style:width={width + 'px'}
|
style:width={position.width + 'px'}
|
||||||
style:height={height + 'px'}
|
style:height={position.height + 'px'}
|
||||||
|
out:scale|global={{ start: 0.1, duration: scaleDuration }}
|
||||||
|
animate:flip={{ duration: transitionDuration }}
|
||||||
>
|
>
|
||||||
<Thumbnail
|
<Thumbnail
|
||||||
{dateGroup}
|
|
||||||
{assetStore}
|
|
||||||
intersectionConfig={{
|
|
||||||
root: assetGridElement,
|
|
||||||
bottom: renderThumbsAtBottomMargin,
|
|
||||||
top: renderThumbsAtTopMargin,
|
|
||||||
}}
|
|
||||||
retrieveElement={assetStore.pendingScrollAssetId === asset.id}
|
|
||||||
onRetrieveElement={(element) => onRetrieveElement(dateGroup, asset, element)}
|
|
||||||
showStackedIcon={withStacked}
|
showStackedIcon={withStacked}
|
||||||
{showArchiveIcon}
|
{showArchiveIcon}
|
||||||
{asset}
|
{asset}
|
||||||
{groupIndex}
|
{groupIndex}
|
||||||
onClick={(asset) => onClick(dateGroup.assets, dateGroup.groupTitle, asset)}
|
|
||||||
onSelect={(asset) => assetSelectHandler(asset, dateGroup.assets, dateGroup.groupTitle)}
|
|
||||||
onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, asset)}
|
|
||||||
selected={assetInteraction.selectedAssets.has(asset) || assetStore.albumAssets.has(asset.id)}
|
|
||||||
handleFocus={() => assetOnFocusHandler(asset)}
|
|
||||||
focussed={assetInteraction.isFocussedAsset(asset)}
|
focussed={assetInteraction.isFocussedAsset(asset)}
|
||||||
selectionCandidate={assetInteraction.assetSelectionCandidates.has(asset)}
|
onClick={(asset) => onClick(dateGroup.getAssets(), dateGroup.groupTitle, asset)}
|
||||||
disabled={assetStore.albumAssets.has(asset.id)}
|
onSelect={(asset) => assetSelectHandler(asset, dateGroup.getAssets(), dateGroup.groupTitle)}
|
||||||
thumbnailWidth={width}
|
onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, $state.snapshot(asset))}
|
||||||
thumbnailHeight={height}
|
selected={assetInteraction.hasSelectedAsset(asset.id) || dateGroup.bucket.store.albumAssets.has(asset.id)}
|
||||||
|
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
|
||||||
|
handleFocus={() => assetOnFocusHandler(asset)}
|
||||||
|
disabled={dateGroup.bucket.store.albumAssets.has(asset.id)}
|
||||||
|
thumbnailWidth={position.width}
|
||||||
|
thumbnailHeight={position.height}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- {/if} -->
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</section>
|
</section>
|
||||||
|
{/each}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#asset-group-by-date {
|
section {
|
||||||
contain: layout paint style;
|
contain: layout paint style;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -4,38 +4,26 @@
|
|||||||
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
||||||
import { AppRoute, AssetAction } from '$lib/constants';
|
import { AppRoute, AssetAction } from '$lib/constants';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { AssetBucket, AssetStore, type BucketListener, type ViewportXY } from '$lib/stores/assets-store.svelte';
|
import { AssetBucket, AssetStore } from '$lib/stores/assets-store.svelte';
|
||||||
import { locale, showDeleteModal } from '$lib/stores/preferences.store';
|
import { showDeleteModal } from '$lib/stores/preferences.store';
|
||||||
import { isSearchEnabled } from '$lib/stores/search.store';
|
import { isSearchEnabled } from '$lib/stores/search.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 { deleteAssets } from '$lib/utils/actions';
|
import { deleteAssets } from '$lib/utils/actions';
|
||||||
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
|
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
|
||||||
import { navigate } from '$lib/utils/navigation';
|
import { navigate } from '$lib/utils/navigation';
|
||||||
import {
|
import { type ScrubberListener } from '$lib/utils/timeline-util';
|
||||||
formatGroupTitle,
|
|
||||||
splitBucketIntoDateGroups,
|
|
||||||
type ScrubberListener,
|
|
||||||
type ScrollTargetListener,
|
|
||||||
} from '$lib/utils/timeline-util';
|
|
||||||
import { TUNABLES } from '$lib/utils/tunables';
|
|
||||||
import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk';
|
import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk';
|
||||||
import { throttle } from 'lodash-es';
|
import { onMount, type Snippet } from 'svelte';
|
||||||
import { onDestroy, onMount, type Snippet } from 'svelte';
|
|
||||||
import Portal from '../shared-components/portal/portal.svelte';
|
import Portal from '../shared-components/portal/portal.svelte';
|
||||||
import Scrubber from '../shared-components/scrubber/scrubber.svelte';
|
import Scrubber from '../shared-components/scrubber/scrubber.svelte';
|
||||||
import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
|
import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
|
||||||
import AssetDateGroup from './asset-date-group.svelte';
|
import AssetDateGroup from './asset-date-group.svelte';
|
||||||
import DeleteAssetDialog from './delete-asset-dialog.svelte';
|
import DeleteAssetDialog from './delete-asset-dialog.svelte';
|
||||||
|
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
|
||||||
import { resizeObserver } from '$lib/actions/resize-observer';
|
|
||||||
import MeasureDateGroup from '$lib/components/photos-page/measure-date-group.svelte';
|
|
||||||
import { intersectionObserver } from '$lib/actions/intersection-observer';
|
|
||||||
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
|
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import type { UpdatePayload } from 'vite';
|
import type { UpdatePayload } from 'vite';
|
||||||
import { generateId } from '$lib/utils/generate-id';
|
|
||||||
import { isTimelineScrolling } from '$lib/stores/timeline.store';
|
|
||||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -81,64 +69,41 @@
|
|||||||
|
|
||||||
let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget } = assetViewingStore;
|
let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget } = assetViewingStore;
|
||||||
|
|
||||||
const viewport: ViewportXY = $state({ width: 0, height: 0, x: 0, y: 0 });
|
|
||||||
const safeViewport: ViewportXY = $state({ width: 0, height: 0, x: 0, y: 0 });
|
|
||||||
|
|
||||||
const componentId = generateId();
|
|
||||||
let element: HTMLElement | undefined = $state();
|
let element: HTMLElement | undefined = $state();
|
||||||
|
|
||||||
let timelineElement: HTMLElement | undefined = $state();
|
let timelineElement: HTMLElement | undefined = $state();
|
||||||
let showShortcuts = $state(false);
|
let showShortcuts = $state(false);
|
||||||
let showSkeleton = $state(true);
|
let showSkeleton = $state(true);
|
||||||
let internalScroll = false;
|
|
||||||
let navigating = false;
|
|
||||||
let preMeasure: AssetBucket[] = $state([]);
|
|
||||||
let lastIntersectedBucketDate: string | undefined;
|
|
||||||
let scrubBucketPercent = $state(0);
|
let scrubBucketPercent = $state(0);
|
||||||
let scrubBucket: { bucketDate: string | undefined } | undefined = $state();
|
let scrubBucket: { bucketDate: string | undefined } | undefined = $state();
|
||||||
let scrubOverallPercent: number = $state(0);
|
let scrubOverallPercent: number = $state(0);
|
||||||
let topSectionHeight = $state(0);
|
|
||||||
let topSectionOffset = $state(0);
|
|
||||||
// 60 is the bottom spacer element at 60px
|
// 60 is the bottom spacer element at 60px
|
||||||
let bottomSectionHeight = 60;
|
let bottomSectionHeight = 60;
|
||||||
let leadout = $state(false);
|
let leadout = $state(false);
|
||||||
|
|
||||||
const {
|
const completeNav = async () => {
|
||||||
ASSET_GRID: { NAVIGATE_ON_ASSET_IN_VIEW },
|
|
||||||
BUCKET: {
|
|
||||||
INTERSECTION_ROOT_TOP: BUCKET_INTERSECTION_ROOT_TOP,
|
|
||||||
INTERSECTION_ROOT_BOTTOM: BUCKET_INTERSECTION_ROOT_BOTTOM,
|
|
||||||
},
|
|
||||||
THUMBNAIL: {
|
|
||||||
INTERSECTION_ROOT_TOP: THUMBNAIL_INTERSECTION_ROOT_TOP,
|
|
||||||
INTERSECTION_ROOT_BOTTOM: THUMBNAIL_INTERSECTION_ROOT_BOTTOM,
|
|
||||||
},
|
|
||||||
} = TUNABLES;
|
|
||||||
|
|
||||||
const isViewportOrigin = () => {
|
|
||||||
return viewport.height === 0 && viewport.width === 0;
|
|
||||||
};
|
|
||||||
const isEqual = (a: ViewportXY, b: ViewportXY) => {
|
|
||||||
return a.height == b.height && a.width == b.width && a.x === b.x && a.y === b.y;
|
|
||||||
};
|
|
||||||
|
|
||||||
const completeNav = () => {
|
|
||||||
navigating = false;
|
|
||||||
if (internalScroll) {
|
|
||||||
internalScroll = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($gridScrollTarget?.at) {
|
if ($gridScrollTarget?.at) {
|
||||||
void assetStore.scheduleScrollToAssetId($gridScrollTarget, () => {
|
try {
|
||||||
|
const bucket = await assetStore.findBucketForAsset($gridScrollTarget.at);
|
||||||
|
if (bucket) {
|
||||||
|
const height = bucket.findAssetAbsolutePosition($gridScrollTarget.at);
|
||||||
|
if (height) {
|
||||||
|
element?.scrollTo({ top: height });
|
||||||
|
showSkeleton = false;
|
||||||
|
assetStore.updateIntersections();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
element?.scrollTo({ top: 0 });
|
element?.scrollTo({ top: 0 });
|
||||||
showSkeleton = false;
|
showSkeleton = false;
|
||||||
});
|
}
|
||||||
} else {
|
} else {
|
||||||
element?.scrollTo({ top: 0 });
|
element?.scrollTo({ top: 0 });
|
||||||
showSkeleton = false;
|
showSkeleton = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
beforeNavigate(() => (assetStore.suspendTransitions = true));
|
||||||
afterNavigate((nav) => {
|
afterNavigate((nav) => {
|
||||||
const { complete, type } = nav;
|
const { complete, type } = nav;
|
||||||
if (type === 'enter') {
|
if (type === 'enter') {
|
||||||
@ -147,10 +112,6 @@
|
|||||||
complete.then(completeNav, completeNav);
|
complete.then(completeNav, completeNav);
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeNavigate(() => {
|
|
||||||
navigating = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const hmrSupport = () => {
|
const hmrSupport = () => {
|
||||||
// when hmr happens, skeleton is initialized to true by default
|
// when hmr happens, skeleton is initialized to true by default
|
||||||
// normally, loading asset-grid is part of a navigation event, and the completion of
|
// normally, loading asset-grid is part of a navigation event, and the completion of
|
||||||
@ -165,7 +126,6 @@
|
|||||||
|
|
||||||
if (assetGridUpdate) {
|
if (assetGridUpdate) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
void assetStore.updateViewport(safeViewport, true);
|
|
||||||
const asset = $page.url.searchParams.get('at');
|
const asset = $page.url.searchParams.get('at');
|
||||||
if (asset) {
|
if (asset) {
|
||||||
$gridScrollTarget = { at: asset };
|
$gridScrollTarget = { at: asset };
|
||||||
@ -193,94 +153,60 @@
|
|||||||
return () => void 0;
|
return () => void 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const scrollTolastIntersectedBucket = (adjustedBucket: AssetBucket, delta: number) => {
|
const updateIsScrolling = () => (assetStore.scrolling = true);
|
||||||
if (lastIntersectedBucketDate) {
|
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
|
||||||
const currentIndex = assetStore.buckets.findIndex((b) => b.bucketDate === lastIntersectedBucketDate);
|
const updateSlidingWindow = () => assetStore.updateSlidingWindow(element?.scrollTop || 0);
|
||||||
const deltaIndex = assetStore.buckets.indexOf(adjustedBucket);
|
const compensateScrollCallback = (delta: number) => element?.scrollBy(0, delta);
|
||||||
|
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (assetStore.topSectionHeight = height);
|
||||||
if (deltaIndex < currentIndex) {
|
|
||||||
element?.scrollBy(0, delta);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const bucketListener: BucketListener = (event) => {
|
|
||||||
const { type } = event;
|
|
||||||
if (type === 'bucket-height') {
|
|
||||||
const { bucket, delta } = event;
|
|
||||||
scrollTolastIntersectedBucket(bucket, delta);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void assetStore
|
assetStore.setCompensateScrollCallback(compensateScrollCallback);
|
||||||
.init({ bucketListener })
|
|
||||||
.then(() => (assetStore.connect(), assetStore.updateViewport(safeViewport)));
|
|
||||||
if (!enableRouting) {
|
if (!enableRouting) {
|
||||||
showSkeleton = false;
|
showSkeleton = false;
|
||||||
}
|
}
|
||||||
const dispose = hmrSupport();
|
const disposeHmr = hmrSupport();
|
||||||
return () => {
|
return () => {
|
||||||
assetStore.disconnect();
|
assetStore.setCompensateScrollCallback();
|
||||||
assetStore.destroy();
|
disposeHmr();
|
||||||
dispose();
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const _updateViewport = () => void assetStore.updateViewport(safeViewport);
|
const getMaxScrollPercent = () => {
|
||||||
const updateViewport = throttle(_updateViewport, 16);
|
const totalHeight = assetStore.timelineHeight + bottomSectionHeight + assetStore.topSectionHeight;
|
||||||
|
return (totalHeight - assetStore.viewportHeight) / totalHeight;
|
||||||
function getOffset(bucketDate: string) {
|
};
|
||||||
let offset = 0;
|
|
||||||
for (let a = 0; a < assetStore.buckets.length; a++) {
|
|
||||||
if (assetStore.buckets[a].bucketDate === bucketDate) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
offset += assetStore.buckets[a].bucketHeight;
|
|
||||||
}
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getMaxScrollPercent = () =>
|
|
||||||
(assetStore.timelineHeight + bottomSectionHeight + topSectionHeight - safeViewport.height) /
|
|
||||||
(assetStore.timelineHeight + bottomSectionHeight + topSectionHeight);
|
|
||||||
|
|
||||||
const getMaxScroll = () => {
|
const getMaxScroll = () => {
|
||||||
if (!element || !timelineElement) {
|
if (!element || !timelineElement) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
return assetStore.topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight);
|
||||||
return topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const scrollToBucketAndOffset = (bucket: AssetBucket, bucketScrollPercent: number) => {
|
const scrollToBucketAndOffset = (bucket: AssetBucket, bucketScrollPercent: number) => {
|
||||||
const topOffset = getOffset(bucket.bucketDate) + topSectionHeight + topSectionOffset;
|
const topOffset = bucket.top;
|
||||||
const maxScrollPercent = getMaxScrollPercent();
|
const maxScrollPercent = getMaxScrollPercent();
|
||||||
const delta = bucket.bucketHeight * bucketScrollPercent;
|
const delta = bucket.bucketHeight * bucketScrollPercent;
|
||||||
const scrollTop = (topOffset + delta) * maxScrollPercent;
|
const scrollTop = (topOffset + delta) * maxScrollPercent;
|
||||||
|
|
||||||
if (!element) {
|
if (element) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
element.scrollTop = scrollTop;
|
element.scrollTop = scrollTop;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const _onScrub: ScrubberListener = (
|
// note: don't throttle, debounch, or otherwise make this function async - it causes flicker
|
||||||
|
const onScrub: ScrubberListener = (
|
||||||
bucketDate: string | undefined,
|
bucketDate: string | undefined,
|
||||||
scrollPercent: number,
|
scrollPercent: number,
|
||||||
bucketScrollPercent: number,
|
bucketScrollPercent: number,
|
||||||
) => {
|
) => {
|
||||||
if (!bucketDate || assetStore.timelineHeight < safeViewport.height * 2) {
|
if (!bucketDate || assetStore.timelineHeight < assetStore.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 * scrollPercent;
|
||||||
|
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
element.scrollTop = offset;
|
element.scrollTop = offset;
|
||||||
} else {
|
} else {
|
||||||
const bucket = assetStore.buckets.find((b) => b.bucketDate === bucketDate);
|
const bucket = assetStore.buckets.find((b) => b.bucketDate === bucketDate);
|
||||||
@ -290,47 +216,16 @@
|
|||||||
scrollToBucketAndOffset(bucket, bucketScrollPercent);
|
scrollToBucketAndOffset(bucket, bucketScrollPercent);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const onScrub = throttle(_onScrub, 16, { leading: false, trailing: true });
|
|
||||||
|
|
||||||
const stopScrub: ScrubberListener = async (
|
|
||||||
bucketDate: string | undefined,
|
|
||||||
_scrollPercent: number,
|
|
||||||
bucketScrollPercent: number,
|
|
||||||
) => {
|
|
||||||
if (!bucketDate || assetStore.timelineHeight < safeViewport.height * 2) {
|
|
||||||
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const bucket = assetStore.buckets.find((b) => b.bucketDate === bucketDate);
|
|
||||||
if (!bucket) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (bucket && !bucket.measured) {
|
|
||||||
preMeasure.push(bucket);
|
|
||||||
await assetStore.loadBucket(bucketDate, { preventCancel: true, pending: true });
|
|
||||||
await bucket.measuredPromise;
|
|
||||||
scrollToBucketAndOffset(bucket, bucketScrollPercent);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let scrollObserverTimer: NodeJS.Timeout;
|
|
||||||
|
|
||||||
const _handleTimelineScroll = () => {
|
|
||||||
$isTimelineScrolling = true;
|
|
||||||
if (scrollObserverTimer) {
|
|
||||||
clearTimeout(scrollObserverTimer);
|
|
||||||
}
|
|
||||||
scrollObserverTimer = setTimeout(() => {
|
|
||||||
$isTimelineScrolling = false;
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
|
// note: don't throttle, debounch, or otherwise make this function async - it causes flicker
|
||||||
|
const handleTimelineScroll = () => {
|
||||||
leadout = false;
|
leadout = false;
|
||||||
|
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (assetStore.timelineHeight < safeViewport.height * 2) {
|
if (assetStore.timelineHeight < assetStore.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);
|
||||||
@ -338,8 +233,8 @@
|
|||||||
scrubBucket = undefined;
|
scrubBucket = undefined;
|
||||||
scrubBucketPercent = 0;
|
scrubBucketPercent = 0;
|
||||||
} else {
|
} else {
|
||||||
let top = element?.scrollTop;
|
let top = element.scrollTop;
|
||||||
if (top < topSectionHeight) {
|
if (top < assetStore.topSectionHeight) {
|
||||||
// in the lead-in area
|
// in the lead-in area
|
||||||
scrubBucket = undefined;
|
scrubBucket = undefined;
|
||||||
scrubBucketPercent = 0;
|
scrubBucketPercent = 0;
|
||||||
@ -352,18 +247,24 @@
|
|||||||
let maxScrollPercent = getMaxScrollPercent();
|
let maxScrollPercent = getMaxScrollPercent();
|
||||||
let found = false;
|
let found = false;
|
||||||
|
|
||||||
// create virtual buckets....
|
const bucketsLength = assetStore.buckets.length;
|
||||||
const vbuckets = [
|
for (let i = -1; i < bucketsLength + 1; i++) {
|
||||||
{ bucketHeight: topSectionHeight, bucketDate: undefined },
|
let bucket: { bucketDate: string | undefined } | undefined;
|
||||||
...assetStore.buckets,
|
let bucketHeight = 0;
|
||||||
{ bucketHeight: bottomSectionHeight, bucketDate: undefined },
|
if (i === -1) {
|
||||||
];
|
// lead-in
|
||||||
|
bucketHeight = assetStore.topSectionHeight;
|
||||||
for (const bucket of vbuckets) {
|
} else if (i === bucketsLength) {
|
||||||
let next = top - bucket.bucketHeight * maxScrollPercent;
|
// lead-out
|
||||||
|
bucketHeight = bottomSectionHeight;
|
||||||
|
} else {
|
||||||
|
bucket = assetStore.buckets[i];
|
||||||
|
bucketHeight = assetStore.buckets[i].bucketHeight;
|
||||||
|
}
|
||||||
|
let next = top - bucketHeight * maxScrollPercent;
|
||||||
if (next < 0) {
|
if (next < 0) {
|
||||||
scrubBucket = bucket;
|
scrubBucket = bucket;
|
||||||
scrubBucketPercent = top / (bucket.bucketHeight * maxScrollPercent);
|
scrubBucketPercent = top / (bucketHeight * maxScrollPercent);
|
||||||
found = true;
|
found = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -377,34 +278,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleTimelineScroll = throttle(_handleTimelineScroll, 16, { leading: false, trailing: true });
|
|
||||||
|
|
||||||
const _onAssetInGrid = async (asset: AssetResponseDto) => {
|
|
||||||
if (!enableRouting || navigating || internalScroll) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$gridScrollTarget = { at: asset.id };
|
|
||||||
internalScroll = true;
|
|
||||||
await navigate(
|
|
||||||
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
|
|
||||||
{ replaceState: true, forceNavigate: true },
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const onAssetInGrid = NAVIGATE_ON_ASSET_IN_VIEW
|
|
||||||
? throttle(_onAssetInGrid, 16, { leading: false, trailing: true })
|
|
||||||
: () => void 0;
|
|
||||||
|
|
||||||
const onScrollTarget: ScrollTargetListener = ({ bucket, offset }) => {
|
|
||||||
element?.scrollTo({ top: offset });
|
|
||||||
if (!bucket.measured) {
|
|
||||||
preMeasure.push(bucket);
|
|
||||||
}
|
|
||||||
showSkeleton = false;
|
|
||||||
assetStore.clearPendingScroll();
|
|
||||||
// set intersecting true manually here, to reduce flicker that happens when
|
|
||||||
// clearing pending scroll, but the intersection observer hadn't yet had time to run
|
|
||||||
assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
const trashOrDelete = async (force: boolean = false) => {
|
const trashOrDelete = async (force: boolean = false) => {
|
||||||
isShowDeleteConfirmation = false;
|
isShowDeleteConfirmation = false;
|
||||||
@ -439,11 +312,9 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleArchive = async () => {
|
const toggleArchive = async () => {
|
||||||
const ids = await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived);
|
await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived);
|
||||||
if (ids) {
|
assetStore.updateAssets(assetInteraction.selectedAssetsArray);
|
||||||
assetStore.removeAssets(ids);
|
|
||||||
deselectAllAssets();
|
deselectAllAssets();
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const focusElement = () => {
|
const focusElement = () => {
|
||||||
@ -458,23 +329,6 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleIntersect(bucket: AssetBucket) {
|
|
||||||
// updateLastIntersectedBucketDate();
|
|
||||||
const task = () => {
|
|
||||||
assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
|
|
||||||
void assetStore.loadBucket(bucket.bucketDate);
|
|
||||||
};
|
|
||||||
assetStore.taskManager.intersectedBucket(componentId, bucket, task);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSeparate(bucket: AssetBucket) {
|
|
||||||
const task = () => {
|
|
||||||
assetStore.updateBucket(bucket.bucketDate, { intersecting: false });
|
|
||||||
bucket.cancel();
|
|
||||||
};
|
|
||||||
assetStore.taskManager.separatedBucket(componentId, bucket, task);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePrevious = async () => {
|
const handlePrevious = async () => {
|
||||||
const previousAsset = await assetStore.getPreviousAsset($viewingAsset);
|
const previousAsset = await assetStore.getPreviousAsset($viewingAsset);
|
||||||
|
|
||||||
@ -610,7 +464,6 @@
|
|||||||
if (!asset) {
|
if (!asset) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onSelect(asset);
|
onSelect(asset);
|
||||||
|
|
||||||
if (singleSelect && element) {
|
if (singleSelect && element) {
|
||||||
@ -619,7 +472,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rangeSelection = assetInteraction.assetSelectionCandidates.size > 0;
|
const rangeSelection = assetInteraction.assetSelectionCandidates.size > 0;
|
||||||
const deselect = assetInteraction.selectedAssets.has(asset);
|
const deselect = assetInteraction.hasSelectedAsset(asset.id);
|
||||||
|
|
||||||
// Select/deselect already loaded assets
|
// Select/deselect already loaded assets
|
||||||
if (deselect) {
|
if (deselect) {
|
||||||
@ -637,22 +490,25 @@
|
|||||||
assetInteraction.clearAssetSelectionCandidates();
|
assetInteraction.clearAssetSelectionCandidates();
|
||||||
|
|
||||||
if (assetInteraction.assetSelectionStart && rangeSelection) {
|
if (assetInteraction.assetSelectionStart && rangeSelection) {
|
||||||
let startBucketIndex = assetStore.getBucketIndexByAssetId(assetInteraction.assetSelectionStart.id);
|
let startBucket = assetStore.getBucketIndexByAssetId(assetInteraction.assetSelectionStart.id);
|
||||||
let endBucketIndex = assetStore.getBucketIndexByAssetId(asset.id);
|
let endBucket = assetStore.getBucketIndexByAssetId(asset.id);
|
||||||
|
|
||||||
if (startBucketIndex === null || endBucketIndex === null) {
|
if (startBucket === null || endBucket === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (endBucketIndex < startBucketIndex) {
|
// Select/deselect assets in range (start,end]
|
||||||
[startBucketIndex, endBucketIndex] = [endBucketIndex, startBucketIndex];
|
let started = false;
|
||||||
|
for (const bucket of assetStore.buckets) {
|
||||||
|
if (bucket === startBucket) {
|
||||||
|
started = true;
|
||||||
}
|
}
|
||||||
|
if (bucket === endBucket) {
|
||||||
// Select/deselect assets in all intermediate buckets
|
break;
|
||||||
for (let bucketIndex = startBucketIndex + 1; bucketIndex < endBucketIndex; bucketIndex++) {
|
}
|
||||||
const bucket = assetStore.buckets[bucketIndex];
|
if (started) {
|
||||||
await assetStore.loadBucket(bucket.bucketDate);
|
await assetStore.loadBucket(bucket.bucketDate);
|
||||||
for (const asset of bucket.assets) {
|
for (const asset of bucket.getAssets()) {
|
||||||
if (deselect) {
|
if (deselect) {
|
||||||
assetInteraction.removeAssetFromMultiselectGroup(asset);
|
assetInteraction.removeAssetFromMultiselectGroup(asset);
|
||||||
} else {
|
} else {
|
||||||
@ -660,16 +516,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update date group selection
|
// Update date group selection
|
||||||
for (let bucketIndex = startBucketIndex; bucketIndex <= endBucketIndex; bucketIndex++) {
|
started = false;
|
||||||
const bucket = assetStore.buckets[bucketIndex];
|
for (const bucket of assetStore.buckets) {
|
||||||
|
if (bucket === startBucket) {
|
||||||
|
started = true;
|
||||||
|
}
|
||||||
|
if (bucket === endBucket) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// Split bucket into date groups and check each group
|
// Split bucket into date groups and check each group
|
||||||
const assetsGroupByDate = splitBucketIntoDateGroups(bucket, $locale);
|
for (const dateGroup of bucket.dateGroups) {
|
||||||
for (const dateGroup of assetsGroupByDate) {
|
const dateGroupTitle = dateGroup.groupTitle;
|
||||||
const dateGroupTitle = formatGroupTitle(dateGroup.date);
|
if (dateGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
|
||||||
if (dateGroup.assets.every((a) => assetInteraction.selectedAssets.has(a))) {
|
|
||||||
assetInteraction.addGroupToMultiselectGroup(dateGroupTitle);
|
assetInteraction.addGroupToMultiselectGroup(dateGroupTitle);
|
||||||
} else {
|
} else {
|
||||||
assetInteraction.removeGroupFromMultiselectGroup(dateGroupTitle);
|
assetInteraction.removeGroupFromMultiselectGroup(dateGroupTitle);
|
||||||
@ -691,14 +553,16 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let start = assetStore.assets.findIndex((a) => a.id === startAsset.id);
|
const assets = assetStore.getAssets();
|
||||||
let end = assetStore.assets.findIndex((a) => a.id === endAsset.id);
|
|
||||||
|
let start = assets.findIndex((a) => a.id === startAsset.id);
|
||||||
|
let end = assets.findIndex((a) => a.id === endAsset.id);
|
||||||
|
|
||||||
if (start > end) {
|
if (start > end) {
|
||||||
[start, end] = [end, start];
|
[start, end] = [end, start];
|
||||||
}
|
}
|
||||||
|
|
||||||
assetInteraction.setAssetSelectionCandidates(assetStore.assets.slice(start, end + 1));
|
assetInteraction.setAssetSelectionCandidates(assets.slice(start, end + 1));
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSelectStart = (e: Event) => {
|
const onSelectStart = (e: Event) => {
|
||||||
@ -710,14 +574,14 @@
|
|||||||
const focusNextAsset = async () => {
|
const focusNextAsset = async () => {
|
||||||
if (assetInteraction.focussedAssetId === null) {
|
if (assetInteraction.focussedAssetId === null) {
|
||||||
const firstAsset = assetStore.getFirstAsset();
|
const firstAsset = assetStore.getFirstAsset();
|
||||||
if (firstAsset !== null) {
|
if (firstAsset) {
|
||||||
assetInteraction.focussedAssetId = firstAsset.id;
|
assetInteraction.focussedAssetId = firstAsset.id;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const focussedAsset = assetStore.assets.find((asset) => asset.id === assetInteraction.focussedAssetId);
|
const focussedAsset = assetStore.getAssets().find((asset) => asset.id === assetInteraction.focussedAssetId);
|
||||||
if (focussedAsset) {
|
if (focussedAsset) {
|
||||||
const nextAsset = await assetStore.getNextAsset(focussedAsset);
|
const nextAsset = await assetStore.getNextAsset(focussedAsset);
|
||||||
if (nextAsset !== null) {
|
if (nextAsset) {
|
||||||
assetInteraction.focussedAssetId = nextAsset.id;
|
assetInteraction.focussedAssetId = nextAsset.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -726,7 +590,7 @@
|
|||||||
|
|
||||||
const focusPreviousAsset = async () => {
|
const focusPreviousAsset = async () => {
|
||||||
if (assetInteraction.focussedAssetId !== null) {
|
if (assetInteraction.focussedAssetId !== null) {
|
||||||
const focussedAsset = assetStore.assets.find((asset) => asset.id === assetInteraction.focussedAssetId);
|
const focussedAsset = assetStore.getAssets().find((asset) => asset.id === assetInteraction.focussedAssetId);
|
||||||
if (focussedAsset) {
|
if (focussedAsset) {
|
||||||
const previousAsset = await assetStore.getPreviousAsset(focussedAsset);
|
const previousAsset = await assetStore.getPreviousAsset(focussedAsset);
|
||||||
if (previousAsset) {
|
if (previousAsset) {
|
||||||
@ -736,11 +600,8 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
assetStore.taskManager.removeAllTasksForComponent(componentId);
|
|
||||||
});
|
|
||||||
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
|
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
|
||||||
let isEmpty = $derived(assetStore.initialized && assetStore.buckets.length === 0);
|
let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0);
|
||||||
let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id));
|
let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id));
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@ -749,23 +610,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (element && isViewportOrigin()) {
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
viewport.height = rect.height;
|
|
||||||
viewport.width = rect.width;
|
|
||||||
viewport.x = rect.x;
|
|
||||||
viewport.y = rect.y;
|
|
||||||
}
|
|
||||||
if (!isViewportOrigin() && !isEqual(viewport, safeViewport)) {
|
|
||||||
safeViewport.height = viewport.height;
|
|
||||||
safeViewport.width = viewport.width;
|
|
||||||
safeViewport.x = viewport.x;
|
|
||||||
safeViewport.y = viewport.y;
|
|
||||||
updateViewport();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let shortcutList = $derived(
|
let shortcutList = $derived(
|
||||||
(() => {
|
(() => {
|
||||||
if ($isSearchEnabled || $showAssetViewer) {
|
if ($isSearchEnabled || $showAssetViewer) {
|
||||||
@ -829,19 +673,34 @@
|
|||||||
{#if showShortcuts}
|
{#if showShortcuts}
|
||||||
<ShowShortcuts onClose={() => (showShortcuts = !showShortcuts)} />
|
<ShowShortcuts onClose={() => (showShortcuts = !showShortcuts)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if assetStore.buckets.length > 0}
|
{#if assetStore.buckets.length > 0}
|
||||||
<Scrubber
|
<Scrubber
|
||||||
invisible={showSkeleton}
|
invisible={showSkeleton}
|
||||||
{assetStore}
|
{assetStore}
|
||||||
height={safeViewport.height}
|
height={assetStore.viewportHeight}
|
||||||
timelineTopOffset={topSectionHeight}
|
timelineTopOffset={assetStore.topSectionHeight}
|
||||||
timelineBottomOffset={bottomSectionHeight}
|
timelineBottomOffset={bottomSectionHeight}
|
||||||
{leadout}
|
{leadout}
|
||||||
{scrubOverallPercent}
|
{scrubOverallPercent}
|
||||||
{scrubBucketPercent}
|
{scrubBucketPercent}
|
||||||
{scrubBucket}
|
{scrubBucket}
|
||||||
{onScrub}
|
{onScrub}
|
||||||
{stopScrub}
|
onScrubKeyDown={(evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
let amount = 50;
|
||||||
|
if (shiftKeyIsDown) {
|
||||||
|
amount = 500;
|
||||||
|
}
|
||||||
|
if (evt.key === 'ArrowUp') {
|
||||||
|
amount = -amount;
|
||||||
|
if (shiftKeyIsDown) {
|
||||||
|
element?.scrollBy({ top: amount, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
} else if (evt.key === 'ArrowDown') {
|
||||||
|
element?.scrollBy({ top: amount, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@ -850,13 +709,23 @@
|
|||||||
id="asset-grid"
|
id="asset-grid"
|
||||||
class="scrollbar-hidden h-full overflow-y-auto outline-none {isEmpty ? 'm-0' : 'ml-4 tall:ml-0 mr-[60px]'}"
|
class="scrollbar-hidden h-full overflow-y-auto outline-none {isEmpty ? 'm-0' : 'ml-4 tall:ml-0 mr-[60px]'}"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
use:resizeObserver={({ height, width }) => ((viewport.width = width), (viewport.height = height))}
|
bind:clientHeight={assetStore.viewportHeight}
|
||||||
|
bind:clientWidth={null, (v) => ((assetStore.viewportWidth = v), updateSlidingWindow())}
|
||||||
bind:this={element}
|
bind:this={element}
|
||||||
onscroll={() => ((assetStore.lastScrollTime = Date.now()), handleTimelineScroll())}
|
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
use:resizeObserver={({ target, height }) => ((topSectionHeight = height), (topSectionOffset = target.offsetTop))}
|
bind:this={timelineElement}
|
||||||
|
id="virtual-timeline"
|
||||||
class:invisible={showSkeleton}
|
class:invisible={showSkeleton}
|
||||||
|
style:height={assetStore.timelineHeight + 'px'}
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
use:resizeObserver={topSectionResizeObserver}
|
||||||
|
class:invisible={showSkeleton}
|
||||||
|
style:position="absolute"
|
||||||
|
style:left="0"
|
||||||
|
style:right="0"
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
{#if isEmpty}
|
{#if isEmpty}
|
||||||
@ -865,75 +734,42 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
|
||||||
bind:this={timelineElement}
|
|
||||||
id="virtual-timeline"
|
|
||||||
class:invisible={showSkeleton}
|
|
||||||
style:height={assetStore.timelineHeight + 'px'}
|
|
||||||
>
|
|
||||||
{#each assetStore.buckets as bucket (bucket.viewId)}
|
{#each assetStore.buckets as bucket (bucket.viewId)}
|
||||||
{@const isPremeasure = preMeasure.includes(bucket)}
|
{@const display = bucket.intersecting}
|
||||||
{@const display = bucket.intersecting || bucket === assetStore.pendingScrollBucket || isPremeasure}
|
{@const absoluteHeight = bucket.top}
|
||||||
|
|
||||||
|
{#if !bucket.isLoaded}
|
||||||
|
<div
|
||||||
|
style:height={bucket.bucketHeight + 'px'}
|
||||||
|
style:position="absolute"
|
||||||
|
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
||||||
|
style:width="100%"
|
||||||
|
>
|
||||||
|
<Skeleton height={bucket.bucketHeight} title={bucket.bucketDateFormatted} />
|
||||||
|
</div>
|
||||||
|
{:else if display}
|
||||||
<div
|
<div
|
||||||
class="bucket"
|
class="bucket"
|
||||||
style:overflow={bucket.measured ? 'visible' : 'clip'}
|
|
||||||
use:intersectionObserver={[
|
|
||||||
{
|
|
||||||
key: bucket.viewId,
|
|
||||||
onIntersect: () => handleIntersect(bucket),
|
|
||||||
onSeparate: () => handleSeparate(bucket),
|
|
||||||
top: BUCKET_INTERSECTION_ROOT_TOP,
|
|
||||||
bottom: BUCKET_INTERSECTION_ROOT_BOTTOM,
|
|
||||||
root: element,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: bucket.viewId + '.bucketintersection',
|
|
||||||
onIntersect: () => (lastIntersectedBucketDate = bucket.bucketDate),
|
|
||||||
top: '0px',
|
|
||||||
bottom: '-' + Math.max(0, safeViewport.height - 1) + 'px',
|
|
||||||
left: '0px',
|
|
||||||
right: '0px',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
data-bucket-display={bucket.intersecting}
|
|
||||||
data-bucket-date={bucket.bucketDate}
|
|
||||||
style:height={bucket.bucketHeight + 'px'}
|
style:height={bucket.bucketHeight + 'px'}
|
||||||
|
style:position="absolute"
|
||||||
|
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
||||||
|
style:width="100%"
|
||||||
>
|
>
|
||||||
{#if display && !bucket.measured}
|
|
||||||
<MeasureDateGroup
|
|
||||||
{bucket}
|
|
||||||
{assetStore}
|
|
||||||
onMeasured={() => (preMeasure = preMeasure.filter((b) => b !== bucket))}
|
|
||||||
></MeasureDateGroup>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if !display || !bucket.measured}
|
|
||||||
<Skeleton height={bucket.bucketHeight + 'px'} title={`${bucket.bucketDateFormattted}`} />
|
|
||||||
{/if}
|
|
||||||
{#if display && bucket.measured}
|
|
||||||
<AssetDateGroup
|
<AssetDateGroup
|
||||||
assetGridElement={element}
|
|
||||||
renderThumbsAtTopMargin={THUMBNAIL_INTERSECTION_ROOT_TOP}
|
|
||||||
renderThumbsAtBottomMargin={THUMBNAIL_INTERSECTION_ROOT_BOTTOM}
|
|
||||||
{withStacked}
|
{withStacked}
|
||||||
{showArchiveIcon}
|
{showArchiveIcon}
|
||||||
{assetStore}
|
|
||||||
{assetInteraction}
|
{assetInteraction}
|
||||||
{isSelectionMode}
|
{isSelectionMode}
|
||||||
{singleSelect}
|
{singleSelect}
|
||||||
{onScrollTarget}
|
|
||||||
{onAssetInGrid}
|
|
||||||
{bucket}
|
{bucket}
|
||||||
viewport={safeViewport}
|
|
||||||
onSelect={({ title, assets }) => handleGroupSelect(title, assets)}
|
onSelect={({ title, assets }) => handleGroupSelect(title, assets)}
|
||||||
onSelectAssetCandidates={handleSelectAssetCandidates}
|
onSelectAssetCandidates={handleSelectAssetCandidates}
|
||||||
onSelectAssets={handleSelectAssets}
|
onSelectAssets={handleSelectAssets}
|
||||||
/>
|
/>
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
<div class="h-[60px]"></div>
|
<!-- <div class="h-[60px]" style:position="absolute" style:left="0" style:right="0" style:bottom="0"></div> -->
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -965,6 +801,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bucket {
|
.bucket {
|
||||||
contain: layout size;
|
contain: layout size paint;
|
||||||
|
transform-style: flat;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
transform-origin: center center;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,91 +0,0 @@
|
|||||||
<script lang="ts" module>
|
|
||||||
const recentTimes: number[] = [];
|
|
||||||
// TODO: track average time to measure, and use this to populate TUNABLES.ASSETS_STORE.CHECK_INTERVAL_MS
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
function adjustTunables(avg: number) {}
|
|
||||||
function addMeasure(time: number) {
|
|
||||||
recentTimes.push(time);
|
|
||||||
if (recentTimes.length > 10) {
|
|
||||||
recentTimes.shift();
|
|
||||||
}
|
|
||||||
const sum = recentTimes.reduce((acc: number, val: number) => {
|
|
||||||
return acc + val;
|
|
||||||
}, 0);
|
|
||||||
const avg = sum / recentTimes.length;
|
|
||||||
adjustTunables(avg);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { resizeObserver } from '$lib/actions/resize-observer';
|
|
||||||
import type { AssetBucket, AssetStore, BucketListener } from '$lib/stores/assets-store.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
assetStore: AssetStore;
|
|
||||||
bucket: AssetBucket;
|
|
||||||
onMeasured: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { assetStore, bucket, onMeasured }: Props = $props();
|
|
||||||
|
|
||||||
async function _measure(element: Element) {
|
|
||||||
try {
|
|
||||||
await bucket.complete;
|
|
||||||
const t1 = Date.now();
|
|
||||||
let heightPending = bucket.dateGroups.some((group) => !group.heightActual);
|
|
||||||
if (heightPending) {
|
|
||||||
const listener: BucketListener = (event) => {
|
|
||||||
const { type } = event;
|
|
||||||
if (type === 'height') {
|
|
||||||
const { bucket: changedBucket } = event;
|
|
||||||
if (changedBucket === bucket && type === 'height') {
|
|
||||||
heightPending = bucket.dateGroups.some((group) => !group.heightActual);
|
|
||||||
if (!heightPending) {
|
|
||||||
const height = element.getBoundingClientRect().height;
|
|
||||||
if (height !== 0) {
|
|
||||||
assetStore.updateBucket(bucket.bucketDate, { height, measured: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
onMeasured();
|
|
||||||
assetStore.removeListener(listener);
|
|
||||||
const t2 = Date.now();
|
|
||||||
|
|
||||||
addMeasure((t2 - t1) / bucket.bucketCount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
assetStore.addListener(listener);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore if complete rejects (canceled load)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function measure(element: Element) {
|
|
||||||
void _measure(element);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<section id="measure-asset-group-by-date" class="flex flex-wrap gap-x-12" use:measure>
|
|
||||||
{#each bucket.dateGroups as dateGroup (dateGroup.date)}
|
|
||||||
<div id="date-group" data-date-group={dateGroup.date}>
|
|
||||||
<div use:resizeObserver={({ height }) => assetStore.updateBucketDateGroup(bucket, dateGroup, { height })}>
|
|
||||||
<div
|
|
||||||
class="flex z-[100] sticky top-[-1px] pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
|
|
||||||
style:width={dateGroup.geometry.containerWidth + 'px'}
|
|
||||||
>
|
|
||||||
<span class="w-full truncate first-letter:capitalize">
|
|
||||||
{dateGroup.groupTitle}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="relative overflow-clip"
|
|
||||||
style:height={dateGroup.geometry!.containerHeight + 'px'}
|
|
||||||
style:width={dateGroup.geometry!.containerWidth + 'px'}
|
|
||||||
style:visibility="hidden"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</section>
|
|
@ -1,30 +1,28 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
title?: string | null;
|
height: number;
|
||||||
height?: string | null;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { title = null, height = null }: Props = $props();
|
let { height = 0, title }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="overflow-clip" style={`height: ${height}`}>
|
<div class="overflow-clip" style:height={height + 'px'}>
|
||||||
{#if title}
|
|
||||||
<div
|
<div
|
||||||
class="flex z-[100] sticky top-0 pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
|
class="flex z-[100] pt-[calc(1.75rem+1px)] pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
|
||||||
>
|
>
|
||||||
<span class="w-full truncate first-letter:capitalize">{title}</span>
|
{title}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
<div class="animate-pulse absolute w-full h-full" data-skeleton="true"></div>
|
||||||
<div id="skeleton" style={`height: ${height}`}></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#skeleton {
|
[data-skeleton] {
|
||||||
background-image: url('/light_skeleton.png');
|
background-image: url('/light_skeleton.png');
|
||||||
background-repeat: repeat;
|
background-repeat: repeat;
|
||||||
background-size: 235px, 235px;
|
background-size: 235px, 235px;
|
||||||
}
|
}
|
||||||
:global(.dark) #skeleton {
|
:global(.dark) [data-skeleton] {
|
||||||
background-image: url('/dark_skeleton.png');
|
background-image: url('/dark_skeleton.png');
|
||||||
}
|
}
|
||||||
@keyframes delayedVisibility {
|
@keyframes delayedVisibility {
|
||||||
@ -32,8 +30,10 @@
|
|||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#skeleton {
|
[data-skeleton] {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
animation: 0s linear 0.1s forwards delayedVisibility;
|
animation:
|
||||||
|
0s linear 0.1s forwards delayedVisibility,
|
||||||
|
pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -69,7 +69,7 @@
|
|||||||
<div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full z-[100] bg-transparent">
|
<div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full z-[100] bg-transparent">
|
||||||
<div
|
<div
|
||||||
id="asset-selection-app-bar"
|
id="asset-selection-app-bar"
|
||||||
class={`grid ${multiRow ? 'grid-cols-[100%] md:grid-cols-[25%_50%_25%]' : 'grid-cols-[10%_80%_10%] sm:grid-cols-[25%_50%_25%]'} justify-between lg:grid-cols-[25%_50%_25%] ${appBarBorder} mx-2 mt-2 place-items-center rounded-lg p-2 transition-all ${tailwindClasses} dark:bg-immich-dark-gray ${
|
class={`grid ${multiRow ? 'grid-cols-[100%] md:grid-cols-[25%_50%_25%]' : 'grid-cols-[10%_80%_10%] sm:grid-cols-[25%_50%_25%]'} justify-between lg:grid-cols-[25%_50%_25%] ${appBarBorder} mx-2 my-2 place-items-center rounded-lg p-2 transition-all ${tailwindClasses} dark:bg-immich-dark-gray ${
|
||||||
forceDark && 'bg-immich-dark-gray text-white'
|
forceDark && 'bg-immich-dark-gray text-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
@ -8,13 +8,11 @@
|
|||||||
import type { Viewport } from '$lib/stores/assets-store.svelte';
|
import type { Viewport } from '$lib/stores/assets-store.svelte';
|
||||||
import { showDeleteModal } from '$lib/stores/preferences.store';
|
import { showDeleteModal } from '$lib/stores/preferences.store';
|
||||||
import { deleteAssets } from '$lib/utils/actions';
|
import { deleteAssets } from '$lib/utils/actions';
|
||||||
import { archiveAssets, cancelMultiselect, getAssetRatio } from '$lib/utils/asset-utils';
|
import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils';
|
||||||
import { featureFlags } from '$lib/stores/server-config.store';
|
import { featureFlags } from '$lib/stores/server-config.store';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { navigate } from '$lib/utils/navigation';
|
import { navigate } from '$lib/utils/navigation';
|
||||||
import { calculateWidth } from '$lib/utils/timeline-util';
|
|
||||||
import { type AssetResponseDto } from '@immich/sdk';
|
import { type AssetResponseDto } from '@immich/sdk';
|
||||||
import justifiedLayout from 'justified-layout';
|
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
|
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
|
||||||
import ShowShortcuts from '../show-shortcuts.svelte';
|
import ShowShortcuts from '../show-shortcuts.svelte';
|
||||||
@ -22,6 +20,8 @@
|
|||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte';
|
import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte';
|
||||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
|
import { debounce } from 'lodash-es';
|
||||||
|
import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
assets: AssetResponseDto[];
|
assets: AssetResponseDto[];
|
||||||
@ -53,11 +53,84 @@
|
|||||||
|
|
||||||
let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore;
|
let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore;
|
||||||
|
|
||||||
|
let geometry: CommonJustifiedLayout | undefined = $state();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const _assets = assets;
|
||||||
|
updateSlidingWindow();
|
||||||
|
|
||||||
|
geometry = getJustifiedLayoutFromAssets(_assets, {
|
||||||
|
spacing: 2,
|
||||||
|
heightTolerance: 0.15,
|
||||||
|
rowHeight: 235,
|
||||||
|
rowWidth: Math.floor(viewport.width),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let assetLayouts = $derived.by(() => {
|
||||||
|
const assetLayout = [];
|
||||||
|
let containerHeight = 0;
|
||||||
|
let containerWidth = 0;
|
||||||
|
if (geometry) {
|
||||||
|
containerHeight = geometry.containerHeight;
|
||||||
|
containerWidth = geometry.containerWidth;
|
||||||
|
for (const [i, asset] of assets.entries()) {
|
||||||
|
const layout = {
|
||||||
|
asset,
|
||||||
|
top: geometry.getTop(i),
|
||||||
|
left: geometry.getLeft(i),
|
||||||
|
width: geometry.getWidth(i),
|
||||||
|
height: geometry.getHeight(i),
|
||||||
|
};
|
||||||
|
// 54 is the content height of the asset-selection-app-bar
|
||||||
|
const layoutTopWithOffset = layout.top + 54;
|
||||||
|
const layoutBottom = layoutTopWithOffset + layout.height;
|
||||||
|
|
||||||
|
const display = layoutTopWithOffset < slidingWindow.bottom && layoutBottom > slidingWindow.top;
|
||||||
|
assetLayout.push({ ...layout, display });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
assetLayout,
|
||||||
|
containerHeight,
|
||||||
|
containerWidth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
let showShortcuts = $state(false);
|
let showShortcuts = $state(false);
|
||||||
let currentViewAssetIndex = 0;
|
let currentViewAssetIndex = 0;
|
||||||
let shiftKeyIsDown = $state(false);
|
let shiftKeyIsDown = $state(false);
|
||||||
let lastAssetMouseEvent: AssetResponseDto | null = $state(null);
|
let lastAssetMouseEvent: AssetResponseDto | null = $state(null);
|
||||||
|
let slidingWindow = $state({ top: 0, bottom: 0 });
|
||||||
|
|
||||||
|
const updateSlidingWindow = () => {
|
||||||
|
const v = $state.snapshot(viewport);
|
||||||
|
const top = document.scrollingElement?.scrollTop || 0;
|
||||||
|
const bottom = top + v.height;
|
||||||
|
const w = {
|
||||||
|
top,
|
||||||
|
bottom,
|
||||||
|
};
|
||||||
|
slidingWindow = w;
|
||||||
|
};
|
||||||
|
const debouncedOnIntersected = debounce(() => onIntersected?.(), 750, { maxWait: 100, leading: true });
|
||||||
|
|
||||||
|
let lastIntersectedHeight = 0;
|
||||||
|
$effect(() => {
|
||||||
|
// notify we got to (near) the end of scroll
|
||||||
|
const scrollPercentage =
|
||||||
|
((slidingWindow.bottom - viewport.height) / (viewport.height - (document.scrollingElement?.clientHeight || 0))) *
|
||||||
|
100;
|
||||||
|
|
||||||
|
if (scrollPercentage > 90) {
|
||||||
|
const intersectedHeight = geometry?.containerHeight || 0;
|
||||||
|
if (lastIntersectedHeight !== intersectedHeight) {
|
||||||
|
debouncedOnIntersected();
|
||||||
|
lastIntersectedHeight = intersectedHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
const viewAssetHandler = async (asset: AssetResponseDto) => {
|
const viewAssetHandler = async (asset: AssetResponseDto) => {
|
||||||
currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id);
|
currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id);
|
||||||
setAsset(assets[currentViewAssetIndex]);
|
setAsset(assets[currentViewAssetIndex]);
|
||||||
@ -75,6 +148,7 @@
|
|||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Shift') {
|
if (event.key === 'Shift') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
shiftKeyIsDown = true;
|
shiftKeyIsDown = true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -90,7 +164,7 @@
|
|||||||
if (!asset) {
|
if (!asset) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const deselect = assetInteraction.selectedAssets.has(asset);
|
const deselect = assetInteraction.hasSelectedAsset(asset.id);
|
||||||
|
|
||||||
// Select/deselect already loaded assets
|
// Select/deselect already loaded assets
|
||||||
if (deselect) {
|
if (deselect) {
|
||||||
@ -173,7 +247,7 @@
|
|||||||
const toggleArchive = async () => {
|
const toggleArchive = async () => {
|
||||||
const ids = await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived);
|
const ids = await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived);
|
||||||
if (ids) {
|
if (ids) {
|
||||||
assets.filter((asset) => !ids.includes(asset.id));
|
assets = assets.filter((asset) => !ids.includes(asset.id));
|
||||||
deselectAllAssets();
|
deselectAllAssets();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -248,7 +322,7 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRandom = async (): Promise<AssetResponseDto | null> => {
|
const handleRandom = async (): Promise<AssetResponseDto | undefined> => {
|
||||||
try {
|
try {
|
||||||
let asset: AssetResponseDto | undefined;
|
let asset: AssetResponseDto | undefined;
|
||||||
if (onRandom) {
|
if (onRandom) {
|
||||||
@ -261,14 +335,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
return null;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await navigateToAsset(asset);
|
await navigateToAsset(asset);
|
||||||
return asset;
|
return asset;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.cannot_navigate_next_asset'));
|
handleError(error, $t('errors.cannot_navigate_next_asset'));
|
||||||
return null;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -335,26 +409,6 @@
|
|||||||
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
|
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
|
||||||
let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id));
|
let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id));
|
||||||
|
|
||||||
let geometry = $derived(
|
|
||||||
(() => {
|
|
||||||
const justifiedLayoutResult = justifiedLayout(
|
|
||||||
assets.map((asset) => getAssetRatio(asset)),
|
|
||||||
{
|
|
||||||
boxSpacing: 2,
|
|
||||||
containerWidth: Math.floor(viewport.width),
|
|
||||||
containerPadding: 0,
|
|
||||||
targetRowHeightTolerance: 0.15,
|
|
||||||
targetRowHeight: 235,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...justifiedLayoutResult,
|
|
||||||
containerWidth: calculateWidth(justifiedLayoutResult.boxes),
|
|
||||||
};
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!lastAssetMouseEvent) {
|
if (!lastAssetMouseEvent) {
|
||||||
assetInteraction.clearAssetSelectionCandidates();
|
assetInteraction.clearAssetSelectionCandidates();
|
||||||
@ -374,7 +428,13 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={onKeyDown} onkeyup={onKeyUp} onselectstart={onSelectStart} use:shortcuts={shortcutList} />
|
<svelte:window
|
||||||
|
onkeydown={onKeyDown}
|
||||||
|
onkeyup={onKeyUp}
|
||||||
|
onselectstart={onSelectStart}
|
||||||
|
use:shortcuts={shortcutList}
|
||||||
|
onscroll={() => updateSlidingWindow()}
|
||||||
|
/>
|
||||||
|
|
||||||
{#if isShowDeleteConfirmation}
|
{#if isShowDeleteConfirmation}
|
||||||
<DeleteAssetDialog
|
<DeleteAssetDialog
|
||||||
@ -389,12 +449,19 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if assets.length > 0}
|
{#if assets.length > 0}
|
||||||
<div class="relative" style="height: {geometry.containerHeight}px;width: {geometry.containerWidth}px ">
|
<div
|
||||||
{#each assets as asset, i (i)}
|
style:position="relative"
|
||||||
|
style:height={assetLayouts.containerHeight + 'px'}
|
||||||
|
style:width={assetLayouts.containerWidth - 1 + 'px'}
|
||||||
|
>
|
||||||
|
{#each assetLayouts.assetLayout as layout (layout.asset.id)}
|
||||||
|
{@const asset = layout.asset}
|
||||||
|
|
||||||
|
{#if layout.display}
|
||||||
<div
|
<div
|
||||||
class="absolute"
|
class="absolute"
|
||||||
style="width: {geometry.boxes[i].width}px; height: {geometry.boxes[i].height}px; top: {geometry.boxes[i]
|
style:overflow="clip"
|
||||||
.top}px; left: {geometry.boxes[i].left}px"
|
style="width: {layout.width}px; height: {layout.height}px; top: {layout.top}px; left: {layout.left}px"
|
||||||
title={showAssetName ? asset.originalFileName : ''}
|
title={showAssetName ? asset.originalFileName : ''}
|
||||||
>
|
>
|
||||||
<Thumbnail
|
<Thumbnail
|
||||||
@ -409,14 +476,13 @@
|
|||||||
onSelect={(asset) => handleSelectAssets(asset)}
|
onSelect={(asset) => handleSelectAssets(asset)}
|
||||||
onMouseEvent={() => assetMouseEventHandler(asset)}
|
onMouseEvent={() => assetMouseEventHandler(asset)}
|
||||||
handleFocus={() => assetOnFocusHandler(asset)}
|
handleFocus={() => assetOnFocusHandler(asset)}
|
||||||
onIntersected={() => (i === Math.max(1, assets.length - 7) ? onIntersected?.() : void 0)}
|
|
||||||
{showArchiveIcon}
|
{showArchiveIcon}
|
||||||
{asset}
|
{asset}
|
||||||
selected={assetInteraction.selectedAssets.has(asset)}
|
selected={assetInteraction.hasSelectedAsset(asset.id)}
|
||||||
|
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
|
||||||
focussed={assetInteraction.isFocussedAsset(asset)}
|
focussed={assetInteraction.isFocussedAsset(asset)}
|
||||||
selectionCandidate={assetInteraction.assetSelectionCandidates.has(asset)}
|
thumbnailWidth={layout.width}
|
||||||
thumbnailWidth={geometry.boxes[i].width}
|
thumbnailHeight={layout.height}
|
||||||
thumbnailHeight={geometry.boxes[i].height}
|
|
||||||
/>
|
/>
|
||||||
{#if showAssetName}
|
{#if showAssetName}
|
||||||
<div
|
<div
|
||||||
@ -426,6 +492,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { AssetStore, AssetBucket, BucketListener } from '$lib/stores/assets-store.svelte';
|
import type { AssetStore, LiteBucket } from '$lib/stores/assets-store.svelte';
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
import { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util';
|
import { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util';
|
||||||
import { clamp } from 'lodash-es';
|
import { clamp } from 'lodash-es';
|
||||||
import { onMount } from 'svelte';
|
import { DateTime } from 'luxon';
|
||||||
import { isTimelineScrolling } from '$lib/stores/timeline.store';
|
|
||||||
import { fade, fly } from 'svelte/transition';
|
import { fade, fly } from 'svelte/transition';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -15,11 +13,12 @@
|
|||||||
invisible?: boolean;
|
invisible?: boolean;
|
||||||
scrubOverallPercent?: number;
|
scrubOverallPercent?: number;
|
||||||
scrubBucketPercent?: number;
|
scrubBucketPercent?: number;
|
||||||
scrubBucket?: { bucketDate: string | undefined } | undefined;
|
scrubBucket?: { bucketDate: string | undefined };
|
||||||
leadout?: boolean;
|
leadout?: boolean;
|
||||||
onScrub?: ScrubberListener | undefined;
|
onScrub?: ScrubberListener;
|
||||||
startScrub?: ScrubberListener | undefined;
|
onScrubKeyDown?: (event: KeyboardEvent, element: HTMLElement) => void;
|
||||||
stopScrub?: ScrubberListener | undefined;
|
startScrub?: ScrubberListener;
|
||||||
|
stopScrub?: ScrubberListener;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@ -27,25 +26,22 @@
|
|||||||
timelineBottomOffset = 0,
|
timelineBottomOffset = 0,
|
||||||
height = 0,
|
height = 0,
|
||||||
assetStore,
|
assetStore,
|
||||||
invisible = false,
|
|
||||||
scrubOverallPercent = 0,
|
scrubOverallPercent = 0,
|
||||||
scrubBucketPercent = 0,
|
scrubBucketPercent = 0,
|
||||||
scrubBucket = undefined,
|
scrubBucket = undefined,
|
||||||
leadout = false,
|
leadout = false,
|
||||||
onScrub = undefined,
|
onScrub = undefined,
|
||||||
|
onScrubKeyDown = undefined,
|
||||||
startScrub = undefined,
|
startScrub = undefined,
|
||||||
stopScrub = undefined,
|
stopScrub = undefined,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let isHover = $state(false);
|
let isHover = $state(false);
|
||||||
let isDragging = $state(false);
|
let isDragging = $state(false);
|
||||||
let hoverLabel: string | undefined = $state();
|
|
||||||
let bucketDate: string | undefined;
|
|
||||||
let hoverY = $state(0);
|
let hoverY = $state(0);
|
||||||
let clientY = 0;
|
let clientY = 0;
|
||||||
let windowHeight = $state(0);
|
let windowHeight = $state(0);
|
||||||
let scrollBar: HTMLElement | undefined = $state();
|
let scrollBar: HTMLElement | undefined = $state();
|
||||||
let segments: Segment[] = $state([]);
|
|
||||||
|
|
||||||
const toScrollY = (percent: number) => percent * (height - HOVER_DATE_HEIGHT * 2);
|
const toScrollY = (percent: number) => percent * (height - HOVER_DATE_HEIGHT * 2);
|
||||||
const toTimelineY = (scrollY: number) => scrollY / (height - HOVER_DATE_HEIGHT * 2);
|
const toTimelineY = (scrollY: number) => scrollY / (height - HOVER_DATE_HEIGHT * 2);
|
||||||
@ -87,28 +83,11 @@
|
|||||||
return scrubOverallPercent * (height - HOVER_DATE_HEIGHT * 2) - 2;
|
return scrubOverallPercent * (height - HOVER_DATE_HEIGHT * 2) - 2;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let scrollY = $state(0);
|
let scrollY = $derived(toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent));
|
||||||
$effect(() => {
|
let timelineFullHeight = $derived(assetStore.scrubberTimelineHeight + timelineTopOffset + timelineBottomOffset);
|
||||||
scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent);
|
|
||||||
});
|
|
||||||
|
|
||||||
let timelineFullHeight = $derived(assetStore.timelineHeight + 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));
|
||||||
|
|
||||||
const listener: BucketListener = (event) => {
|
|
||||||
const { type } = event;
|
|
||||||
if (type === 'viewport') {
|
|
||||||
segments = calculateSegments(assetStore.buckets);
|
|
||||||
scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
assetStore.addListener(listener);
|
|
||||||
return () => assetStore.removeListener(listener);
|
|
||||||
});
|
|
||||||
|
|
||||||
type Segment = {
|
type Segment = {
|
||||||
count: number;
|
count: number;
|
||||||
height: number;
|
height: number;
|
||||||
@ -119,7 +98,7 @@
|
|||||||
hasDot: boolean;
|
hasDot: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateSegments = (buckets: AssetBucket[]) => {
|
const calculateSegments = (buckets: LiteBucket[]) => {
|
||||||
let height = 0;
|
let height = 0;
|
||||||
let dotHeight = 0;
|
let dotHeight = 0;
|
||||||
|
|
||||||
@ -127,11 +106,10 @@
|
|||||||
let previousLabeledSegment: Segment | undefined;
|
let previousLabeledSegment: Segment | undefined;
|
||||||
|
|
||||||
for (const [i, bucket] of buckets.entries()) {
|
for (const [i, bucket] of buckets.entries()) {
|
||||||
const scrollBarPercentage =
|
const scrollBarPercentage = bucket.bucketHeight / timelineFullHeight;
|
||||||
bucket.bucketHeight / (assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset);
|
|
||||||
|
|
||||||
const segment = {
|
const segment = {
|
||||||
count: bucket.assets.length,
|
count: bucket.assetCount,
|
||||||
height: toScrollY(scrollBarPercentage),
|
height: toScrollY(scrollBarPercentage),
|
||||||
bucketDate: bucket.bucketDate,
|
bucketDate: bucket.bucketDate,
|
||||||
date: fromLocalDateTime(bucket.bucketDate),
|
date: fromLocalDateTime(bucket.bucketDate),
|
||||||
@ -161,14 +139,23 @@
|
|||||||
segments.push(segment);
|
segments.push(segment);
|
||||||
}
|
}
|
||||||
|
|
||||||
hoverLabel = segments[0]?.dateFormatted;
|
|
||||||
return segments;
|
return segments;
|
||||||
};
|
};
|
||||||
|
let activeSegment: HTMLElement | undefined = $state();
|
||||||
const updateLabel = (segment: HTMLElement) => {
|
const segments = $derived(calculateSegments(assetStore.scrubberBuckets));
|
||||||
hoverLabel = segment.dataset.label;
|
const hoverLabel = $derived(activeSegment?.dataset.label);
|
||||||
bucketDate = segment.dataset.timeSegmentBucketDate;
|
const bucketDate = $derived(activeSegment?.dataset.timeSegmentBucketDate);
|
||||||
};
|
const scrollHoverLabel = $derived.by(() => {
|
||||||
|
const y = scrollY;
|
||||||
|
let cur = 0;
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (y <= cur + segment.height + relativeTopOffset) {
|
||||||
|
return segment.dateFormatted;
|
||||||
|
}
|
||||||
|
cur += segment.height;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
const handleMouseEvent = (event: { clientY: number; isDragging?: boolean }) => {
|
const handleMouseEvent = (event: { clientY: number; isDragging?: boolean }) => {
|
||||||
const wasDragging = isDragging;
|
const wasDragging = isDragging;
|
||||||
@ -189,7 +176,8 @@
|
|||||||
const segment = elems.find(({ id }) => id === 'time-segment');
|
const segment = elems.find(({ id }) => id === 'time-segment');
|
||||||
let bucketPercentY = 0;
|
let bucketPercentY = 0;
|
||||||
if (segment) {
|
if (segment) {
|
||||||
updateLabel(segment as HTMLElement);
|
activeSegment = segment as HTMLElement;
|
||||||
|
|
||||||
const sr = segment.getBoundingClientRect();
|
const sr = segment.getBoundingClientRect();
|
||||||
const sy = sr.y;
|
const sy = sr.y;
|
||||||
const relativeY = clientY - sy;
|
const relativeY = clientY - sy;
|
||||||
@ -197,9 +185,9 @@
|
|||||||
} else {
|
} else {
|
||||||
const leadin = elems.find(({ id }) => id === 'lead-in');
|
const leadin = elems.find(({ id }) => id === 'lead-in');
|
||||||
if (leadin) {
|
if (leadin) {
|
||||||
updateLabel(leadin as HTMLElement);
|
activeSegment = leadin as HTMLElement;
|
||||||
} else {
|
} else {
|
||||||
bucketDate = undefined;
|
activeSegment = undefined;
|
||||||
bucketPercentY = 0;
|
bucketPercentY = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -230,27 +218,34 @@
|
|||||||
onmouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })}
|
onmouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
transition:fly={{ x: 50, duration: 250 }}
|
transition:fly={{ x: 50, duration: 250 }}
|
||||||
|
tabindex="-1"
|
||||||
|
role="scrollbar"
|
||||||
|
aria-controls="time-label"
|
||||||
|
aria-valuenow={scrollY + HOVER_DATE_HEIGHT}
|
||||||
|
aria-valuemax={toScrollY(100)}
|
||||||
|
aria-valuemin={toScrollY(0)}
|
||||||
id="immich-scrubbable-scrollbar"
|
id="immich-scrubbable-scrollbar"
|
||||||
class="absolute right-0 z-[1] select-none bg-immich-bg hover:cursor-row-resize"
|
class="absolute right-0 z-[1] select-none bg-immich-bg hover:cursor-row-resize"
|
||||||
style:padding-top={HOVER_DATE_HEIGHT + 'px'}
|
style:padding-top={HOVER_DATE_HEIGHT + 'px'}
|
||||||
style:padding-bottom={HOVER_DATE_HEIGHT + 'px'}
|
style:padding-bottom={HOVER_DATE_HEIGHT + 'px'}
|
||||||
class:invisible
|
|
||||||
style:width={isDragging ? '100vw' : '60px'}
|
style:width={isDragging ? '100vw' : '60px'}
|
||||||
style:height={height + 'px'}
|
style:height={height + 'px'}
|
||||||
style:background-color={isDragging ? 'transparent' : 'transparent'}
|
style:background-color={isDragging ? 'transparent' : 'transparent'}
|
||||||
draggable="false"
|
|
||||||
bind:this={scrollBar}
|
bind:this={scrollBar}
|
||||||
onmouseenter={() => (isHover = true)}
|
onmouseenter={() => (isHover = true)}
|
||||||
onmouseleave={() => (isHover = false)}
|
onmouseleave={() => (isHover = false)}
|
||||||
|
onkeydown={(event) => onScrubKeyDown?.(event, event.currentTarget)}
|
||||||
>
|
>
|
||||||
{#if hoverLabel && (isHover || isDragging)}
|
{#if hoverLabel && (isHover || isDragging)}
|
||||||
<div
|
<div
|
||||||
id="time-label"
|
id="time-label"
|
||||||
class="truncate opacity-85 pointer-events-none absolute right-0 z-[100] min-w-20 max-w-64 w-fit rounded-tl-md border-b-2 border-immich-primary bg-immich-bg py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:bg-immich-dark-gray dark:text-immich-dark-fg"
|
class={[
|
||||||
|
{ 'border-b-2': isDragging },
|
||||||
|
{ 'rounded-bl-md': !isDragging },
|
||||||
|
'truncate opacity-85 pointer-events-none absolute right-0 z-[100] min-w-20 max-w-64 w-fit rounded-tl-md border-immich-primary bg-immich-bg py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:bg-immich-dark-gray dark:text-immich-dark-fg',
|
||||||
|
]}
|
||||||
style:top="{hoverY + 2}px"
|
style:top="{hoverY + 2}px"
|
||||||
>
|
>
|
||||||
{hoverLabel}
|
{hoverLabel}
|
||||||
@ -262,12 +257,12 @@
|
|||||||
class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary"
|
class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary"
|
||||||
style:top="{scrollY + HOVER_DATE_HEIGHT}px"
|
style:top="{scrollY + HOVER_DATE_HEIGHT}px"
|
||||||
>
|
>
|
||||||
{#if $isTimelineScrolling && scrubBucket?.bucketDate}
|
{#if assetStore.scrolling && scrollHoverLabel}
|
||||||
<p
|
<p
|
||||||
transition:fade={{ duration: 200 }}
|
transition:fade={{ duration: 200 }}
|
||||||
class="truncate pointer-events-none absolute right-0 bottom-0 z-[100] min-w-20 max-w-64 w-fit rounded-tl-md border-b-2 border-immich-primary bg-immich-bg/80 py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:bg-immich-dark-gray/80 dark:text-immich-dark-fg"
|
class="truncate pointer-events-none absolute right-0 bottom-0 z-[100] min-w-20 max-w-64 w-fit rounded-tl-md border-b-2 border-immich-primary bg-immich-bg/80 py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:bg-immich-dark-gray/80 dark:text-immich-dark-fg"
|
||||||
>
|
>
|
||||||
{assetStore.getBucketByDate(scrubBucket.bucketDate)?.bucketDateFormattted}
|
{scrollHoverLabel}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
@ -121,15 +121,14 @@
|
|||||||
|
|
||||||
<Portal target="body">
|
<Portal target="body">
|
||||||
{#if showMessage}
|
{#if showMessage}
|
||||||
<div
|
<dialog
|
||||||
|
open
|
||||||
class="w-[500px] absolute bottom-[75px] left-[255px] bg-gray-50 dark:border-gray-800 border border-gray-200 dark:bg-immich-dark-gray dark:text-white text-black rounded-3xl z-10 shadow-2xl px-8 py-6"
|
class="w-[500px] absolute bottom-[75px] left-[255px] bg-gray-50 dark:border-gray-800 border border-gray-200 dark:bg-immich-dark-gray dark:text-white text-black rounded-3xl z-10 shadow-2xl px-8 py-6"
|
||||||
transition:fade={{ duration: 150 }}
|
transition:fade={{ duration: 150 }}
|
||||||
onmouseover={() => (hoverMessage = true)}
|
onmouseover={() => (hoverMessage = true)}
|
||||||
onmouseleave={() => (hoverMessage = false)}
|
onmouseleave={() => (hoverMessage = false)}
|
||||||
onfocus={() => (hoverMessage = true)}
|
onfocus={() => (hoverMessage = true)}
|
||||||
onblur={() => (hoverMessage = false)}
|
onblur={() => (hoverMessage = false)}
|
||||||
role="dialog"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
<div class="flex justify-between place-items-center">
|
<div class="flex justify-between place-items-center">
|
||||||
<div class="h-10 w-10">
|
<div class="h-10 w-10">
|
||||||
@ -178,6 +177,12 @@
|
|||||||
{$t('purchase_button_reminder')}
|
{$t('purchase_button_reminder')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</dialog>
|
||||||
{/if}
|
{/if}
|
||||||
</Portal>
|
</Portal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
dialog {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@ -45,7 +45,7 @@
|
|||||||
onclick={() => {}}
|
onclick={() => {}}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
{#each pathSegments as segment, index (segment)}
|
{#each pathSegments as segment, index (index)}
|
||||||
{@const isLastSegment = index === pathSegments.length - 1}
|
{@const isLastSegment = index === pathSegments.length - 1}
|
||||||
<li
|
<li
|
||||||
class="flex gap-2 items-center font-mono text-sm text-nowrap text-immich-primary dark:text-immich-dark-primary"
|
class="flex gap-2 items-center font-mono text-sm text-nowrap text-immich-primary dark:text-immich-dark-primary"
|
||||||
|
@ -62,7 +62,7 @@
|
|||||||
|
|
||||||
const onRandom = () => {
|
const onRandom = () => {
|
||||||
if (assets.length <= 0) {
|
if (assets.length <= 0) {
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(undefined);
|
||||||
}
|
}
|
||||||
const index = Math.floor(Math.random() * assets.length);
|
const index = Math.floor(Math.random() * assets.length);
|
||||||
const asset = assets[index];
|
const asset = assets[index];
|
||||||
|
@ -358,15 +358,24 @@ export enum SettingInputFieldType {
|
|||||||
COLOR = 'color',
|
COLOR = 'color',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AlbumPageViewMode {
|
export const AlbumPageViewMode = {
|
||||||
LINK_SHARING = 'link-sharing',
|
LINK_SHARING: 'link-sharing',
|
||||||
SELECT_USERS = 'select-users',
|
SELECT_USERS: 'select-users',
|
||||||
SELECT_THUMBNAIL = 'select-thumbnail',
|
SELECT_THUMBNAIL: 'select-thumbnail',
|
||||||
SELECT_ASSETS = 'select-assets',
|
SELECT_ASSETS: 'select-assets',
|
||||||
VIEW_USERS = 'view-users',
|
VIEW_USERS: 'view-users',
|
||||||
VIEW = 'view',
|
VIEW: 'view',
|
||||||
OPTIONS = 'options',
|
OPTIONS: 'options',
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export type AlbumPageViewMode =
|
||||||
|
| typeof AlbumPageViewMode.LINK_SHARING
|
||||||
|
| typeof AlbumPageViewMode.SELECT_USERS
|
||||||
|
| typeof AlbumPageViewMode.SELECT_THUMBNAIL
|
||||||
|
| typeof AlbumPageViewMode.SELECT_ASSETS
|
||||||
|
| typeof AlbumPageViewMode.VIEW_USERS
|
||||||
|
| typeof AlbumPageViewMode.VIEW
|
||||||
|
| typeof AlbumPageViewMode.OPTIONS;
|
||||||
|
|
||||||
export enum PersonPageViewMode {
|
export enum PersonPageViewMode {
|
||||||
VIEW_ASSETS = 'view-assets',
|
VIEW_ASSETS = 'view-assets',
|
||||||
|
@ -5,8 +5,14 @@ import { fromStore } from 'svelte/store';
|
|||||||
|
|
||||||
export class AssetInteraction {
|
export class AssetInteraction {
|
||||||
readonly selectedAssets = new SvelteSet<AssetResponseDto>();
|
readonly selectedAssets = new SvelteSet<AssetResponseDto>();
|
||||||
|
hasSelectedAsset(assetId: string) {
|
||||||
|
return [...this.selectedAssets.values()].some((asset) => asset.id === assetId);
|
||||||
|
}
|
||||||
readonly selectedGroup = new SvelteSet<string>();
|
readonly selectedGroup = new SvelteSet<string>();
|
||||||
assetSelectionCandidates = $state(new SvelteSet<AssetResponseDto>());
|
assetSelectionCandidates = $state(new SvelteSet<AssetResponseDto>());
|
||||||
|
hasSelectionCandidate(assetId: string) {
|
||||||
|
return [...this.assetSelectionCandidates.values()].some((asset) => asset.id === assetId);
|
||||||
|
}
|
||||||
assetSelectionStart = $state<AssetResponseDto | null>(null);
|
assetSelectionStart = $state<AssetResponseDto | null>(null);
|
||||||
focussedAssetId = $state<string | null>(null);
|
focussedAssetId = $state<string | null>(null);
|
||||||
|
|
||||||
@ -32,7 +38,10 @@ export class AssetInteraction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
removeAssetFromMultiselectGroup(asset: AssetResponseDto) {
|
removeAssetFromMultiselectGroup(asset: AssetResponseDto) {
|
||||||
this.selectedAssets.delete(asset);
|
const selectedAsset = [...this.selectedAssets.values()].find((a) => a.id === asset.id);
|
||||||
|
if (selectedAsset) {
|
||||||
|
this.selectedAssets.delete(selectedAsset);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addGroupToMultiselectGroup(group: string) {
|
addGroupToMultiselectGroup(group: string) {
|
||||||
|
@ -12,21 +12,25 @@ describe('AssetStore', () => {
|
|||||||
describe('init', () => {
|
describe('init', () => {
|
||||||
let assetStore: AssetStore;
|
let assetStore: AssetStore;
|
||||||
const bucketAssets: Record<string, AssetResponseDto[]> = {
|
const bucketAssets: Record<string, AssetResponseDto[]> = {
|
||||||
'2024-03-01T00:00:00.000Z': assetFactory.buildList(1),
|
'2024-03-01T00:00:00.000Z': assetFactory
|
||||||
'2024-02-01T00:00:00.000Z': assetFactory.buildList(100),
|
.buildList(1)
|
||||||
'2024-01-01T00:00:00.000Z': assetFactory.buildList(3),
|
.map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })),
|
||||||
|
'2024-02-01T00:00:00.000Z': assetFactory
|
||||||
|
.buildList(100)
|
||||||
|
.map((asset) => ({ ...asset, localDateTime: '2024-02-01T00:00:00.000Z' })),
|
||||||
|
'2024-01-01T00:00:00.000Z': assetFactory
|
||||||
|
.buildList(3)
|
||||||
|
.map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
assetStore = new AssetStore({});
|
assetStore = new AssetStore();
|
||||||
sdkMock.getTimeBuckets.mockResolvedValue([
|
sdkMock.getTimeBuckets.mockResolvedValue([
|
||||||
{ count: 1, timeBucket: '2024-03-01T00:00:00.000Z' },
|
{ count: 1, timeBucket: '2024-03-01T00:00:00.000Z' },
|
||||||
{ count: 100, timeBucket: '2024-02-01T00:00:00.000Z' },
|
{ count: 100, timeBucket: '2024-02-01T00:00:00.000Z' },
|
||||||
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
|
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
|
||||||
]);
|
]);
|
||||||
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket]));
|
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket]));
|
||||||
|
|
||||||
await assetStore.init();
|
|
||||||
await assetStore.updateViewport({ width: 1588, height: 1000 });
|
await assetStore.updateViewport({ width: 1588, height: 1000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -37,51 +41,57 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('calculates bucket height', () => {
|
it('calculates bucket height', () => {
|
||||||
expect(assetStore.buckets).toEqual(
|
const plainBuckets = assetStore.buckets.map((bucket) => ({
|
||||||
|
bucketDate: bucket.bucketDate,
|
||||||
|
bucketHeight: bucket.bucketHeight,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(plainBuckets).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 286 }),
|
expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 304 }),
|
||||||
expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 3811 }),
|
expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 4515.333_333_333_333 }),
|
||||||
expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 286 }),
|
expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 286 }),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calculates timeline height', () => {
|
it('calculates timeline height', () => {
|
||||||
expect(assetStore.timelineHeight).toBe(4383);
|
expect(assetStore.timelineHeight).toBe(5105.333_333_333_333);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('loadBucket', () => {
|
describe('loadBucket', () => {
|
||||||
let assetStore: AssetStore;
|
let assetStore: AssetStore;
|
||||||
const bucketAssets: Record<string, AssetResponseDto[]> = {
|
const bucketAssets: Record<string, AssetResponseDto[]> = {
|
||||||
'2024-01-03T00:00:00.000Z': assetFactory.buildList(1),
|
'2024-01-03T00:00:00.000Z': assetFactory
|
||||||
'2024-01-01T00:00:00.000Z': assetFactory.buildList(3),
|
.buildList(1)
|
||||||
|
.map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })),
|
||||||
|
'2024-01-01T00:00:00.000Z': assetFactory
|
||||||
|
.buildList(3)
|
||||||
|
.map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
assetStore = new AssetStore({});
|
assetStore = new AssetStore();
|
||||||
sdkMock.getTimeBuckets.mockResolvedValue([
|
sdkMock.getTimeBuckets.mockResolvedValue([
|
||||||
{ count: 1, timeBucket: '2024-01-03T00:00:00.000Z' },
|
{ count: 1, timeBucket: '2024-03-01T00:00:00.000Z' },
|
||||||
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
|
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
|
||||||
]);
|
]);
|
||||||
sdkMock.getTimeBucket.mockImplementation(async ({ timeBucket }, { signal } = {}) => {
|
sdkMock.getTimeBucket.mockImplementation(async ({ timeBucket }, { signal } = {}) => {
|
||||||
// Allow request to be aborted
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
throw new AbortError();
|
throw new AbortError();
|
||||||
}
|
}
|
||||||
|
|
||||||
return bucketAssets[timeBucket];
|
return bucketAssets[timeBucket];
|
||||||
});
|
});
|
||||||
await assetStore.init();
|
await assetStore.updateViewport({ width: 1588, height: 0 });
|
||||||
await assetStore.updateViewport({ width: 0, height: 0 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads a bucket', async () => {
|
it('loads a bucket', async () => {
|
||||||
expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(0);
|
expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(0);
|
||||||
await assetStore.loadBucket('2024-01-01T00:00:00.000Z');
|
await assetStore.loadBucket('2024-01-01T00:00:00.000Z');
|
||||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||||
expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(3);
|
expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores invalid buckets', async () => {
|
it('ignores invalid buckets', async () => {
|
||||||
@ -90,15 +100,13 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('cancels bucket loading', async () => {
|
it('cancels bucket loading', async () => {
|
||||||
const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z');
|
const bucket = assetStore.getBucketByDate(2024, 1)!;
|
||||||
const loadPromise = assetStore.loadBucket(bucket!.bucketDate);
|
void assetStore.loadBucket(bucket!.bucketDate);
|
||||||
|
const abortSpy = vi.spyOn(bucket!.loader!.cancelToken!, 'abort');
|
||||||
const abortSpy = vi.spyOn(bucket!.cancelToken!, 'abort');
|
|
||||||
bucket?.cancel();
|
bucket?.cancel();
|
||||||
expect(abortSpy).toBeCalledTimes(1);
|
expect(abortSpy).toBeCalledTimes(1);
|
||||||
|
await assetStore.loadBucket(bucket!.bucketDate);
|
||||||
await loadPromise;
|
expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(3);
|
||||||
expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prevents loading buckets multiple times', async () => {
|
it('prevents loading buckets multiple times', async () => {
|
||||||
@ -113,15 +121,15 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('allows loading a canceled bucket', async () => {
|
it('allows loading a canceled bucket', async () => {
|
||||||
const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z');
|
const bucket = assetStore.getBucketByDate(2024, 1)!;
|
||||||
const loadPromise = assetStore.loadBucket(bucket!.bucketDate);
|
const loadPromise = assetStore.loadBucket(bucket!.bucketDate);
|
||||||
|
|
||||||
bucket?.cancel();
|
bucket.cancel();
|
||||||
await loadPromise;
|
await loadPromise;
|
||||||
expect(bucket?.assets.length).toEqual(0);
|
expect(bucket?.getAssets().length).toEqual(0);
|
||||||
|
|
||||||
await assetStore.loadBucket(bucket!.bucketDate);
|
await assetStore.loadBucket(bucket.bucketDate);
|
||||||
expect(bucket!.assets.length).toEqual(3);
|
expect(bucket!.getAssets().length).toEqual(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -129,15 +137,15 @@ describe('AssetStore', () => {
|
|||||||
let assetStore: AssetStore;
|
let assetStore: AssetStore;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
assetStore = new AssetStore({});
|
assetStore = new AssetStore();
|
||||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||||
await assetStore.init();
|
|
||||||
await assetStore.updateViewport({ width: 1588, height: 1000 });
|
await assetStore.updateViewport({ width: 1588, height: 1000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is empty initially', () => {
|
it('is empty initially', () => {
|
||||||
expect(assetStore.buckets.length).toEqual(0);
|
expect(assetStore.buckets.length).toEqual(0);
|
||||||
expect(assetStore.assets.length).toEqual(0);
|
expect(assetStore.getAssets().length).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adds assets to new bucket', () => {
|
it('adds assets to new bucket', () => {
|
||||||
@ -148,10 +156,10 @@ describe('AssetStore', () => {
|
|||||||
assetStore.addAssets([asset]);
|
assetStore.addAssets([asset]);
|
||||||
|
|
||||||
expect(assetStore.buckets.length).toEqual(1);
|
expect(assetStore.buckets.length).toEqual(1);
|
||||||
expect(assetStore.assets.length).toEqual(1);
|
expect(assetStore.getAssets().length).toEqual(1);
|
||||||
expect(assetStore.buckets[0].assets.length).toEqual(1);
|
expect(assetStore.buckets[0].getAssets().length).toEqual(1);
|
||||||
expect(assetStore.buckets[0].bucketDate).toEqual('2024-01-01T00:00:00.000Z');
|
expect(assetStore.buckets[0].bucketDate).toEqual('2024-01-01T00:00:00.000Z');
|
||||||
expect(assetStore.assets[0].id).toEqual(asset.id);
|
expect(assetStore.getAssets()[0].id).toEqual(asset.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adds assets to existing bucket', () => {
|
it('adds assets to existing bucket', () => {
|
||||||
@ -163,8 +171,8 @@ describe('AssetStore', () => {
|
|||||||
assetStore.addAssets([assetTwo]);
|
assetStore.addAssets([assetTwo]);
|
||||||
|
|
||||||
expect(assetStore.buckets.length).toEqual(1);
|
expect(assetStore.buckets.length).toEqual(1);
|
||||||
expect(assetStore.assets.length).toEqual(2);
|
expect(assetStore.getAssets().length).toEqual(2);
|
||||||
expect(assetStore.buckets[0].assets.length).toEqual(2);
|
expect(assetStore.buckets[0].getAssets().length).toEqual(2);
|
||||||
expect(assetStore.buckets[0].bucketDate).toEqual('2024-01-01T00:00:00.000Z');
|
expect(assetStore.buckets[0].bucketDate).toEqual('2024-01-01T00:00:00.000Z');
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -183,12 +191,12 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
assetStore.addAssets([assetOne, assetTwo, assetThree]);
|
assetStore.addAssets([assetOne, assetTwo, assetThree]);
|
||||||
|
|
||||||
const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z');
|
const bucket = assetStore.getBucketByDate(2024, 1);
|
||||||
expect(bucket).not.toBeNull();
|
expect(bucket).not.toBeNull();
|
||||||
expect(bucket?.assets.length).toEqual(3);
|
expect(bucket?.getAssets().length).toEqual(3);
|
||||||
expect(bucket?.assets[0].id).toEqual(assetOne.id);
|
expect(bucket?.getAssets()[0].id).toEqual(assetOne.id);
|
||||||
expect(bucket?.assets[1].id).toEqual(assetThree.id);
|
expect(bucket?.getAssets()[1].id).toEqual(assetThree.id);
|
||||||
expect(bucket?.assets[2].id).toEqual(assetTwo.id);
|
expect(bucket?.getAssets()[2].id).toEqual(assetTwo.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('orders buckets by descending date', () => {
|
it('orders buckets by descending date', () => {
|
||||||
@ -210,17 +218,18 @@ describe('AssetStore', () => {
|
|||||||
|
|
||||||
assetStore.addAssets([asset]);
|
assetStore.addAssets([asset]);
|
||||||
expect(updateAssetsSpy).toBeCalledWith([asset]);
|
expect(updateAssetsSpy).toBeCalledWith([asset]);
|
||||||
expect(assetStore.assets.length).toEqual(1);
|
expect(assetStore.getAssets().length).toEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// disabled due to the wasm Justified Layout import
|
// disabled due to the wasm Justified Layout import
|
||||||
it.skip('ignores trashed assets when isTrashed is true', () => {
|
it('ignores trashed assets when isTrashed is true', async () => {
|
||||||
const asset = assetFactory.build({ isTrashed: false });
|
const asset = assetFactory.build({ isTrashed: false });
|
||||||
const trashedAsset = assetFactory.build({ isTrashed: true });
|
const trashedAsset = assetFactory.build({ isTrashed: true });
|
||||||
|
|
||||||
const assetStore = new AssetStore({ isTrashed: true });
|
const assetStore = new AssetStore();
|
||||||
|
await assetStore.updateOptions({ isTrashed: true });
|
||||||
assetStore.addAssets([asset, trashedAsset]);
|
assetStore.addAssets([asset, trashedAsset]);
|
||||||
expect(assetStore.assets).toEqual([trashedAsset]);
|
expect(assetStore.getAssets()).toEqual([trashedAsset]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -228,9 +237,9 @@ describe('AssetStore', () => {
|
|||||||
let assetStore: AssetStore;
|
let assetStore: AssetStore;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
assetStore = new AssetStore({});
|
assetStore = new AssetStore();
|
||||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||||
await assetStore.init();
|
|
||||||
await assetStore.updateViewport({ width: 1588, height: 1000 });
|
await assetStore.updateViewport({ width: 1588, height: 1000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -238,7 +247,7 @@ describe('AssetStore', () => {
|
|||||||
assetStore.updateAssets([assetFactory.build()]);
|
assetStore.updateAssets([assetFactory.build()]);
|
||||||
|
|
||||||
expect(assetStore.buckets.length).toEqual(0);
|
expect(assetStore.buckets.length).toEqual(0);
|
||||||
expect(assetStore.assets.length).toEqual(0);
|
expect(assetStore.getAssets().length).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates an asset', () => {
|
it('updates an asset', () => {
|
||||||
@ -246,26 +255,29 @@ describe('AssetStore', () => {
|
|||||||
const updatedAsset = { ...asset, isFavorite: true };
|
const updatedAsset = { ...asset, isFavorite: true };
|
||||||
|
|
||||||
assetStore.addAssets([asset]);
|
assetStore.addAssets([asset]);
|
||||||
expect(assetStore.assets.length).toEqual(1);
|
expect(assetStore.getAssets().length).toEqual(1);
|
||||||
expect(assetStore.assets[0].isFavorite).toEqual(false);
|
expect(assetStore.getAssets()[0].isFavorite).toEqual(false);
|
||||||
|
|
||||||
assetStore.updateAssets([updatedAsset]);
|
assetStore.updateAssets([updatedAsset]);
|
||||||
expect(assetStore.assets.length).toEqual(1);
|
expect(assetStore.getAssets().length).toEqual(1);
|
||||||
expect(assetStore.assets[0].isFavorite).toEqual(true);
|
expect(assetStore.getAssets()[0].isFavorite).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('replaces bucket date when asset date changes', () => {
|
it('asset moves buckets when asset date changes', () => {
|
||||||
const asset = assetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
|
const asset = assetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
|
||||||
const updatedAsset = { ...asset, localDateTime: '2024-03-20T12:00:00.000Z' };
|
const updatedAsset = { ...asset, localDateTime: '2024-03-20T12:00:00.000Z' };
|
||||||
|
|
||||||
assetStore.addAssets([asset]);
|
assetStore.addAssets([asset]);
|
||||||
expect(assetStore.buckets.length).toEqual(1);
|
expect(assetStore.buckets.length).toEqual(1);
|
||||||
expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')).not.toBeNull();
|
expect(assetStore.getBucketByDate(2024, 1)).not.toBeUndefined();
|
||||||
|
expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(1);
|
||||||
|
|
||||||
assetStore.updateAssets([updatedAsset]);
|
assetStore.updateAssets([updatedAsset]);
|
||||||
expect(assetStore.buckets.length).toEqual(1);
|
expect(assetStore.buckets.length).toEqual(2);
|
||||||
expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')).toBeNull();
|
expect(assetStore.getBucketByDate(2024, 1)).not.toBeUndefined();
|
||||||
expect(assetStore.getBucketByDate('2024-03-01T00:00:00.000Z')).not.toBeNull();
|
expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(0);
|
||||||
|
expect(assetStore.getBucketByDate(2024, 3)).not.toBeUndefined();
|
||||||
|
expect(assetStore.getBucketByDate(2024, 3)?.getAssets().length).toEqual(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -273,9 +285,9 @@ describe('AssetStore', () => {
|
|||||||
let assetStore: AssetStore;
|
let assetStore: AssetStore;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
assetStore = new AssetStore({});
|
assetStore = new AssetStore();
|
||||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||||
await assetStore.init();
|
|
||||||
await assetStore.updateViewport({ width: 1588, height: 1000 });
|
await assetStore.updateViewport({ width: 1588, height: 1000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -283,9 +295,9 @@ describe('AssetStore', () => {
|
|||||||
assetStore.addAssets(assetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' }));
|
assetStore.addAssets(assetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' }));
|
||||||
assetStore.removeAssets(['', 'invalid', '4c7d9acc']);
|
assetStore.removeAssets(['', 'invalid', '4c7d9acc']);
|
||||||
|
|
||||||
expect(assetStore.assets.length).toEqual(2);
|
expect(assetStore.getAssets().length).toEqual(2);
|
||||||
expect(assetStore.buckets.length).toEqual(1);
|
expect(assetStore.buckets.length).toEqual(1);
|
||||||
expect(assetStore.buckets[0].assets.length).toEqual(2);
|
expect(assetStore.buckets[0].getAssets().length).toEqual(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes asset from bucket', () => {
|
it('removes asset from bucket', () => {
|
||||||
@ -293,18 +305,18 @@ describe('AssetStore', () => {
|
|||||||
assetStore.addAssets([assetOne, assetTwo]);
|
assetStore.addAssets([assetOne, assetTwo]);
|
||||||
assetStore.removeAssets([assetOne.id]);
|
assetStore.removeAssets([assetOne.id]);
|
||||||
|
|
||||||
expect(assetStore.assets.length).toEqual(1);
|
expect(assetStore.getAssets().length).toEqual(1);
|
||||||
expect(assetStore.buckets.length).toEqual(1);
|
expect(assetStore.buckets.length).toEqual(1);
|
||||||
expect(assetStore.buckets[0].assets.length).toEqual(1);
|
expect(assetStore.buckets[0].getAssets().length).toEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes bucket when empty', () => {
|
it('does not remove bucket when empty', () => {
|
||||||
const assets = assetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' });
|
const assets = assetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' });
|
||||||
assetStore.addAssets(assets);
|
assetStore.addAssets(assets);
|
||||||
assetStore.removeAssets(assets.map((asset) => asset.id));
|
assetStore.removeAssets(assets.map((asset) => asset.id));
|
||||||
|
|
||||||
expect(assetStore.assets.length).toEqual(0);
|
expect(assetStore.getAssets().length).toEqual(0);
|
||||||
expect(assetStore.buckets.length).toEqual(0);
|
expect(assetStore.buckets.length).toEqual(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -312,14 +324,13 @@ describe('AssetStore', () => {
|
|||||||
let assetStore: AssetStore;
|
let assetStore: AssetStore;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
assetStore = new AssetStore({});
|
assetStore = new AssetStore();
|
||||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||||
await assetStore.init();
|
|
||||||
await assetStore.updateViewport({ width: 0, height: 0 });
|
await assetStore.updateViewport({ width: 0, height: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('empty store returns null', () => {
|
it('empty store returns null', () => {
|
||||||
expect(assetStore.getFirstAsset()).toBeNull();
|
expect(assetStore.getFirstAsset()).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('populated store returns first asset', () => {
|
it('populated store returns first asset', () => {
|
||||||
@ -339,13 +350,19 @@ describe('AssetStore', () => {
|
|||||||
describe('getPreviousAsset', () => {
|
describe('getPreviousAsset', () => {
|
||||||
let assetStore: AssetStore;
|
let assetStore: AssetStore;
|
||||||
const bucketAssets: Record<string, AssetResponseDto[]> = {
|
const bucketAssets: Record<string, AssetResponseDto[]> = {
|
||||||
'2024-03-01T00:00:00.000Z': assetFactory.buildList(1),
|
'2024-03-01T00:00:00.000Z': assetFactory
|
||||||
'2024-02-01T00:00:00.000Z': assetFactory.buildList(6),
|
.buildList(1)
|
||||||
'2024-01-01T00:00:00.000Z': assetFactory.buildList(3),
|
.map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })),
|
||||||
|
'2024-02-01T00:00:00.000Z': assetFactory
|
||||||
|
.buildList(6)
|
||||||
|
.map((asset) => ({ ...asset, localDateTime: '2024-02-01T00:00:00.000Z' })),
|
||||||
|
'2024-01-01T00:00:00.000Z': assetFactory
|
||||||
|
.buildList(3)
|
||||||
|
.map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
assetStore = new AssetStore({});
|
assetStore = new AssetStore();
|
||||||
sdkMock.getTimeBuckets.mockResolvedValue([
|
sdkMock.getTimeBuckets.mockResolvedValue([
|
||||||
{ count: 1, timeBucket: '2024-03-01T00:00:00.000Z' },
|
{ count: 1, timeBucket: '2024-03-01T00:00:00.000Z' },
|
||||||
{ count: 6, timeBucket: '2024-02-01T00:00:00.000Z' },
|
{ count: 6, timeBucket: '2024-02-01T00:00:00.000Z' },
|
||||||
@ -353,38 +370,46 @@ describe('AssetStore', () => {
|
|||||||
]);
|
]);
|
||||||
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket]));
|
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket]));
|
||||||
|
|
||||||
await assetStore.init();
|
await assetStore.updateViewport({ width: 1588, height: 1000 });
|
||||||
await assetStore.updateViewport({ width: 0, height: 0 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null for invalid assetId', async () => {
|
it('returns null for invalid assetId', async () => {
|
||||||
expect(() => assetStore.getPreviousAsset({ id: 'invalid' } as AssetResponseDto)).not.toThrow();
|
expect(() => assetStore.getPreviousAsset({ id: 'invalid' } as AssetResponseDto)).not.toThrow();
|
||||||
expect(await assetStore.getPreviousAsset({ id: 'invalid' } as AssetResponseDto)).toBeNull();
|
expect(await assetStore.getPreviousAsset({ id: 'invalid' } as AssetResponseDto)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns previous assetId', async () => {
|
it('returns previous assetId', async () => {
|
||||||
await assetStore.loadBucket('2024-01-01T00:00:00.000Z');
|
await assetStore.loadBucket('2024-01-01T00:00:00.000Z');
|
||||||
const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z');
|
const bucket = assetStore.getBucketByDate(2024, 1);
|
||||||
|
|
||||||
expect(await assetStore.getPreviousAsset(bucket!.assets[1])).toEqual(bucket!.assets[0]);
|
const a = bucket!.getAssets()[0];
|
||||||
|
const b = bucket!.getAssets()[1];
|
||||||
|
const previous = await assetStore.getPreviousAsset(b);
|
||||||
|
expect(previous).toEqual(a);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns previous assetId spanning multiple buckets', async () => {
|
it('returns previous assetId spanning multiple buckets', async () => {
|
||||||
await assetStore.loadBucket('2024-02-01T00:00:00.000Z');
|
await assetStore.loadBucket('2024-02-01T00:00:00.000Z');
|
||||||
await assetStore.loadBucket('2024-03-01T00:00:00.000Z');
|
await assetStore.loadBucket('2024-03-01T00:00:00.000Z');
|
||||||
|
|
||||||
const bucket = assetStore.getBucketByDate('2024-02-01T00:00:00.000Z');
|
const bucket = assetStore.getBucketByDate(2024, 2);
|
||||||
const previousBucket = assetStore.getBucketByDate('2024-03-01T00:00:00.000Z');
|
const previousBucket = assetStore.getBucketByDate(2024, 3);
|
||||||
expect(await assetStore.getPreviousAsset(bucket!.assets[0])).toEqual(previousBucket!.assets[0]);
|
const a = bucket!.getAssets()[0];
|
||||||
|
const b = previousBucket!.getAssets()[0];
|
||||||
|
const previous = await assetStore.getPreviousAsset(a);
|
||||||
|
expect(previous).toEqual(b);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads previous bucket', async () => {
|
it('loads previous bucket', async () => {
|
||||||
await assetStore.loadBucket('2024-02-01T00:00:00.000Z');
|
await assetStore.loadBucket('2024-02-01T00:00:00.000Z');
|
||||||
|
|
||||||
const loadBucketSpy = vi.spyOn(assetStore, 'loadBucket');
|
const loadBucketSpy = vi.spyOn(assetStore, 'loadBucket');
|
||||||
const bucket = assetStore.getBucketByDate('2024-02-01T00:00:00.000Z');
|
const bucket = assetStore.getBucketByDate(2024, 2);
|
||||||
const previousBucket = assetStore.getBucketByDate('2024-03-01T00:00:00.000Z');
|
const previousBucket = assetStore.getBucketByDate(2024, 3);
|
||||||
expect(await assetStore.getPreviousAsset(bucket!.assets[0])).toEqual(previousBucket!.assets[0]);
|
const a = bucket!.getAssets()[0];
|
||||||
|
const b = previousBucket!.getAssets()[0];
|
||||||
|
const previous = await assetStore.getPreviousAsset(a);
|
||||||
|
expect(previous).toEqual(b);
|
||||||
expect(loadBucketSpy).toBeCalledTimes(1);
|
expect(loadBucketSpy).toBeCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -393,14 +418,14 @@ describe('AssetStore', () => {
|
|||||||
await assetStore.loadBucket('2024-02-01T00:00:00.000Z');
|
await assetStore.loadBucket('2024-02-01T00:00:00.000Z');
|
||||||
await assetStore.loadBucket('2024-03-01T00:00:00.000Z');
|
await assetStore.loadBucket('2024-03-01T00:00:00.000Z');
|
||||||
|
|
||||||
const [assetOne, assetTwo, assetThree] = assetStore.assets;
|
const [assetOne, assetTwo, assetThree] = assetStore.getAssets();
|
||||||
assetStore.removeAssets([assetTwo.id]);
|
assetStore.removeAssets([assetTwo.id]);
|
||||||
expect(await assetStore.getPreviousAsset(assetThree)).toEqual(assetOne);
|
expect(await assetStore.getPreviousAsset(assetThree)).toEqual(assetOne);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null when no more assets', async () => {
|
it('returns null when no more assets', async () => {
|
||||||
await assetStore.loadBucket('2024-03-01T00:00:00.000Z');
|
await assetStore.loadBucket('2024-03-01T00:00:00.000Z');
|
||||||
expect(await assetStore.getPreviousAsset(assetStore.assets[0])).toBeNull();
|
expect(await assetStore.getPreviousAsset(assetStore.getAssets()[0])).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -408,15 +433,15 @@ describe('AssetStore', () => {
|
|||||||
let assetStore: AssetStore;
|
let assetStore: AssetStore;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
assetStore = new AssetStore({});
|
assetStore = new AssetStore();
|
||||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||||
await assetStore.init();
|
|
||||||
await assetStore.updateViewport({ width: 0, height: 0 });
|
await assetStore.updateViewport({ width: 0, height: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null for invalid buckets', () => {
|
it('returns null for invalid buckets', () => {
|
||||||
expect(assetStore.getBucketByDate('invalid')).toBeNull();
|
expect(assetStore.getBucketByDate(-1, -1)).toBeUndefined();
|
||||||
expect(assetStore.getBucketByDate('2024-03-01T00:00:00.000Z')).toBeNull();
|
expect(assetStore.getBucketByDate(2024, 3)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the bucket index', () => {
|
it('returns the bucket index', () => {
|
||||||
@ -424,8 +449,8 @@ describe('AssetStore', () => {
|
|||||||
const assetTwo = assetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' });
|
const assetTwo = assetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' });
|
||||||
assetStore.addAssets([assetOne, assetTwo]);
|
assetStore.addAssets([assetOne, assetTwo]);
|
||||||
|
|
||||||
expect(assetStore.getBucketIndexByAssetId(assetTwo.id)).toEqual(0);
|
expect(assetStore.getBucketIndexByAssetId(assetTwo.id)?.bucketDate).toEqual('2024-02-01T00:00:00.000Z');
|
||||||
expect(assetStore.getBucketIndexByAssetId(assetOne.id)).toEqual(1);
|
expect(assetStore.getBucketIndexByAssetId(assetOne.id)?.bucketDate).toEqual('2024-01-01T00:00:00.000Z');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores removed buckets', () => {
|
it('ignores removed buckets', () => {
|
||||||
@ -434,7 +459,7 @@ describe('AssetStore', () => {
|
|||||||
assetStore.addAssets([assetOne, assetTwo]);
|
assetStore.addAssets([assetOne, assetTwo]);
|
||||||
|
|
||||||
assetStore.removeAssets([assetTwo.id]);
|
assetStore.removeAssets([assetTwo.id]);
|
||||||
expect(assetStore.getBucketIndexByAssetId(assetOne.id)).toEqual(0);
|
expect(assetStore.getBucketIndexByAssetId(assetOne.id)?.bucketDate).toEqual('2024-01-01T00:00:00.000Z');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,3 +0,0 @@
|
|||||||
import { writable } from 'svelte/store';
|
|
||||||
|
|
||||||
export const isTimelineScrolling = writable(false);
|
|
@ -1,465 +0,0 @@
|
|||||||
import type { AssetBucket, AssetStore } from '$lib/stores/assets-store.svelte';
|
|
||||||
import { generateId } from '$lib/utils/generate-id';
|
|
||||||
import { cancelIdleCB, idleCB } from '$lib/utils/idle-callback-support';
|
|
||||||
import { KeyedPriorityQueue } from '$lib/utils/keyed-priority-queue';
|
|
||||||
import { type DateGroup } from '$lib/utils/timeline-util';
|
|
||||||
import { TUNABLES } from '$lib/utils/tunables';
|
|
||||||
import { type AssetResponseDto } from '@immich/sdk';
|
|
||||||
import { clamp } from 'lodash-es';
|
|
||||||
|
|
||||||
type Task = () => void;
|
|
||||||
|
|
||||||
class InternalTaskManager {
|
|
||||||
assetStore: AssetStore;
|
|
||||||
componentTasks = new Map<string, Set<string>>();
|
|
||||||
priorityQueue = new KeyedPriorityQueue<string, Task>();
|
|
||||||
idleQueue = new Map<string, Task>();
|
|
||||||
taskCleaners = new Map<string, Task>();
|
|
||||||
|
|
||||||
queueTimer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
lastIdle: number | undefined;
|
|
||||||
|
|
||||||
constructor(assetStore: AssetStore) {
|
|
||||||
this.assetStore = assetStore;
|
|
||||||
}
|
|
||||||
destroy() {
|
|
||||||
this.componentTasks.clear();
|
|
||||||
this.priorityQueue.clear();
|
|
||||||
this.idleQueue.clear();
|
|
||||||
this.taskCleaners.clear();
|
|
||||||
clearTimeout(this.queueTimer);
|
|
||||||
if (this.lastIdle) {
|
|
||||||
cancelIdleCB(this.lastIdle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
getOrCreateComponentTasks(componentId: string) {
|
|
||||||
let componentTaskSet = this.componentTasks.get(componentId);
|
|
||||||
if (!componentTaskSet) {
|
|
||||||
componentTaskSet = new Set<string>();
|
|
||||||
this.componentTasks.set(componentId, componentTaskSet);
|
|
||||||
}
|
|
||||||
|
|
||||||
return componentTaskSet;
|
|
||||||
}
|
|
||||||
deleteFromComponentTasks(componentId: string, taskId: string) {
|
|
||||||
if (this.componentTasks.has(componentId)) {
|
|
||||||
const componentTaskSet = this.componentTasks.get(componentId);
|
|
||||||
componentTaskSet?.delete(taskId);
|
|
||||||
if (componentTaskSet?.size === 0) {
|
|
||||||
this.componentTasks.delete(componentId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
drainIntersectedQueue() {
|
|
||||||
let count = 0;
|
|
||||||
for (let t = this.priorityQueue.shift(); t; t = this.priorityQueue.shift()) {
|
|
||||||
t.value();
|
|
||||||
if (this.taskCleaners.has(t.key)) {
|
|
||||||
this.taskCleaners.get(t.key)!();
|
|
||||||
this.taskCleaners.delete(t.key);
|
|
||||||
}
|
|
||||||
if (count++ >= TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS) {
|
|
||||||
this.scheduleDrainIntersectedQueue(TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS_DELAY_MS);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleDrainIntersectedQueue(delay: number = TUNABLES.SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS) {
|
|
||||||
clearTimeout(this.queueTimer);
|
|
||||||
this.queueTimer = setTimeout(() => {
|
|
||||||
const delta = Date.now() - this.assetStore.lastScrollTime;
|
|
||||||
if (delta < TUNABLES.SCROLL_TASK_QUEUE.MIN_DELAY_MS) {
|
|
||||||
let amount = clamp(
|
|
||||||
1 + Math.round(this.priorityQueue.length / TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_BONUS_FACTOR),
|
|
||||||
1,
|
|
||||||
TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS * 2,
|
|
||||||
);
|
|
||||||
|
|
||||||
const nextDelay = clamp(
|
|
||||||
amount > 1
|
|
||||||
? Math.round(delay / TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATION_FACTOR)
|
|
||||||
: TUNABLES.SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS,
|
|
||||||
TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MIN_DELAY,
|
|
||||||
TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MAX_DELAY,
|
|
||||||
);
|
|
||||||
|
|
||||||
while (amount > 0) {
|
|
||||||
this.priorityQueue.shift()?.value();
|
|
||||||
amount--;
|
|
||||||
}
|
|
||||||
if (this.priorityQueue.length > 0) {
|
|
||||||
this.scheduleDrainIntersectedQueue(nextDelay);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.drainIntersectedQueue();
|
|
||||||
}
|
|
||||||
}, delay);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeAllTasksForComponent(componentId: string) {
|
|
||||||
if (this.componentTasks.has(componentId)) {
|
|
||||||
const tasksIds = this.componentTasks.get(componentId) || [];
|
|
||||||
for (const taskId of tasksIds) {
|
|
||||||
this.priorityQueue.remove(taskId);
|
|
||||||
this.idleQueue.delete(taskId);
|
|
||||||
if (this.taskCleaners.has(taskId)) {
|
|
||||||
const cleanup = this.taskCleaners.get(taskId);
|
|
||||||
this.taskCleaners.delete(taskId);
|
|
||||||
cleanup!();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.componentTasks.delete(componentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
queueScrollSensitiveTask({
|
|
||||||
task,
|
|
||||||
cleanup,
|
|
||||||
componentId,
|
|
||||||
priority = 10,
|
|
||||||
taskId = generateId(),
|
|
||||||
}: {
|
|
||||||
task: Task;
|
|
||||||
cleanup?: Task;
|
|
||||||
componentId: string;
|
|
||||||
priority?: number;
|
|
||||||
taskId?: string;
|
|
||||||
}) {
|
|
||||||
this.priorityQueue.push(taskId, task, priority);
|
|
||||||
if (cleanup) {
|
|
||||||
this.taskCleaners.set(taskId, cleanup);
|
|
||||||
}
|
|
||||||
this.getOrCreateComponentTasks(componentId).add(taskId);
|
|
||||||
const lastTime = this.assetStore.lastScrollTime;
|
|
||||||
const delta = Date.now() - lastTime;
|
|
||||||
if (lastTime != 0 && delta < TUNABLES.SCROLL_TASK_QUEUE.MIN_DELAY_MS) {
|
|
||||||
this.scheduleDrainIntersectedQueue();
|
|
||||||
} else {
|
|
||||||
// flush the queue early
|
|
||||||
clearTimeout(this.queueTimer);
|
|
||||||
this.drainIntersectedQueue();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleDrainSeparatedQueue() {
|
|
||||||
if (this.lastIdle) {
|
|
||||||
cancelIdleCB(this.lastIdle);
|
|
||||||
}
|
|
||||||
this.lastIdle = idleCB(
|
|
||||||
() => {
|
|
||||||
let count = 0;
|
|
||||||
let entry = this.idleQueue.entries().next().value;
|
|
||||||
while (entry) {
|
|
||||||
const [taskId, task] = entry;
|
|
||||||
this.idleQueue.delete(taskId);
|
|
||||||
task();
|
|
||||||
if (count++ >= TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
entry = this.idleQueue.entries().next().value;
|
|
||||||
}
|
|
||||||
if (this.idleQueue.size > 0) {
|
|
||||||
this.scheduleDrainSeparatedQueue();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ timeout: 1000 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
queueSeparateTask({
|
|
||||||
task,
|
|
||||||
cleanup,
|
|
||||||
componentId,
|
|
||||||
taskId,
|
|
||||||
}: {
|
|
||||||
task: Task;
|
|
||||||
cleanup: Task;
|
|
||||||
componentId: string;
|
|
||||||
taskId: string;
|
|
||||||
}) {
|
|
||||||
this.idleQueue.set(taskId, task);
|
|
||||||
this.taskCleaners.set(taskId, cleanup);
|
|
||||||
this.getOrCreateComponentTasks(componentId).add(taskId);
|
|
||||||
this.scheduleDrainSeparatedQueue();
|
|
||||||
}
|
|
||||||
|
|
||||||
removeIntersectedTask(taskId: string) {
|
|
||||||
const removed = this.priorityQueue.remove(taskId);
|
|
||||||
if (this.taskCleaners.has(taskId)) {
|
|
||||||
const cleanup = this.taskCleaners.get(taskId);
|
|
||||||
this.taskCleaners.delete(taskId);
|
|
||||||
cleanup!();
|
|
||||||
}
|
|
||||||
return removed;
|
|
||||||
}
|
|
||||||
|
|
||||||
removeSeparateTask(taskId: string) {
|
|
||||||
const removed = this.idleQueue.delete(taskId);
|
|
||||||
if (this.taskCleaners.has(taskId)) {
|
|
||||||
const cleanup = this.taskCleaners.get(taskId);
|
|
||||||
this.taskCleaners.delete(taskId);
|
|
||||||
cleanup!();
|
|
||||||
}
|
|
||||||
return removed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AssetGridTaskManager {
|
|
||||||
private internalManager: InternalTaskManager;
|
|
||||||
constructor(assetStore: AssetStore) {
|
|
||||||
this.internalManager = new InternalTaskManager(assetStore);
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks: Map<AssetBucket, BucketTask> = new Map();
|
|
||||||
|
|
||||||
queueScrollSensitiveTask({
|
|
||||||
task,
|
|
||||||
cleanup,
|
|
||||||
componentId,
|
|
||||||
priority = 10,
|
|
||||||
taskId = generateId(),
|
|
||||||
}: {
|
|
||||||
task: Task;
|
|
||||||
cleanup?: Task;
|
|
||||||
componentId: string;
|
|
||||||
priority?: number;
|
|
||||||
taskId?: string;
|
|
||||||
}) {
|
|
||||||
return this.internalManager.queueScrollSensitiveTask({ task, cleanup, componentId, priority, taskId });
|
|
||||||
}
|
|
||||||
|
|
||||||
removeAllTasksForComponent(componentId: string) {
|
|
||||||
return this.internalManager.removeAllTasksForComponent(componentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
return this.internalManager.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
private getOrCreateBucketTask(bucket: AssetBucket) {
|
|
||||||
let bucketTask = this.tasks.get(bucket);
|
|
||||||
if (!bucketTask) {
|
|
||||||
bucketTask = this.createBucketTask(bucket);
|
|
||||||
}
|
|
||||||
return bucketTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
private createBucketTask(bucket: AssetBucket) {
|
|
||||||
const bucketTask = new BucketTask(this.internalManager, this, bucket);
|
|
||||||
this.tasks.set(bucket, bucketTask);
|
|
||||||
return bucketTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
intersectedBucket(componentId: string, bucket: AssetBucket, task: Task) {
|
|
||||||
const bucketTask = this.getOrCreateBucketTask(bucket);
|
|
||||||
bucketTask.scheduleIntersected(componentId, task);
|
|
||||||
}
|
|
||||||
|
|
||||||
separatedBucket(componentId: string, bucket: AssetBucket, separated: Task) {
|
|
||||||
const bucketTask = this.getOrCreateBucketTask(bucket);
|
|
||||||
bucketTask.scheduleSeparated(componentId, separated);
|
|
||||||
}
|
|
||||||
|
|
||||||
intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) {
|
|
||||||
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
|
|
||||||
bucketTask.intersectedDateGroup(componentId, dateGroup, intersected);
|
|
||||||
}
|
|
||||||
|
|
||||||
separatedDateGroup(componentId: string, dateGroup: DateGroup, separated: Task) {
|
|
||||||
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
|
|
||||||
bucketTask.separatedDateGroup(componentId, dateGroup, separated);
|
|
||||||
}
|
|
||||||
|
|
||||||
intersectedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, intersected: Task) {
|
|
||||||
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
|
|
||||||
const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup);
|
|
||||||
dateGroupTask.intersectedThumbnail(componentId, asset, intersected);
|
|
||||||
}
|
|
||||||
|
|
||||||
separatedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, separated: Task) {
|
|
||||||
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
|
|
||||||
const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup);
|
|
||||||
dateGroupTask.separatedThumbnail(componentId, asset, separated);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class IntersectionTask {
|
|
||||||
internalTaskManager: InternalTaskManager;
|
|
||||||
separatedKey;
|
|
||||||
intersectedKey;
|
|
||||||
priority;
|
|
||||||
|
|
||||||
intersected: Task | undefined;
|
|
||||||
separated: Task | undefined;
|
|
||||||
|
|
||||||
constructor(internalTaskManager: InternalTaskManager, keyPrefix: string, key: string, priority: number) {
|
|
||||||
this.internalTaskManager = internalTaskManager;
|
|
||||||
this.separatedKey = keyPrefix + ':s:' + key;
|
|
||||||
this.intersectedKey = keyPrefix + ':i:' + key;
|
|
||||||
this.priority = priority;
|
|
||||||
}
|
|
||||||
|
|
||||||
trackIntersectedTask(componentId: string, task: Task) {
|
|
||||||
const execTask = () => {
|
|
||||||
if (this.separated) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
task?.();
|
|
||||||
};
|
|
||||||
this.intersected = execTask;
|
|
||||||
const cleanup = () => {
|
|
||||||
this.intersected = undefined;
|
|
||||||
this.internalTaskManager.deleteFromComponentTasks(componentId, this.intersectedKey);
|
|
||||||
};
|
|
||||||
return { task: execTask, cleanup };
|
|
||||||
}
|
|
||||||
|
|
||||||
trackSeparatedTask(componentId: string, task: Task) {
|
|
||||||
const execTask = () => {
|
|
||||||
if (this.intersected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
task?.();
|
|
||||||
};
|
|
||||||
this.separated = execTask;
|
|
||||||
const cleanup = () => {
|
|
||||||
this.separated = undefined;
|
|
||||||
this.internalTaskManager.deleteFromComponentTasks(componentId, this.separatedKey);
|
|
||||||
};
|
|
||||||
return { task: execTask, cleanup };
|
|
||||||
}
|
|
||||||
|
|
||||||
removePendingSeparated() {
|
|
||||||
if (this.separated) {
|
|
||||||
this.internalTaskManager.removeSeparateTask(this.separatedKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
removePendingIntersected() {
|
|
||||||
if (this.intersected) {
|
|
||||||
this.internalTaskManager.removeIntersectedTask(this.intersectedKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleIntersected(componentId: string, intersected: Task) {
|
|
||||||
this.removePendingSeparated();
|
|
||||||
if (this.intersected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { task, cleanup } = this.trackIntersectedTask(componentId, intersected);
|
|
||||||
this.internalTaskManager.queueScrollSensitiveTask({
|
|
||||||
task,
|
|
||||||
cleanup,
|
|
||||||
componentId,
|
|
||||||
priority: this.priority,
|
|
||||||
taskId: this.intersectedKey,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleSeparated(componentId: string, separated: Task) {
|
|
||||||
this.removePendingIntersected();
|
|
||||||
|
|
||||||
if (this.separated) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { task, cleanup } = this.trackSeparatedTask(componentId, separated);
|
|
||||||
this.internalTaskManager.queueSeparateTask({
|
|
||||||
task,
|
|
||||||
cleanup,
|
|
||||||
componentId,
|
|
||||||
taskId: this.separatedKey,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
class BucketTask extends IntersectionTask {
|
|
||||||
assetBucket: AssetBucket;
|
|
||||||
assetGridTaskManager: AssetGridTaskManager;
|
|
||||||
// indexed by dateGroup's date
|
|
||||||
dateTasks: Map<DateGroup, DateGroupTask> = new Map();
|
|
||||||
|
|
||||||
constructor(internalTaskManager: InternalTaskManager, parent: AssetGridTaskManager, assetBucket: AssetBucket) {
|
|
||||||
super(internalTaskManager, 'b', assetBucket.bucketDate, TUNABLES.BUCKET.PRIORITY);
|
|
||||||
this.assetBucket = assetBucket;
|
|
||||||
this.assetGridTaskManager = parent;
|
|
||||||
}
|
|
||||||
|
|
||||||
getOrCreateDateGroupTask(dateGroup: DateGroup) {
|
|
||||||
let dateGroupTask = this.dateTasks.get(dateGroup);
|
|
||||||
if (!dateGroupTask) {
|
|
||||||
dateGroupTask = this.createDateGroupTask(dateGroup);
|
|
||||||
}
|
|
||||||
return dateGroupTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
createDateGroupTask(dateGroup: DateGroup) {
|
|
||||||
const dateGroupTask = new DateGroupTask(this.internalTaskManager, this, dateGroup);
|
|
||||||
this.dateTasks.set(dateGroup, dateGroupTask);
|
|
||||||
return dateGroupTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
removePendingSeparated() {
|
|
||||||
super.removePendingSeparated();
|
|
||||||
for (const dateGroupTask of this.dateTasks.values()) {
|
|
||||||
dateGroupTask.removePendingSeparated();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) {
|
|
||||||
const dateGroupTask = this.getOrCreateDateGroupTask(dateGroup);
|
|
||||||
dateGroupTask.scheduleIntersected(componentId, intersected);
|
|
||||||
}
|
|
||||||
|
|
||||||
separatedDateGroup(componentId: string, dateGroup: DateGroup, separated: Task) {
|
|
||||||
const dateGroupTask = this.getOrCreateDateGroupTask(dateGroup);
|
|
||||||
dateGroupTask.scheduleSeparated(componentId, separated);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
class DateGroupTask extends IntersectionTask {
|
|
||||||
dateGroup: DateGroup;
|
|
||||||
bucketTask: BucketTask;
|
|
||||||
// indexed by thumbnail's asset
|
|
||||||
thumbnailTasks: Map<AssetResponseDto, ThumbnailTask> = new Map();
|
|
||||||
|
|
||||||
constructor(internalTaskManager: InternalTaskManager, parent: BucketTask, dateGroup: DateGroup) {
|
|
||||||
super(internalTaskManager, 'dg', dateGroup.date.toString(), TUNABLES.DATEGROUP.PRIORITY);
|
|
||||||
this.dateGroup = dateGroup;
|
|
||||||
this.bucketTask = parent;
|
|
||||||
}
|
|
||||||
|
|
||||||
removePendingSeparated() {
|
|
||||||
super.removePendingSeparated();
|
|
||||||
for (const thumbnailTask of this.thumbnailTasks.values()) {
|
|
||||||
thumbnailTask.removePendingSeparated();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getOrCreateThumbnailTask(asset: AssetResponseDto) {
|
|
||||||
let thumbnailTask = this.thumbnailTasks.get(asset);
|
|
||||||
if (!thumbnailTask) {
|
|
||||||
thumbnailTask = new ThumbnailTask(this.internalTaskManager, this, asset);
|
|
||||||
this.thumbnailTasks.set(asset, thumbnailTask);
|
|
||||||
}
|
|
||||||
return thumbnailTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
intersectedThumbnail(componentId: string, asset: AssetResponseDto, intersected: Task) {
|
|
||||||
const thumbnailTask = this.getOrCreateThumbnailTask(asset);
|
|
||||||
thumbnailTask.scheduleIntersected(componentId, intersected);
|
|
||||||
}
|
|
||||||
|
|
||||||
separatedThumbnail(componentId: string, asset: AssetResponseDto, separated: Task) {
|
|
||||||
const thumbnailTask = this.getOrCreateThumbnailTask(asset);
|
|
||||||
thumbnailTask.scheduleSeparated(componentId, separated);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
class ThumbnailTask extends IntersectionTask {
|
|
||||||
asset: AssetResponseDto;
|
|
||||||
dateGroupTask: DateGroupTask;
|
|
||||||
|
|
||||||
constructor(internalTaskManager: InternalTaskManager, parent: DateGroupTask, asset: AssetResponseDto) {
|
|
||||||
super(internalTaskManager, 't', asset.id, TUNABLES.THUMBNAIL.PRIORITY);
|
|
||||||
this.asset = asset;
|
|
||||||
this.dateGroupTask = parent;
|
|
||||||
}
|
|
||||||
}
|
|
@ -476,7 +476,7 @@ export const selectAllAssets = async (assetStore: AssetStore, assetInteraction:
|
|||||||
if (!get(isSelectingAllAssets)) {
|
if (!get(isSelectingAllAssets)) {
|
||||||
break; // Cancelled
|
break; // Cancelled
|
||||||
}
|
}
|
||||||
assetInteraction.selectAssets(bucket.assets);
|
assetInteraction.selectAssets(bucket.getAssets().map((a) => $state.snapshot(a)));
|
||||||
|
|
||||||
// We use setTimeout to allow the UI to update. Otherwise, this may
|
// We use setTimeout to allow the UI to update. Otherwise, this may
|
||||||
// cause a long delay between the start of 'select all' and the
|
// cause a long delay between the start of 'select all' and the
|
||||||
|
135
web/src/lib/utils/cancellable-task.ts
Normal file
135
web/src/lib/utils/cancellable-task.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
export class CancellableTask {
|
||||||
|
cancelToken: AbortController | null = null;
|
||||||
|
cancellable: boolean = true;
|
||||||
|
/**
|
||||||
|
* A promise that resolves once the bucket is loaded, and rejects if bucket is canceled.
|
||||||
|
*/
|
||||||
|
complete!: Promise<unknown>;
|
||||||
|
executed: boolean = false;
|
||||||
|
|
||||||
|
private loadedSignal: (() => void) | undefined;
|
||||||
|
private canceledSignal: (() => void) | undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private loadedCallback?: () => void,
|
||||||
|
private canceledCallback?: () => void,
|
||||||
|
private errorCallback?: (error: unknown) => void,
|
||||||
|
) {
|
||||||
|
this.complete = new Promise<void>((resolve, reject) => {
|
||||||
|
this.loadedSignal = resolve;
|
||||||
|
this.canceledSignal = reject;
|
||||||
|
}).catch(
|
||||||
|
() =>
|
||||||
|
// if no-one waits on complete its rejected a uncaught rejection message is logged.
|
||||||
|
// prevent this message with an empty reject handler, since waiting on a bucket is optional.
|
||||||
|
void 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get loading() {
|
||||||
|
return !!this.cancelToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitUntilCompletion() {
|
||||||
|
if (this.executed) {
|
||||||
|
return 'DONE';
|
||||||
|
}
|
||||||
|
// if there is a cancel token, task is currently executing, so wait on the promise. If it
|
||||||
|
// isn't, then the task is in new state, it hasn't been loaded, nor has it been executed.
|
||||||
|
// in either case, we wait on the promise.
|
||||||
|
await this.complete;
|
||||||
|
return 'WAITED';
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute<F extends (abortSignal: AbortSignal) => Promise<void>>(f: F, cancellable: boolean) {
|
||||||
|
if (this.executed) {
|
||||||
|
return 'DONE';
|
||||||
|
}
|
||||||
|
|
||||||
|
// if promise is pending, wait on previous request instead.
|
||||||
|
if (this.cancelToken) {
|
||||||
|
// if promise is pending, and preventCancel is requested,
|
||||||
|
// do not allow transition from prevent cancel to allow cancel.
|
||||||
|
if (this.cancellable && !cancellable) {
|
||||||
|
this.cancellable = cancellable;
|
||||||
|
}
|
||||||
|
await this.complete;
|
||||||
|
return 'WAITED';
|
||||||
|
}
|
||||||
|
this.cancellable = cancellable;
|
||||||
|
const cancelToken = (this.cancelToken = new AbortController());
|
||||||
|
|
||||||
|
try {
|
||||||
|
await f(cancelToken.signal);
|
||||||
|
this.#transitionToExecuted();
|
||||||
|
return 'LOADED';
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
if ((error as any).name === 'AbortError') {
|
||||||
|
// abort error is not treated as an error, but as a cancelation.
|
||||||
|
return 'CANCELED';
|
||||||
|
}
|
||||||
|
this.#transitionToErrored(error);
|
||||||
|
return 'ERRORED';
|
||||||
|
} finally {
|
||||||
|
this.cancelToken = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
this.cancelToken = null;
|
||||||
|
this.executed = false;
|
||||||
|
// create a promise, and store its resolve/reject callbacks. The loadedSignal callback
|
||||||
|
// will be incoked when a bucket is loaded, fulfilling the promise. The canceledSignal
|
||||||
|
// callback will be called if the bucket is canceled before it was loaded, rejecting the
|
||||||
|
// promise.
|
||||||
|
this.complete = new Promise<void>((resolve, reject) => {
|
||||||
|
this.loadedSignal = resolve;
|
||||||
|
this.canceledSignal = reject;
|
||||||
|
}).catch(
|
||||||
|
() =>
|
||||||
|
// if no-one waits on complete its rejected a uncaught rejection message is logged.
|
||||||
|
// prevent this message with an empty reject handler, since waiting on a bucket is optional.
|
||||||
|
void 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// will reset this job back to the initial state (isLoaded=false, no errors, etc)
|
||||||
|
async reset() {
|
||||||
|
this.#transitionToCancelled();
|
||||||
|
if (this.cancelToken) {
|
||||||
|
await this.waitUntilCompletion();
|
||||||
|
}
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel() {
|
||||||
|
this.#transitionToCancelled();
|
||||||
|
}
|
||||||
|
|
||||||
|
#transitionToCancelled() {
|
||||||
|
if (this.executed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.cancellable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.cancelToken?.abort();
|
||||||
|
this.canceledSignal?.();
|
||||||
|
this.init();
|
||||||
|
this.canceledCallback?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
#transitionToExecuted() {
|
||||||
|
this.executed = true;
|
||||||
|
this.loadedSignal?.();
|
||||||
|
this.loadedCallback?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
#transitionToErrored(error: unknown) {
|
||||||
|
this.cancelToken = null;
|
||||||
|
this.canceledSignal?.();
|
||||||
|
this.init();
|
||||||
|
this.errorCallback?.(error);
|
||||||
|
}
|
||||||
|
}
|
@ -1,22 +0,0 @@
|
|||||||
interface RequestIdleCallback {
|
|
||||||
didTimeout?: boolean;
|
|
||||||
timeRemaining?(): DOMHighResTimeStamp;
|
|
||||||
}
|
|
||||||
interface RequestIdleCallbackOptions {
|
|
||||||
timeout?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
function fake_requestIdleCallback(cb: (deadline: RequestIdleCallback) => any, _?: RequestIdleCallbackOptions) {
|
|
||||||
const start = Date.now();
|
|
||||||
return setTimeout(() => {
|
|
||||||
cb({ didTimeout: false, timeRemaining: () => Math.max(0, 50 - (Date.now() - start)) });
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fake_cancelIdleCallback(id: number) {
|
|
||||||
return clearTimeout(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const idleCB = globalThis.requestIdleCallback || fake_requestIdleCallback;
|
|
||||||
export const cancelIdleCB = globalThis.cancelIdleCallback || fake_cancelIdleCallback;
|
|
@ -1,50 +0,0 @@
|
|||||||
export class KeyedPriorityQueue<K, T> {
|
|
||||||
private items: { key: K; value: T; priority: number }[] = [];
|
|
||||||
private set: Set<K> = new Set();
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
this.items = [];
|
|
||||||
this.set.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
remove(key: K) {
|
|
||||||
const removed = this.set.delete(key);
|
|
||||||
if (removed) {
|
|
||||||
const idx = this.items.findIndex((i) => i.key === key);
|
|
||||||
if (idx !== -1) {
|
|
||||||
this.items.splice(idx, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return removed;
|
|
||||||
}
|
|
||||||
|
|
||||||
push(key: K, value: T, priority: number) {
|
|
||||||
if (this.set.has(key)) {
|
|
||||||
return this.length;
|
|
||||||
}
|
|
||||||
for (let i = 0; i < this.items.length; i++) {
|
|
||||||
if (this.items[i].priority > priority) {
|
|
||||||
this.set.add(key);
|
|
||||||
this.items.splice(i, 0, { key, value, priority });
|
|
||||||
return this.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.set.add(key);
|
|
||||||
return this.items.push({ key, value, priority });
|
|
||||||
}
|
|
||||||
|
|
||||||
shift() {
|
|
||||||
let item = this.items.shift();
|
|
||||||
while (item) {
|
|
||||||
if (this.set.has(item.key)) {
|
|
||||||
this.set.delete(item.key);
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
item = this.items.shift();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get length() {
|
|
||||||
return this.set.size;
|
|
||||||
}
|
|
||||||
}
|
|
@ -49,18 +49,21 @@ export function getJustifiedLayoutFromAssets(
|
|||||||
type Geometry = ReturnType<typeof createJustifiedLayout>;
|
type Geometry = ReturnType<typeof createJustifiedLayout>;
|
||||||
class Adapter {
|
class Adapter {
|
||||||
result;
|
result;
|
||||||
|
width;
|
||||||
constructor(result: Geometry) {
|
constructor(result: Geometry) {
|
||||||
this.result = result;
|
this.result = result;
|
||||||
|
this.width = 0;
|
||||||
|
for (const box of this.result.boxes) {
|
||||||
|
if (box.top < 100) {
|
||||||
|
this.width = box.left + box.width;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get containerWidth() {
|
get containerWidth() {
|
||||||
let width = 0;
|
return this.width;
|
||||||
for (const box of this.result.boxes) {
|
|
||||||
if (box.top < 100) {
|
|
||||||
width = box.left + box.width;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return width;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get containerHeight() {
|
get containerHeight() {
|
||||||
@ -84,12 +87,6 @@ class Adapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const emptyGeometry = new Adapter({
|
|
||||||
containerHeight: 0,
|
|
||||||
widowCount: 0,
|
|
||||||
boxes: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
export function justifiedLayout(assets: AssetResponseDto[], options: CommonLayoutOptions) {
|
export function justifiedLayout(assets: AssetResponseDto[], options: CommonLayoutOptions) {
|
||||||
const adapter = {
|
const adapter = {
|
||||||
targetRowHeight: options.rowHeight,
|
targetRowHeight: options.rowHeight,
|
||||||
@ -104,3 +101,26 @@ export function justifiedLayout(assets: AssetResponseDto[], options: CommonLayou
|
|||||||
);
|
);
|
||||||
return new Adapter(result);
|
return new Adapter(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const emptyGeometry = () =>
|
||||||
|
new Adapter({
|
||||||
|
containerHeight: 0,
|
||||||
|
widowCount: 0,
|
||||||
|
boxes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CommonPosition = {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getPosition(geometry: CommonJustifiedLayout, boxIdx: number): CommonPosition {
|
||||||
|
const top = geometry.getTop(boxIdx);
|
||||||
|
const left = geometry.getLeft(boxIdx);
|
||||||
|
const width = geometry.getWidth(boxIdx);
|
||||||
|
const height = geometry.getHeight(boxIdx);
|
||||||
|
|
||||||
|
return { top, left, width, height };
|
||||||
|
}
|
||||||
|
@ -1,21 +0,0 @@
|
|||||||
export class PriorityQueue<T> {
|
|
||||||
private items: { value: T; priority: number }[] = [];
|
|
||||||
|
|
||||||
push(value: T, priority: number) {
|
|
||||||
for (let i = 0; i < this.items.length; i++) {
|
|
||||||
if (this.items[i].priority > priority) {
|
|
||||||
this.items.splice(i, 0, { value, priority });
|
|
||||||
return this.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this.items.push({ value, priority });
|
|
||||||
}
|
|
||||||
|
|
||||||
shift() {
|
|
||||||
return this.items.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
get length() {
|
|
||||||
return this.items.length;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,21 +1,24 @@
|
|||||||
import type { AssetBucket } from '$lib/stores/assets-store.svelte';
|
import type { AssetBucket } from '$lib/stores/assets-store.svelte';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { emptyGeometry, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
|
import { type CommonJustifiedLayout } from '$lib/utils/layout-utils';
|
||||||
|
|
||||||
import type { AssetResponseDto } from '@immich/sdk';
|
import type { AssetResponseDto } from '@immich/sdk';
|
||||||
import { groupBy, memoize, sortBy } from 'lodash-es';
|
import { memoize } from 'lodash-es';
|
||||||
import { DateTime, type LocaleOptions } from 'luxon';
|
import { DateTime, type LocaleOptions } from 'luxon';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
export type DateGroup = {
|
export type DateGroup = {
|
||||||
|
bucket: AssetBucket;
|
||||||
|
index: number;
|
||||||
|
row: number;
|
||||||
|
col: number;
|
||||||
date: DateTime;
|
date: DateTime;
|
||||||
groupTitle: string;
|
groupTitle: string;
|
||||||
assets: AssetResponseDto[];
|
assets: AssetResponseDto[];
|
||||||
|
assetsIntersecting: boolean[];
|
||||||
height: number;
|
height: number;
|
||||||
heightActual: boolean;
|
|
||||||
intersecting: boolean;
|
intersecting: boolean;
|
||||||
geometry: CommonJustifiedLayout;
|
geometry: CommonJustifiedLayout;
|
||||||
bucket: AssetBucket;
|
|
||||||
};
|
};
|
||||||
export type ScrubberListener = (
|
export type ScrubberListener = (
|
||||||
bucketDate: string | undefined,
|
bucketDate: string | undefined,
|
||||||
@ -40,6 +43,31 @@ export const fromLocalDateTime = (localDateTime: string) =>
|
|||||||
export const fromDateTimeOriginal = (dateTimeOriginal: string, timeZone: string) =>
|
export const fromDateTimeOriginal = (dateTimeOriginal: string, timeZone: string) =>
|
||||||
DateTime.fromISO(dateTimeOriginal, { zone: timeZone });
|
DateTime.fromISO(dateTimeOriginal, { zone: timeZone });
|
||||||
|
|
||||||
|
export type LayoutBox = {
|
||||||
|
aspectRatio: number;
|
||||||
|
top: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
left: number;
|
||||||
|
forcedAspectRatio?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function findTotalOffset(element: HTMLElement, stop: HTMLElement) {
|
||||||
|
let offset = 0;
|
||||||
|
while (element.offsetParent && element !== stop) {
|
||||||
|
offset += element.offsetTop;
|
||||||
|
element = element.offsetParent as HTMLElement;
|
||||||
|
}
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const groupDateFormat: Intl.DateTimeFormatOptions = {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
};
|
||||||
|
|
||||||
export function formatGroupTitle(_date: DateTime): string {
|
export function formatGroupTitle(_date: DateTime): string {
|
||||||
if (!_date.isValid) {
|
if (!_date.isValid) {
|
||||||
return _date.toString();
|
return _date.toString();
|
||||||
@ -73,56 +101,7 @@ export function formatGroupTitle(_date: DateTime): string {
|
|||||||
|
|
||||||
return getDateLocaleString(date);
|
return getDateLocaleString(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string =>
|
export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string =>
|
||||||
date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts);
|
date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts);
|
||||||
|
|
||||||
const formatDateGroupTitle = memoize(formatGroupTitle);
|
export const formatDateGroupTitle = memoize(formatGroupTitle);
|
||||||
|
|
||||||
export function splitBucketIntoDateGroups(bucket: AssetBucket, locale: string | undefined): DateGroup[] {
|
|
||||||
const grouped = groupBy(bucket.assets, (asset) =>
|
|
||||||
getDateLocaleString(fromLocalDateTime(asset.localDateTime), { locale }),
|
|
||||||
);
|
|
||||||
const sorted = sortBy(grouped, (group) => bucket.assets.indexOf(group[0]));
|
|
||||||
return sorted.map((group) => {
|
|
||||||
const date = fromLocalDateTime(group[0].localDateTime).startOf('day');
|
|
||||||
return {
|
|
||||||
date,
|
|
||||||
groupTitle: formatDateGroupTitle(date),
|
|
||||||
assets: group,
|
|
||||||
height: 0,
|
|
||||||
heightActual: false,
|
|
||||||
intersecting: false,
|
|
||||||
geometry: emptyGeometry,
|
|
||||||
bucket,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LayoutBox = {
|
|
||||||
aspectRatio: number;
|
|
||||||
top: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
left: number;
|
|
||||||
forcedAspectRatio?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function calculateWidth(boxes: LayoutBox[]): number {
|
|
||||||
let width = 0;
|
|
||||||
for (const box of boxes) {
|
|
||||||
if (box.top < 100) {
|
|
||||||
width = box.left + box.width;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return width;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function findTotalOffset(element: HTMLElement, stop: HTMLElement) {
|
|
||||||
let offset = 0;
|
|
||||||
while (element.offsetParent && element !== stop) {
|
|
||||||
offset += element.offsetTop;
|
|
||||||
element = element.offsetParent as HTMLElement;
|
|
||||||
}
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
|
@ -10,56 +10,17 @@ function getNumber(string: string | null, fallback: number) {
|
|||||||
}
|
}
|
||||||
return Number.parseInt(string);
|
return Number.parseInt(string);
|
||||||
}
|
}
|
||||||
function getFloat(string: string | null, fallback: number) {
|
|
||||||
if (string === null) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
return Number.parseFloat(string);
|
|
||||||
}
|
|
||||||
export const TUNABLES = {
|
export const TUNABLES = {
|
||||||
LAYOUT: {
|
LAYOUT: {
|
||||||
WASM: getBoolean(localStorage.getItem('LAYOUT.WASM'), false),
|
WASM: getBoolean(localStorage.getItem('LAYOUT.WASM'), false),
|
||||||
},
|
},
|
||||||
SCROLL_TASK_QUEUE: {
|
TIMELINE: {
|
||||||
TRICKLE_BONUS_FACTOR: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_BONUS_FACTOR'), 25),
|
INTERSECTION_EXPAND_TOP: getNumber(localStorage.getItem('TIMELINE_INTERSECTION_EXPAND_TOP'), 500),
|
||||||
TRICKLE_ACCELERATION_FACTOR: getFloat(localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATION_FACTOR'), 1.5),
|
INTERSECTION_EXPAND_BOTTOM: getNumber(localStorage.getItem('TIMELINE_INTERSECTION_EXPAND_BOTTOM'), 500),
|
||||||
TRICKLE_ACCELERATED_MIN_DELAY: getNumber(
|
|
||||||
localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MIN_DELAY'),
|
|
||||||
8,
|
|
||||||
),
|
|
||||||
TRICKLE_ACCELERATED_MAX_DELAY: getNumber(
|
|
||||||
localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MAX_DELAY'),
|
|
||||||
2000,
|
|
||||||
),
|
|
||||||
DRAIN_MAX_TASKS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS'), 15),
|
|
||||||
DRAIN_MAX_TASKS_DELAY_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS_DELAY_MS'), 16),
|
|
||||||
MIN_DELAY_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.MIN_DELAY_MS')!, 200),
|
|
||||||
CHECK_INTERVAL_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS'), 16),
|
|
||||||
},
|
|
||||||
INTERSECTION_OBSERVER_QUEUE: {
|
|
||||||
DRAIN_MAX_TASKS: getNumber(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.DRAIN_MAX_TASKS'), 15),
|
|
||||||
THROTTLE_MS: getNumber(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.THROTTLE_MS'), 16),
|
|
||||||
THROTTLE: getBoolean(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.THROTTLE'), true),
|
|
||||||
},
|
},
|
||||||
ASSET_GRID: {
|
ASSET_GRID: {
|
||||||
NAVIGATE_ON_ASSET_IN_VIEW: getBoolean(localStorage.getItem('ASSET_GRID.NAVIGATE_ON_ASSET_IN_VIEW'), false),
|
NAVIGATE_ON_ASSET_IN_VIEW: getBoolean(localStorage.getItem('ASSET_GRID.NAVIGATE_ON_ASSET_IN_VIEW'), false),
|
||||||
},
|
},
|
||||||
BUCKET: {
|
|
||||||
PRIORITY: getNumber(localStorage.getItem('BUCKET.PRIORITY'), 2),
|
|
||||||
INTERSECTION_ROOT_TOP: localStorage.getItem('BUCKET.INTERSECTION_ROOT_TOP') || '300%',
|
|
||||||
INTERSECTION_ROOT_BOTTOM: localStorage.getItem('BUCKET.INTERSECTION_ROOT_BOTTOM') || '300%',
|
|
||||||
},
|
|
||||||
DATEGROUP: {
|
|
||||||
PRIORITY: getNumber(localStorage.getItem('DATEGROUP.PRIORITY'), 4),
|
|
||||||
INTERSECTION_DISABLED: getBoolean(localStorage.getItem('DATEGROUP.INTERSECTION_DISABLED'), false),
|
|
||||||
INTERSECTION_ROOT_TOP: localStorage.getItem('DATEGROUP.INTERSECTION_ROOT_TOP') || '150%',
|
|
||||||
INTERSECTION_ROOT_BOTTOM: localStorage.getItem('DATEGROUP.INTERSECTION_ROOT_BOTTOM') || '150%',
|
|
||||||
},
|
|
||||||
THUMBNAIL: {
|
|
||||||
PRIORITY: getNumber(localStorage.getItem('THUMBNAIL.PRIORITY'), 8),
|
|
||||||
INTERSECTION_ROOT_TOP: localStorage.getItem('THUMBNAIL.INTERSECTION_ROOT_TOP') || '250%',
|
|
||||||
INTERSECTION_ROOT_BOTTOM: localStorage.getItem('THUMBNAIL.INTERSECTION_ROOT_BOTTOM') || '250%',
|
|
||||||
},
|
|
||||||
IMAGE_THUMBNAIL: {
|
IMAGE_THUMBNAIL: {
|
||||||
THUMBHASH_FADE_DURATION: getNumber(localStorage.getItem('THUMBHASH_FADE_DURATION'), 150),
|
THUMBHASH_FADE_DURATION: getNumber(localStorage.getItem('THUMBHASH_FADE_DURATION'), 150),
|
||||||
},
|
},
|
||||||
|
@ -100,7 +100,7 @@
|
|||||||
let oldAt: AssetGridRouteSearchParams | null | undefined = $state();
|
let oldAt: AssetGridRouteSearchParams | null | undefined = $state();
|
||||||
|
|
||||||
let backUrl: string = $state(AppRoute.ALBUMS);
|
let backUrl: string = $state(AppRoute.ALBUMS);
|
||||||
let viewMode = $state(AlbumPageViewMode.VIEW);
|
let viewMode: AlbumPageViewMode = $state(AlbumPageViewMode.VIEW);
|
||||||
let isCreatingSharedAlbum = $state(false);
|
let isCreatingSharedAlbum = $state(false);
|
||||||
let isShowActivity = $state(false);
|
let isShowActivity = $state(false);
|
||||||
let isLiked: ActivityResponseDto | null = $state(null);
|
let isLiked: ActivityResponseDto | null = $state(null);
|
||||||
@ -203,7 +203,9 @@
|
|||||||
|
|
||||||
const handleStartSlideshow = async () => {
|
const handleStartSlideshow = async () => {
|
||||||
const asset =
|
const asset =
|
||||||
$slideshowNavigation === SlideshowNavigation.Shuffle ? await assetStore.getRandomAsset() : assetStore.assets[0];
|
$slideshowNavigation === SlideshowNavigation.Shuffle
|
||||||
|
? await assetStore.getRandomAsset()
|
||||||
|
: assetStore.buckets[0]?.dateGroups[0]?.intersetingAssets[0]?.asset;
|
||||||
if (asset) {
|
if (asset) {
|
||||||
setAsset(asset);
|
setAsset(asset);
|
||||||
$slideshowState = SlideshowState.PlaySlideshow;
|
$slideshowState = SlideshowState.PlaySlideshow;
|
||||||
@ -211,6 +213,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleEscape = async () => {
|
const handleEscape = async () => {
|
||||||
|
assetStore.suspendTransitions = true;
|
||||||
if (viewMode === AlbumPageViewMode.SELECT_USERS) {
|
if (viewMode === AlbumPageViewMode.SELECT_USERS) {
|
||||||
viewMode = AlbumPageViewMode.VIEW;
|
viewMode = AlbumPageViewMode.VIEW;
|
||||||
return;
|
return;
|
||||||
@ -270,11 +273,8 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setModeToView = async () => {
|
const setModeToView = async () => {
|
||||||
|
assetStore.suspendTransitions = true;
|
||||||
viewMode = AlbumPageViewMode.VIEW;
|
viewMode = AlbumPageViewMode.VIEW;
|
||||||
assetStore.destroy();
|
|
||||||
assetStore = new AssetStore({ albumId, order: albumOrder });
|
|
||||||
timelineStore.destroy();
|
|
||||||
timelineStore = new AssetStore({ isArchived: false }, albumId);
|
|
||||||
await navigate(
|
await navigate(
|
||||||
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: { at: oldAt?.at } },
|
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: { at: oldAt?.at } },
|
||||||
{ replaceState: true, forceNavigate: true },
|
{ replaceState: true, forceNavigate: true },
|
||||||
@ -394,14 +394,8 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
assetStore.destroy();
|
|
||||||
timelineStore.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
let album = $state(data.album);
|
let album = $state(data.album);
|
||||||
let albumId = $derived(album.id);
|
let albumId = $derived(album.id);
|
||||||
let albumKey = $derived(`${albumId}_${albumOrder}`);
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!album.isActivityEnabled && $numberOfComments === 0) {
|
if (!album.isActivityEnabled && $numberOfComments === 0) {
|
||||||
@ -409,8 +403,18 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let assetStore = $derived(new AssetStore({ albumId, order: albumOrder }));
|
let assetStore = new AssetStore();
|
||||||
let timelineStore = $derived(new AssetStore({ isArchived: false, withPartners: true }, albumId));
|
$effect(() => {
|
||||||
|
if (viewMode === AlbumPageViewMode.VIEW) {
|
||||||
|
void assetStore.updateOptions({ albumId, order: albumOrder });
|
||||||
|
} else if (viewMode === AlbumPageViewMode.SELECT_ASSETS) {
|
||||||
|
void assetStore.updateOptions({ isArchived: false, withPartners: true, timelineAlbumId: albumId });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onDestroy(() => assetStore.destroy());
|
||||||
|
// let timelineStore = new AssetStore();
|
||||||
|
// $effect(() => void timelineStore.updateOptions({ isArchived: false, withPartners: true, timelineAlbumId: albumId }));
|
||||||
|
// onDestroy(() => timelineStore.destroy());
|
||||||
|
|
||||||
let isOwned = $derived($user.id == album.ownerId);
|
let isOwned = $derived($user.id == album.ownerId);
|
||||||
|
|
||||||
@ -429,6 +433,22 @@
|
|||||||
handlePromiseError(getNumberOfComments());
|
handlePromiseError(getNumberOfComments());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const isShared = $derived(viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : album.albumUsers.length > 0);
|
||||||
|
const isSelectionMode = $derived(
|
||||||
|
viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : viewMode === AlbumPageViewMode.SELECT_THUMBNAIL,
|
||||||
|
);
|
||||||
|
const singleSelect = $derived(
|
||||||
|
viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : viewMode === AlbumPageViewMode.SELECT_THUMBNAIL,
|
||||||
|
);
|
||||||
|
const showArchiveIcon = $derived(viewMode !== AlbumPageViewMode.SELECT_ASSETS);
|
||||||
|
const onSelect = ({ id }: { id: string }) => {
|
||||||
|
if (viewMode !== AlbumPageViewMode.SELECT_ASSETS) {
|
||||||
|
void handleUpdateThumbnail(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const currentAssetIntersection = $derived(
|
||||||
|
viewMode === AlbumPageViewMode.SELECT_ASSETS ? timelineInteraction : assetInteraction,
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex overflow-hidden" use:scrollMemoryClearer={{ routeStartsWith: AppRoute.ALBUMS }}>
|
<div class="flex overflow-hidden" use:scrollMemoryClearer={{ routeStartsWith: AppRoute.ALBUMS }}>
|
||||||
@ -445,7 +465,14 @@
|
|||||||
<AddToAlbum shared />
|
<AddToAlbum shared />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
{#if assetInteraction.isAllUserOwned}
|
{#if assetInteraction.isAllUserOwned}
|
||||||
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} />
|
<FavoriteAction
|
||||||
|
removeFavorite={assetInteraction.isAllFavorite}
|
||||||
|
onFavorite={(ids, isFavorite) =>
|
||||||
|
assetStore.updateAssetOperation(ids, (asset) => {
|
||||||
|
asset.isFavorite = isFavorite;
|
||||||
|
return { remove: false };
|
||||||
|
})}
|
||||||
|
></FavoriteAction>
|
||||||
{/if}
|
{/if}
|
||||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||||
<DownloadAction menuItem filename="{album.albumName}.zip" />
|
<DownloadAction menuItem filename="{album.albumName}.zip" />
|
||||||
@ -482,6 +509,7 @@
|
|||||||
<CircleIconButton
|
<CircleIconButton
|
||||||
title={$t('add_photos')}
|
title={$t('add_photos')}
|
||||||
onclick={async () => {
|
onclick={async () => {
|
||||||
|
assetStore.suspendTransitions = true;
|
||||||
viewMode = AlbumPageViewMode.SELECT_ASSETS;
|
viewMode = AlbumPageViewMode.SELECT_ASSETS;
|
||||||
oldAt = { at: $gridScrollTarget?.at };
|
oldAt = { at: $gridScrollTarget?.at };
|
||||||
await navigate(
|
await navigate(
|
||||||
@ -576,28 +604,19 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<main class="relative h-screen overflow-hidden bg-immich-bg px-6 pt-[var(--navbar-height)] dark:bg-immich-dark-bg">
|
<main class="relative h-screen overflow-hidden bg-immich-bg px-6 pt-[var(--navbar-height)] dark:bg-immich-dark-bg">
|
||||||
<!-- Use key because AssetGrid can't deal with changing stores -->
|
|
||||||
{#key albumKey}
|
|
||||||
{#if viewMode === AlbumPageViewMode.SELECT_ASSETS}
|
|
||||||
<AssetGrid
|
<AssetGrid
|
||||||
enableRouting={false}
|
enableRouting={viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : true}
|
||||||
assetStore={timelineStore}
|
|
||||||
assetInteraction={timelineInteraction}
|
|
||||||
isSelectionMode={true}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<AssetGrid
|
|
||||||
enableRouting={true}
|
|
||||||
{album}
|
{album}
|
||||||
{assetStore}
|
{assetStore}
|
||||||
{assetInteraction}
|
assetInteraction={currentAssetIntersection}
|
||||||
isShared={album.albumUsers.length > 0}
|
{isShared}
|
||||||
isSelectionMode={viewMode === AlbumPageViewMode.SELECT_THUMBNAIL}
|
{isSelectionMode}
|
||||||
singleSelect={viewMode === AlbumPageViewMode.SELECT_THUMBNAIL}
|
{singleSelect}
|
||||||
showArchiveIcon
|
{showArchiveIcon}
|
||||||
onSelect={({ id }) => handleUpdateThumbnail(id)}
|
{onSelect}
|
||||||
onEscape={handleEscape}
|
onEscape={handleEscape}
|
||||||
>
|
>
|
||||||
|
{#if viewMode !== AlbumPageViewMode.SELECT_ASSETS}
|
||||||
{#if viewMode !== AlbumPageViewMode.SELECT_THUMBNAIL}
|
{#if viewMode !== AlbumPageViewMode.SELECT_THUMBNAIL}
|
||||||
<!-- ALBUM TITLE -->
|
<!-- ALBUM TITLE -->
|
||||||
<section class="pt-8 md:pt-24">
|
<section class="pt-8 md:pt-24">
|
||||||
@ -682,8 +701,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
</AssetGrid>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
</AssetGrid>
|
||||||
|
|
||||||
{#if showActivityStatus}
|
{#if showActivityStatus}
|
||||||
<div class="absolute z-[2] bottom-0 right-0 mb-6 mr-6 justify-self-end">
|
<div class="absolute z-[2] bottom-0 right-0 mb-6 mr-6 justify-self-end">
|
||||||
@ -696,7 +715,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/key}
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
{#if album.albumUsers.length > 0 && album && isShowActivity && $user && !$showAssetViewer}
|
{#if album.albumUsers.length > 0 && album && isShowActivity && $user && !$showAssetViewer}
|
||||||
|
@ -12,20 +12,23 @@
|
|||||||
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 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 { AssetStore } from '$lib/stores/assets-store.svelte';
|
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import { mdiPlus, mdiDotsVertical } from '@mdi/js';
|
import { mdiPlus, mdiDotsVertical } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
|
import { AssetStore } from '$lib/stores/assets-store.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: PageData;
|
data: PageData;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { data }: Props = $props();
|
let { data }: Props = $props();
|
||||||
|
const assetStore = new AssetStore();
|
||||||
|
void assetStore.updateOptions({ isArchived: true });
|
||||||
|
onDestroy(() => assetStore.destroy());
|
||||||
|
|
||||||
const assetStore = new AssetStore({ isArchived: true });
|
|
||||||
const assetInteraction = new AssetInteraction();
|
const assetInteraction = new AssetInteraction();
|
||||||
|
|
||||||
const handleEscape = () => {
|
const handleEscape = () => {
|
||||||
@ -34,10 +37,6 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
assetStore.destroy();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if assetInteraction.selectionActive}
|
{#if assetInteraction.selectionActive}
|
||||||
@ -45,14 +44,28 @@
|
|||||||
assets={assetInteraction.selectedAssets}
|
assets={assetInteraction.selectedAssets}
|
||||||
clearSelect={() => assetInteraction.clearMultiselect()}
|
clearSelect={() => assetInteraction.clearMultiselect()}
|
||||||
>
|
>
|
||||||
<ArchiveAction unarchive onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
|
<ArchiveAction
|
||||||
|
unarchive
|
||||||
|
onArchive={(ids, isArchived) =>
|
||||||
|
assetStore.updateAssetOperation(ids, (asset) => {
|
||||||
|
asset.isArchived = isArchived;
|
||||||
|
return { remove: false };
|
||||||
|
})}
|
||||||
|
/>
|
||||||
<CreateSharedLink />
|
<CreateSharedLink />
|
||||||
<SelectAllAssets {assetStore} {assetInteraction} />
|
<SelectAllAssets {assetStore} {assetInteraction} />
|
||||||
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||||
<AddToAlbum />
|
<AddToAlbum />
|
||||||
<AddToAlbum shared />
|
<AddToAlbum shared />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} />
|
<FavoriteAction
|
||||||
|
removeFavorite={assetInteraction.isAllFavorite}
|
||||||
|
onFavorite={(ids, isFavorite) =>
|
||||||
|
assetStore.updateAssetOperation(ids, (asset) => {
|
||||||
|
asset.isFavorite = isFavorite;
|
||||||
|
return { remove: false };
|
||||||
|
})}
|
||||||
|
/>
|
||||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||||
<DownloadAction menuItem />
|
<DownloadAction menuItem />
|
||||||
<DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
|
<DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
|
||||||
|
@ -29,7 +29,10 @@
|
|||||||
|
|
||||||
let { data }: Props = $props();
|
let { data }: Props = $props();
|
||||||
|
|
||||||
const assetStore = new AssetStore({ isFavorite: true });
|
const assetStore = new AssetStore();
|
||||||
|
void assetStore.updateOptions({ isFavorite: true });
|
||||||
|
onDestroy(() => assetStore.destroy());
|
||||||
|
|
||||||
const assetInteraction = new AssetInteraction();
|
const assetInteraction = new AssetInteraction();
|
||||||
|
|
||||||
const handleEscape = () => {
|
const handleEscape = () => {
|
||||||
@ -38,10 +41,6 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
assetStore.destroy();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Multiselection mode app bar -->
|
<!-- Multiselection mode app bar -->
|
||||||
|
@ -98,7 +98,19 @@
|
|||||||
<AddToAlbum onAddToAlbum={() => cancelMultiselect(assetInteraction)} />
|
<AddToAlbum onAddToAlbum={() => cancelMultiselect(assetInteraction)} />
|
||||||
<AddToAlbum onAddToAlbum={() => cancelMultiselect(assetInteraction)} shared />
|
<AddToAlbum onAddToAlbum={() => cancelMultiselect(assetInteraction)} shared />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} onFavorite={triggerAssetUpdate} />
|
<FavoriteAction
|
||||||
|
removeFavorite={assetInteraction.isAllFavorite}
|
||||||
|
onFavorite={(ids, isFavorite) => {
|
||||||
|
if (data.pathAssets && data.pathAssets.length > 0) {
|
||||||
|
for (const id of ids) {
|
||||||
|
const asset = data.pathAssets.find((asset) => asset.id === id);
|
||||||
|
if (asset) {
|
||||||
|
asset.isFavorite = isFavorite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||||
<DownloadAction menuItem />
|
<DownloadAction menuItem />
|
||||||
|
@ -123,7 +123,7 @@
|
|||||||
|
|
||||||
async function navigateRandom() {
|
async function navigateRandom() {
|
||||||
if (viewingAssets.length <= 0) {
|
if (viewingAssets.length <= 0) {
|
||||||
return null;
|
return undefined;
|
||||||
}
|
}
|
||||||
const index = Math.floor(Math.random() * viewingAssets.length);
|
const index = Math.floor(Math.random() * viewingAssets.length);
|
||||||
const asset = await setAssetId(viewingAssets[index]);
|
const asset = await setAssetId(viewingAssets[index]);
|
||||||
|
@ -21,7 +21,9 @@
|
|||||||
|
|
||||||
let { data }: Props = $props();
|
let { data }: Props = $props();
|
||||||
|
|
||||||
const assetStore = new AssetStore({ userId: data.partner.id, isArchived: false, withStacked: true });
|
const assetStore = new AssetStore();
|
||||||
|
$effect(() => void assetStore.updateOptions({ userId: data.partner.id, isArchived: false, withStacked: true }));
|
||||||
|
onDestroy(() => assetStore.destroy());
|
||||||
const assetInteraction = new AssetInteraction();
|
const assetInteraction = new AssetInteraction();
|
||||||
|
|
||||||
const handleEscape = () => {
|
const handleEscape = () => {
|
||||||
@ -30,10 +32,6 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
assetStore.destroy();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="grid h-screen bg-immich-bg pt-18 dark:bg-immich-dark-bg">
|
<main class="grid h-screen bg-immich-bg pt-18 dark:bg-immich-dark-bg">
|
||||||
|
@ -456,10 +456,10 @@
|
|||||||
</UserPageLayout>
|
</UserPageLayout>
|
||||||
|
|
||||||
{#if selectHidden}
|
{#if selectHidden}
|
||||||
<div
|
<dialog
|
||||||
|
open
|
||||||
transition:fly={{ y: innerHeight, duration: 150, easing: quintOut, opacity: 0 }}
|
transition:fly={{ y: innerHeight, duration: 150, easing: quintOut, opacity: 0 }}
|
||||||
class="absolute left-0 top-0 z-[9999] h-full w-full bg-immich-bg dark:bg-immich-dark-bg"
|
class="absolute left-0 top-0 z-[9999] h-full w-full bg-immich-bg dark:bg-immich-dark-bg"
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="manage-visibility-title"
|
aria-labelledby="manage-visibility-title"
|
||||||
use:focusTrap
|
use:focusTrap
|
||||||
@ -471,5 +471,5 @@
|
|||||||
onClose={() => (selectHidden = false)}
|
onClose={() => (selectHidden = false)}
|
||||||
{loadNextPage}
|
{loadNextPage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</dialog>
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -74,14 +74,9 @@
|
|||||||
let numberOfAssets = $state(data.statistics.assets);
|
let numberOfAssets = $state(data.statistics.assets);
|
||||||
let { isViewing: showAssetViewer } = assetViewingStore;
|
let { isViewing: showAssetViewer } = assetViewingStore;
|
||||||
|
|
||||||
const assetStoreOptions = { isArchived: false, personId: data.person.id };
|
const assetStore = new AssetStore();
|
||||||
const assetStore = new AssetStore(assetStoreOptions);
|
$effect(() => void assetStore.updateOptions({ isArchived: false, personId: data.person.id }));
|
||||||
|
onDestroy(() => assetStore.destroy());
|
||||||
$effect(() => {
|
|
||||||
// Check to trigger rebuild the timeline when navigating between people from the info panel
|
|
||||||
assetStoreOptions.personId = data.person.id;
|
|
||||||
handlePromiseError(assetStore.updateOptions(assetStoreOptions));
|
|
||||||
});
|
|
||||||
|
|
||||||
const assetInteraction = new AssetInteraction();
|
const assetInteraction = new AssetInteraction();
|
||||||
|
|
||||||
@ -360,9 +355,6 @@
|
|||||||
await updateAssetCount();
|
await updateAssetCount();
|
||||||
};
|
};
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
assetStore.destroy();
|
|
||||||
});
|
|
||||||
let person = $derived(data.person);
|
let person = $derived(data.person);
|
||||||
|
|
||||||
let thumbnailData = $derived(getPeopleThumbnailUrl(person));
|
let thumbnailData = $derived(getPeopleThumbnailUrl(person));
|
||||||
@ -418,7 +410,14 @@
|
|||||||
<AddToAlbum />
|
<AddToAlbum />
|
||||||
<AddToAlbum shared />
|
<AddToAlbum shared />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} />
|
<FavoriteAction
|
||||||
|
removeFavorite={assetInteraction.isAllFavorite}
|
||||||
|
onFavorite={(ids, isFavorite) =>
|
||||||
|
assetStore.updateAssetOperation(ids, (asset) => {
|
||||||
|
asset.isFavorite = isFavorite;
|
||||||
|
return { remove: false };
|
||||||
|
})}
|
||||||
|
/>
|
||||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||||
<DownloadAction menuItem filename="{person.name || 'immich'}.zip" />
|
<DownloadAction menuItem filename="{person.name || 'immich'}.zip" />
|
||||||
<MenuOption
|
<MenuOption
|
||||||
|
@ -33,7 +33,10 @@
|
|||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
let { isViewing: showAssetViewer } = assetViewingStore;
|
let { isViewing: showAssetViewer } = assetViewingStore;
|
||||||
const assetStore = new AssetStore({ isArchived: false, withStacked: true, withPartners: true });
|
const assetStore = new AssetStore();
|
||||||
|
void assetStore.updateOptions({ isArchived: false, withStacked: true, withPartners: true });
|
||||||
|
onDestroy(() => assetStore.destroy());
|
||||||
|
|
||||||
const assetInteraction = new AssetInteraction();
|
const assetInteraction = new AssetInteraction();
|
||||||
|
|
||||||
let selectedAssets = $derived(assetInteraction.selectedAssetsArray);
|
let selectedAssets = $derived(assetInteraction.selectedAssetsArray);
|
||||||
@ -67,10 +70,6 @@
|
|||||||
assetStore.updateAssets([still]);
|
assetStore.updateAssets([still]);
|
||||||
};
|
};
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
assetStore.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeNavigate(() => {
|
beforeNavigate(() => {
|
||||||
isFaceEditMode.value = false;
|
isFaceEditMode.value = false;
|
||||||
});
|
});
|
||||||
@ -88,7 +87,14 @@
|
|||||||
<AddToAlbum />
|
<AddToAlbum />
|
||||||
<AddToAlbum shared />
|
<AddToAlbum shared />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} />
|
<FavoriteAction
|
||||||
|
removeFavorite={assetInteraction.isAllFavorite}
|
||||||
|
onFavorite={(ids, isFavorite) =>
|
||||||
|
assetStore.updateAssetOperation(ids, (asset) => {
|
||||||
|
asset.isFavorite = isFavorite;
|
||||||
|
return { remove: false };
|
||||||
|
})}
|
||||||
|
></FavoriteAction>
|
||||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||||
<DownloadAction menuItem />
|
<DownloadAction menuItem />
|
||||||
{#if assetInteraction.selectedAssets.size > 1 || isAssetStackSelected}
|
{#if assetInteraction.selectedAssets.size > 1 || isAssetStackSelected}
|
||||||
|
@ -136,13 +136,17 @@
|
|||||||
nextPage = 1;
|
nextPage = 1;
|
||||||
searchResultAssets = [];
|
searchResultAssets = [];
|
||||||
searchResultAlbums = [];
|
searchResultAlbums = [];
|
||||||
await loadNextPage();
|
await loadNextPage(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadNextPage = async () => {
|
// eslint-disable-next-line svelte/valid-prop-names-in-kit-pages
|
||||||
|
export const loadNextPage = async (force?: boolean) => {
|
||||||
if (!nextPage || searchResultAssets.length >= MAX_ASSET_COUNT) {
|
if (!nextPage || searchResultAssets.length >= MAX_ASSET_COUNT) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isLoading && !force) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
|
|
||||||
const searchDto: SearchTerms = {
|
const searchDto: SearchTerms = {
|
||||||
@ -232,9 +236,6 @@
|
|||||||
return tagNames.join(', ');
|
return tagNames.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line no-self-assign
|
|
||||||
const triggerAssetUpdate = () => (searchResultAssets = searchResultAssets);
|
|
||||||
|
|
||||||
const onAddToAlbum = (assetIds: string[]) => {
|
const onAddToAlbum = (assetIds: string[]) => {
|
||||||
if (terms.isNotInAlbum.toString() == 'true') {
|
if (terms.isNotInAlbum.toString() == 'true') {
|
||||||
const assetIdSet = new Set(assetIds);
|
const assetIdSet = new Set(assetIds);
|
||||||
@ -262,13 +263,23 @@
|
|||||||
<AddToAlbum {onAddToAlbum} />
|
<AddToAlbum {onAddToAlbum} />
|
||||||
<AddToAlbum shared {onAddToAlbum} />
|
<AddToAlbum shared {onAddToAlbum} />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} onFavorite={triggerAssetUpdate} />
|
<FavoriteAction
|
||||||
|
removeFavorite={assetInteraction.isAllFavorite}
|
||||||
|
onFavorite={(ids, isFavorite) => {
|
||||||
|
for (const id of ids) {
|
||||||
|
const asset = searchResultAssets.find((asset) => asset.id === id);
|
||||||
|
if (asset) {
|
||||||
|
asset.isFavorite = isFavorite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||||
<DownloadAction menuItem />
|
<DownloadAction menuItem />
|
||||||
<ChangeDate menuItem />
|
<ChangeDate menuItem />
|
||||||
<ChangeLocation menuItem />
|
<ChangeLocation menuItem />
|
||||||
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} onArchive={triggerAssetUpdate} />
|
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
|
||||||
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
||||||
<TagAction menuItem />
|
<TagAction menuItem />
|
||||||
{/if}
|
{/if}
|
||||||
@ -281,6 +292,10 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="fixed z-[100] top-0 left-0 w-full">
|
<div class="fixed z-[100] top-0 left-0 w-full">
|
||||||
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
|
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
|
||||||
|
<div
|
||||||
|
class="-z-[1] bg-immich-bg dark:bg-immich-dark-bg"
|
||||||
|
style="position:absolute;top:0;left:0;right:0;bottom:0;"
|
||||||
|
></div>
|
||||||
<div class="w-full flex-1 pl-4">
|
<div class="w-full flex-1 pl-4">
|
||||||
<SearchBar grayTheme={false} value={terms?.query ?? ''} searchQuery={terms} />
|
<SearchBar grayTheme={false} value={terms?.query ?? ''} searchQuery={terms} />
|
||||||
</div>
|
</div>
|
||||||
@ -329,11 +344,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<section
|
<section
|
||||||
class="relative mb-12 bg-immich-bg dark:bg-immich-dark-bg m-4"
|
class="mb-12 bg-immich-bg dark:bg-immich-dark-bg m-4"
|
||||||
bind:clientHeight={viewport.height}
|
bind:clientHeight={viewport.height}
|
||||||
bind:clientWidth={viewport.width}
|
bind:clientWidth={viewport.width}
|
||||||
>
|
>
|
||||||
<section class="immich-scrollbar relative overflow-y-auto">
|
|
||||||
{#if searchResultAlbums.length > 0}
|
{#if searchResultAlbums.length > 0}
|
||||||
<section>
|
<section>
|
||||||
<div class="ml-6 text-4xl font-medium text-black/70 dark:text-white/80">{$t('albums').toUpperCase()}</div>
|
<div class="ml-6 text-4xl font-medium text-black/70 dark:text-white/80">{$t('albums').toUpperCase()}</div>
|
||||||
@ -344,7 +358,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
<section id="search-content" class="relative bg-immich-bg dark:bg-immich-dark-bg">
|
<section id="search-content">
|
||||||
{#if searchResultAssets.length > 0}
|
{#if searchResultAssets.length > 0}
|
||||||
<GalleryViewer
|
<GalleryViewer
|
||||||
assets={searchResultAssets}
|
assets={searchResultAssets}
|
||||||
@ -370,4 +384,3 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
|
||||||
|
@ -24,6 +24,7 @@
|
|||||||
import { mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js';
|
import { mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: PageData;
|
data: PageData;
|
||||||
@ -39,8 +40,9 @@
|
|||||||
const buildMap = (tags: TagResponseDto[]) => {
|
const buildMap = (tags: TagResponseDto[]) => {
|
||||||
return Object.fromEntries(tags.map((tag) => [tag.value, tag]));
|
return Object.fromEntries(tags.map((tag) => [tag.value, tag]));
|
||||||
};
|
};
|
||||||
|
const assetStore = new AssetStore();
|
||||||
const assetStore = new AssetStore({});
|
$effect(() => void assetStore.updateOptions({ deferInit: !tag, tagId }));
|
||||||
|
onDestroy(() => assetStore.destroy());
|
||||||
|
|
||||||
let tags = $state<TagResponseDto[]>([]);
|
let tags = $state<TagResponseDto[]>([]);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@ -52,10 +54,6 @@
|
|||||||
let tagId = $derived(tag?.id);
|
let tagId = $derived(tag?.id);
|
||||||
let tree = $derived(buildTree(tags.map((tag) => tag.value)));
|
let tree = $derived(buildTree(tags.map((tag) => tag.value)));
|
||||||
|
|
||||||
$effect.pre(() => {
|
|
||||||
void assetStore.updateOptions({ tagId });
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleNavigation = async (tag: string) => {
|
const handleNavigation = async (tag: string) => {
|
||||||
await navigateToView(normalizeTreePath(`${data.path || ''}/${tag}`));
|
await navigateToView(normalizeTreePath(`${data.path || ''}/${tag}`));
|
||||||
};
|
};
|
||||||
|
@ -36,8 +36,10 @@
|
|||||||
handlePromiseError(goto(AppRoute.PHOTOS));
|
handlePromiseError(goto(AppRoute.PHOTOS));
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = { isTrashed: true };
|
const assetStore = new AssetStore();
|
||||||
const assetStore = new AssetStore(options);
|
void assetStore.updateOptions({ isTrashed: true });
|
||||||
|
onDestroy(() => assetStore.destroy());
|
||||||
|
|
||||||
const assetInteraction = new AssetInteraction();
|
const assetInteraction = new AssetInteraction();
|
||||||
|
|
||||||
const handleEmptyTrash = async () => {
|
const handleEmptyTrash = async () => {
|
||||||
@ -56,9 +58,6 @@
|
|||||||
message: $t('assets_permanently_deleted_count', { values: { count } }),
|
message: $t('assets_permanently_deleted_count', { values: { count } }),
|
||||||
type: NotificationType.Info,
|
type: NotificationType.Info,
|
||||||
});
|
});
|
||||||
|
|
||||||
// reset asset grid (TODO fix in asset store that it should reset when it is empty)
|
|
||||||
await assetStore.updateOptions(options);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.unable_to_empty_trash'));
|
handleError(error, $t('errors.unable_to_empty_trash'));
|
||||||
}
|
}
|
||||||
@ -80,7 +79,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 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)
|
||||||
await assetStore.updateOptions(options);
|
// 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
|
||||||
|
// await assetStore.updateOptions({ deferInit: true, isTrashed: true });
|
||||||
|
// await assetStore.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'));
|
||||||
}
|
}
|
||||||
@ -92,10 +94,6 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
assetStore.destroy();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if assetInteraction.selectionActive}
|
{#if assetInteraction.selectionActive}
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
"checkJs": true,
|
"checkJs": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"module": "es2020",
|
"module": "es2022",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user