mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge branch 'main' into feat/search-filter-album/web
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
> 0.2% and last 4 major versions
|
||||
> 0.5%
|
||||
not dead
|
||||
+1
-1
@@ -1 +1 @@
|
||||
22.16.0
|
||||
22.17.0
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import js from '@eslint/js';
|
||||
import tslintPluginCompat from '@koddsson/eslint-plugin-tscompat';
|
||||
import eslintPluginCompat from 'eslint-plugin-compat';
|
||||
import eslintPluginSvelte from 'eslint-plugin-svelte';
|
||||
import eslintPluginUnicorn from 'eslint-plugin-unicorn';
|
||||
import globals from 'globals';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import parser from 'svelte-eslint-parser';
|
||||
@@ -14,6 +17,43 @@ export default typescriptEslint.config(
|
||||
...eslintPluginSvelte.configs.recommended,
|
||||
eslintPluginUnicorn.configs.recommended,
|
||||
js.configs.recommended,
|
||||
{
|
||||
plugins: {
|
||||
tscompat: tslintPluginCompat,
|
||||
},
|
||||
rules: {
|
||||
'tscompat/tscompat': [
|
||||
'error',
|
||||
{
|
||||
browserslist: fs
|
||||
.readFileSync(path.join(__dirname, '.browserslistrc'), 'utf8')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith('#')),
|
||||
},
|
||||
],
|
||||
},
|
||||
languageOptions: {
|
||||
parser,
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
},
|
||||
ignores: ['**/service-worker/**'],
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
compat: eslintPluginCompat,
|
||||
},
|
||||
settings: {
|
||||
polyfills: [],
|
||||
lintAllEsApis: true,
|
||||
},
|
||||
rules: {
|
||||
'compat/compat': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
'**/.DS_Store',
|
||||
|
||||
Generated
+740
-449
File diff suppressed because it is too large
Load Diff
+11
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "1.134.0",
|
||||
"version": "1.135.3",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -28,7 +28,7 @@
|
||||
"dependencies": {
|
||||
"@formatjs/icu-messageformat-parser": "^2.9.8",
|
||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||
"@immich/ui": "^0.22.4",
|
||||
"@immich/ui": "^0.23.2",
|
||||
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@photo-sphere-viewer/core": "^5.11.5",
|
||||
@@ -37,9 +37,12 @@
|
||||
"@photo-sphere-viewer/settings-plugin": "^5.11.5",
|
||||
"@photo-sphere-viewer/video-plugin": "^5.11.5",
|
||||
"@zoom-image/svelte": "^0.3.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"dom-to-image": "^2.6.0",
|
||||
"fabric": "^6.5.4",
|
||||
"geojson": "^0.5.0",
|
||||
"handlebars": "^4.7.8",
|
||||
"happy-dom": "^18.0.1",
|
||||
"intl-messageformat": "^10.7.11",
|
||||
"justified-layout": "^4.1.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
@@ -59,6 +62,7 @@
|
||||
"@eslint/eslintrc": "^3.1.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@faker-js/faker": "^9.3.0",
|
||||
"@koddsson/eslint-plugin-tscompat": "^0.2.0",
|
||||
"@socket.io/component-emitter": "^3.1.0",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/enhanced-img": "^0.6.0",
|
||||
@@ -79,11 +83,13 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.0",
|
||||
"eslint-p": "^0.23.0",
|
||||
"eslint-p": "^0.24.0",
|
||||
"eslint-plugin-compat": "^6.0.2",
|
||||
"eslint-plugin-svelte": "^3.9.0",
|
||||
"eslint-plugin-unicorn": "^59.0.0",
|
||||
"factory.ts": "^1.4.1",
|
||||
"globals": "^16.0.0",
|
||||
"happy-dom": "^18.0.1",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-organize-imports": "^4.0.0",
|
||||
"prettier-plugin-sort-json": "^4.1.1",
|
||||
@@ -91,6 +97,7 @@
|
||||
"rollup-plugin-visualizer": "^6.0.0",
|
||||
"svelte": "^5.25.3",
|
||||
"svelte-check": "^4.1.5",
|
||||
"svelte-eslint-parser": "^1.2.0",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.7.3",
|
||||
@@ -99,6 +106,6 @@
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"volta": {
|
||||
"node": "22.16.0"
|
||||
"node": "22.17.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,17 +41,11 @@
|
||||
--color-immich-bg: rgb(var(--immich-bg));
|
||||
--color-immich-fg: rgb(var(--immich-fg));
|
||||
--color-immich-gray: rgb(var(--immich-gray));
|
||||
--color-immich-error: rgb(var(--immich-error));
|
||||
--color-immich-success: rgb(var(--immich-success));
|
||||
--color-immich-warning: rgb(var(--immich-warning));
|
||||
|
||||
--color-immich-dark-primary: rgb(var(--immich-dark-primary));
|
||||
--color-immich-dark-bg: rgb(var(--immich-dark-bg));
|
||||
--color-immich-dark-fg: rgb(var(--immich-dark-fg));
|
||||
--color-immich-dark-gray: rgb(var(--immich-dark-gray));
|
||||
--color-immich-dark-error: rgb(var(--immich-dark-error));
|
||||
--color-immich-dark-success: rgb(var(--immich-dark-success));
|
||||
--color-immich-dark-warning: rgb(var(--immich-dark-warning));
|
||||
}
|
||||
|
||||
@theme {
|
||||
@@ -74,18 +68,12 @@
|
||||
--immich-primary: 66 80 175;
|
||||
--immich-bg: 255 255 255;
|
||||
--immich-fg: 0 0 0;
|
||||
--immich-error: 229 115 115;
|
||||
--immich-success: 129 199 132;
|
||||
--immich-warning: 255 183 77;
|
||||
|
||||
/* dark */
|
||||
--immich-dark-primary: 172 203 250;
|
||||
--immich-dark-bg: 10 10 10;
|
||||
--immich-dark-fg: 229 231 235;
|
||||
--immich-dark-gray: 33 33 33;
|
||||
--immich-dark-error: 211 47 47;
|
||||
--immich-dark-success: 56 142 60;
|
||||
--immich-dark-warning: 245 124 0;
|
||||
}
|
||||
|
||||
*,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { JobCommand, type JobCommandDto, type JobCountsDto, type QueueStatusDto } from '@immich/sdk';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import {
|
||||
mdiAlertCircle,
|
||||
mdiAllInclusive,
|
||||
@@ -17,7 +18,6 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
import JobTileButton from './job-tile-button.svelte';
|
||||
import JobTileStatus from './job-tile-status.svelte';
|
||||
import { IconButton } from '@immich/ui';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -71,7 +71,7 @@
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
{#if jobCounts.failed > 0}
|
||||
<Badge color="primary">
|
||||
<Badge>
|
||||
<div class="flex flex-row gap-1">
|
||||
<span class="text-sm">
|
||||
{$t('admin.jobs_failed', { values: { jobCount: jobCounts.failed.toLocaleString($locale) } })}
|
||||
@@ -88,7 +88,7 @@
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if jobCounts.delayed > 0}
|
||||
<Badge color="secondary">
|
||||
<Badge>
|
||||
<span class="text-sm">
|
||||
{$t('admin.jobs_delayed', { values: { jobCount: jobCounts.delayed.toLocaleString($locale) } })}
|
||||
</span>
|
||||
|
||||
@@ -113,12 +113,12 @@
|
||||
>
|
||||
<td class="w-1/4 text-ellipsis px-2 text-sm">
|
||||
{getByteUnitString(user.usage, $locale, 0)}
|
||||
{#if user.quotaSizeInBytes}
|
||||
{#if user.quotaSizeInBytes !== null}
|
||||
/ {getByteUnitString(user.quotaSizeInBytes, $locale, 0)}
|
||||
{/if}
|
||||
<span class="text-immich-primary dark:text-immich-dark-primary">
|
||||
{#if user.quotaSizeInBytes}
|
||||
({(user.usage / user.quotaSizeInBytes).toLocaleString($locale, {
|
||||
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
|
||||
({(user.quotaSizeInBytes === 0 ? 1 : user.usage / user.quotaSizeInBytes).toLocaleString($locale, {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: 0,
|
||||
})})
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
const handleSave = async (skipConfirm: boolean) => {
|
||||
const allMethodsDisabled = !config.oauth.enabled && !config.passwordLogin.enabled;
|
||||
if (allMethodsDisabled && !skipConfirm) {
|
||||
const isConfirmed = await modalManager.show(AuthDisableLoginConfirmModal, {});
|
||||
const isConfirmed = await modalManager.show(AuthDisableLoginConfirmModal);
|
||||
if (!isConfirmed) {
|
||||
return;
|
||||
}
|
||||
@@ -167,6 +167,16 @@
|
||||
isEdited={!(config.oauth.storageLabelClaim == savedConfig.oauth.storageLabelClaim)}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.oauth_role_claim').toUpperCase()}
|
||||
description={$t('admin.oauth_role_claim_description')}
|
||||
bind:value={config.oauth.roleClaim}
|
||||
required={true}
|
||||
disabled={disabled || !config.oauth.enabled}
|
||||
isEdited={!(config.oauth.roleClaim == savedConfig.oauth.roleClaim)}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.oauth_storage_quota_claim').toUpperCase()}
|
||||
@@ -182,7 +192,7 @@
|
||||
label={$t('admin.oauth_storage_quota_default').toUpperCase()}
|
||||
description={$t('admin.oauth_storage_quota_default_description')}
|
||||
bind:value={config.oauth.defaultStorageQuota}
|
||||
required={true}
|
||||
required={false}
|
||||
disabled={disabled || !config.oauth.enabled}
|
||||
isEdited={!(config.oauth.defaultStorageQuota == savedConfig.oauth.defaultStorageQuota)}
|
||||
/>
|
||||
|
||||
+45
-64
@@ -4,9 +4,11 @@
|
||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
import SettingTextarea from '$lib/components/shared-components/settings/setting-textarea.svelte';
|
||||
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||
import EmailTemplatePreviewModal from '$lib/modals/EmailTemplatePreviewModal.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { type SystemConfigDto, type SystemConfigTemplateEmailsDto, getNotificationTemplateAdmin } from '@immich/sdk';
|
||||
import { Button, Modal, ModalBody } from '@immich/ui';
|
||||
import { Button } from '@immich/ui';
|
||||
import { mdiEyeOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
@@ -18,14 +20,13 @@
|
||||
|
||||
let { savedConfig, config = $bindable() }: Props = $props();
|
||||
|
||||
let htmlPreview = $state('');
|
||||
let loadingPreview = $state(false);
|
||||
|
||||
const getTemplate = async (name: string, template: string) => {
|
||||
try {
|
||||
loadingPreview = true;
|
||||
const result = await getNotificationTemplateAdmin({ name, templateDto: { template } });
|
||||
htmlPreview = result.html;
|
||||
const { html } = await getNotificationTemplateAdmin({ name, templateDto: { template } });
|
||||
await modalManager.show(EmailTemplatePreviewModal, { html });
|
||||
} catch (error) {
|
||||
handleError(error, 'Could not load template.');
|
||||
} finally {
|
||||
@@ -33,10 +34,6 @@
|
||||
}
|
||||
};
|
||||
|
||||
const closePreviewModal = () => {
|
||||
htmlPreview = '';
|
||||
};
|
||||
|
||||
const templateConfigs = [
|
||||
{
|
||||
label: $t('admin.template_email_welcome'),
|
||||
@@ -66,63 +63,47 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" {onsubmit} class="mt-4">
|
||||
<div class="flex flex-col gap-4">
|
||||
<SettingAccordion
|
||||
key="templates"
|
||||
title={$t('admin.template_email_settings')}
|
||||
subtitle={$t('admin.template_settings_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<p class="text-sm dark:text-immich-dark-fg">
|
||||
<FormatMessage key="admin.template_email_if_empty">
|
||||
{$t('admin.template_email_if_empty')}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
<hr />
|
||||
{#if loadingPreview}
|
||||
<LoadingSpinner />
|
||||
{/if}
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" {onsubmit} class="mt-4">
|
||||
<div class="flex flex-col gap-4">
|
||||
<SettingAccordion
|
||||
key="templates"
|
||||
title={$t('admin.template_email_settings')}
|
||||
subtitle={$t('admin.template_settings_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<p class="text-sm dark:text-immich-dark-fg">
|
||||
<FormatMessage key="admin.template_email_if_empty">
|
||||
{$t('admin.template_email_if_empty')}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
<hr />
|
||||
{#if loadingPreview}
|
||||
<LoadingSpinner />
|
||||
{/if}
|
||||
|
||||
{#each templateConfigs as { label, templateKey, descriptionTags, templateName } (templateKey)}
|
||||
<SettingTextarea
|
||||
{label}
|
||||
description={$t('admin.template_email_available_tags', { values: { tags: descriptionTags } })}
|
||||
bind:value={config.templates.email[templateKey]}
|
||||
isEdited={isEdited(templateKey)}
|
||||
disabled={!config.notifications.smtp.enabled}
|
||||
/>
|
||||
<div class="flex justify-between">
|
||||
<Button
|
||||
size="small"
|
||||
shape="round"
|
||||
onclick={() => getTemplate(templateName, config.templates.email[templateKey])}
|
||||
title={$t('admin.template_email_preview')}
|
||||
>
|
||||
<Icon path={mdiEyeOutline} class="me-1" />
|
||||
{$t('admin.template_email_preview')}
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
</div>
|
||||
|
||||
{#if htmlPreview}
|
||||
<Modal title={$t('admin.template_email_preview')} onClose={closePreviewModal} size="medium">
|
||||
<ModalBody>
|
||||
<div style="position:relative; width:100%; height:90vh; overflow: hidden">
|
||||
<iframe
|
||||
{#each templateConfigs as { label, templateKey, descriptionTags, templateName } (templateKey)}
|
||||
<SettingTextarea
|
||||
{label}
|
||||
description={$t('admin.template_email_available_tags', { values: { tags: descriptionTags } })}
|
||||
bind:value={config.templates.email[templateKey]}
|
||||
isEdited={isEdited(templateKey)}
|
||||
disabled={!config.notifications.smtp.enabled}
|
||||
/>
|
||||
<div class="flex justify-between">
|
||||
<Button
|
||||
size="small"
|
||||
shape="round"
|
||||
onclick={() => getTemplate(templateName, config.templates.email[templateKey])}
|
||||
title={$t('admin.template_email_preview')}
|
||||
srcdoc={htmlPreview}
|
||||
style="width: 100%; height: 100%; border: none; overflow:hidden; position: absolute; top: 0; left: 0;"
|
||||
></iframe>
|
||||
>
|
||||
<Icon path={mdiEyeOutline} class="me-1" />
|
||||
{$t('admin.template_email_preview')}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { Icon, Text } from '@immich/ui';
|
||||
import { mdiCheck, mdiClose } from '@mdi/js';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
state: boolean;
|
||||
}
|
||||
|
||||
let { title, state }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
<Text class="text-sm font-medium">{title}</Text>
|
||||
<Icon icon={state ? mdiCheck : mdiClose} class={state ? 'text-primary' : 'text-danger'} size="24" />
|
||||
</div>
|
||||
@@ -1,13 +1,9 @@
|
||||
<script lang="ts">
|
||||
import Portal from '$lib/components/shared-components/portal/portal.svelte';
|
||||
import { timeToLoadTheMap } from '$lib/constants';
|
||||
import { albumMapViewManager } from '$lib/managers/album-view-map.manager.svelte';
|
||||
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||
import MapModal from '$lib/modals/MapModal.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { delay } from '$lib/utils/asset-utils';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { getAlbumInfo, type AlbumResponseDto, type MapMarkerResponseDto } from '@immich/sdk';
|
||||
import { IconButton, LoadingSpinner, Modal, ModalBody } from '@immich/ui';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import { mdiMapOutline } from '@mdi/js';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
@@ -18,11 +14,8 @@
|
||||
|
||||
let { album }: Props = $props();
|
||||
let abortController: AbortController;
|
||||
let { isViewing: showAssetViewer, asset: viewingAsset, setAssetId } = assetViewingStore;
|
||||
let viewingAssets: string[] = $state([]);
|
||||
let viewingAssetCursor = 0;
|
||||
let { setAssetId } = assetViewingStore;
|
||||
|
||||
let zoom = $derived(1);
|
||||
let mapMarkers: MapMarkerResponseDto[] = $state([]);
|
||||
|
||||
onMount(async () => {
|
||||
@@ -59,48 +52,13 @@
|
||||
return markers;
|
||||
}
|
||||
|
||||
function openMap() {
|
||||
albumMapViewManager.isInMapView = true;
|
||||
}
|
||||
async function openMap() {
|
||||
const assetIds = await modalManager.show(MapModal, { mapMarkers });
|
||||
|
||||
function closeMap() {
|
||||
if (!$showAssetViewer) {
|
||||
albumMapViewManager.isInMapView = false;
|
||||
if (assetIds) {
|
||||
await setAssetId(assetIds[0]);
|
||||
}
|
||||
}
|
||||
|
||||
async function onViewAssets(assetIds: string[]) {
|
||||
viewingAssets = assetIds;
|
||||
viewingAssetCursor = 0;
|
||||
closeMap();
|
||||
|
||||
await setAssetId(assetIds[0]);
|
||||
}
|
||||
|
||||
async function navigateNext() {
|
||||
if (viewingAssetCursor < viewingAssets.length - 1) {
|
||||
await setAssetId(viewingAssets[++viewingAssetCursor]);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function navigatePrevious() {
|
||||
if (viewingAssetCursor > 0) {
|
||||
await setAssetId(viewingAssets[--viewingAssetCursor]);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function navigateRandom() {
|
||||
if (viewingAssets.length <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const index = Math.floor(Math.random() * viewingAssets.length);
|
||||
const asset = await setAssetId(viewingAssets[index]);
|
||||
return asset;
|
||||
}
|
||||
</script>
|
||||
|
||||
<IconButton
|
||||
@@ -111,51 +69,3 @@
|
||||
onclick={openMap}
|
||||
aria-label={$t('map')}
|
||||
/>
|
||||
|
||||
{#if albumMapViewManager.isInMapView}
|
||||
<Modal title={$t('map')} size="medium" onClose={closeMap}>
|
||||
<ModalBody>
|
||||
<div class="flex flex-col w-full h-full gap-2 border border-gray-300 dark:border-light rounded-2xl">
|
||||
<div class="h-[500px] min-h-[300px] w-full">
|
||||
{#await import('../shared-components/map/map.svelte')}
|
||||
{#await delay(timeToLoadTheMap) then}
|
||||
<!-- show the loading spinner only if loading the map takes too much time -->
|
||||
<div class="flex items-center justify-center h-full w-full">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{/await}
|
||||
{:then { default: Map }}
|
||||
<Map
|
||||
center={undefined}
|
||||
{zoom}
|
||||
clickable={false}
|
||||
bind:mapMarkers
|
||||
onSelect={onViewAssets}
|
||||
showSettings={false}
|
||||
rounded
|
||||
/>
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
|
||||
<Portal target="body">
|
||||
{#if $showAssetViewer}
|
||||
{#await import('../../../lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
||||
<AssetViewer
|
||||
asset={$viewingAsset}
|
||||
showNavigation={viewingAssets.length > 1}
|
||||
onNext={navigateNext}
|
||||
onPrevious={navigatePrevious}
|
||||
onRandom={navigateRandom}
|
||||
onClose={() => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
|
||||
}}
|
||||
isShared={false}
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
</Portal>
|
||||
{/if}
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
|
||||
import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
AlbumUserRole,
|
||||
AssetOrder,
|
||||
removeUserFromAlbum,
|
||||
updateAlbumInfo,
|
||||
updateAlbumUser,
|
||||
type AlbumResponseDto,
|
||||
type UserResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { Modal, ModalBody } from '@immich/ui';
|
||||
import { mdiArrowDownThin, mdiArrowUpThin, mdiDotsVertical, mdiPlus } from '@mdi/js';
|
||||
import { findKey } from 'lodash-es';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { RenderedOption } from '../elements/dropdown.svelte';
|
||||
import { notificationController, NotificationType } from '../shared-components/notification/notification';
|
||||
import SettingDropdown from '../shared-components/settings/setting-dropdown.svelte';
|
||||
|
||||
interface Props {
|
||||
album: AlbumResponseDto;
|
||||
order: AssetOrder | undefined;
|
||||
user: UserResponseDto;
|
||||
onChangeOrder: (order: AssetOrder) => void;
|
||||
onClose: () => void;
|
||||
onToggleEnabledActivity: () => void;
|
||||
onShowSelectSharedUser: () => void;
|
||||
onRemove: (userId: string) => void;
|
||||
onRefreshAlbum: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
album,
|
||||
order,
|
||||
user,
|
||||
onChangeOrder,
|
||||
onClose,
|
||||
onToggleEnabledActivity,
|
||||
onShowSelectSharedUser,
|
||||
onRemove,
|
||||
onRefreshAlbum,
|
||||
}: Props = $props();
|
||||
|
||||
let selectedRemoveUser: UserResponseDto | null = $state(null);
|
||||
|
||||
const options: Record<AssetOrder, RenderedOption> = {
|
||||
[AssetOrder.Asc]: { icon: mdiArrowUpThin, title: $t('oldest_first') },
|
||||
[AssetOrder.Desc]: { icon: mdiArrowDownThin, title: $t('newest_first') },
|
||||
};
|
||||
|
||||
let selectedOption = $derived(order ? options[order] : options[AssetOrder.Desc]);
|
||||
|
||||
const handleToggle = async (returnedOption: RenderedOption): Promise<void> => {
|
||||
if (selectedOption === returnedOption) {
|
||||
return;
|
||||
}
|
||||
let order: AssetOrder = AssetOrder.Desc;
|
||||
order = findKey(options, (option) => option === returnedOption) as AssetOrder;
|
||||
|
||||
try {
|
||||
await updateAlbumInfo({
|
||||
id: album.id,
|
||||
updateAlbumDto: {
|
||||
order,
|
||||
},
|
||||
});
|
||||
onChangeOrder(order);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_save_album'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMenuRemove = (user: UserResponseDto): void => {
|
||||
selectedRemoveUser = user;
|
||||
};
|
||||
|
||||
const handleRemoveUser = async (): Promise<void> => {
|
||||
if (!selectedRemoveUser) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await removeUserFromAlbum({ id: album.id, userId: selectedRemoveUser.id });
|
||||
onRemove(selectedRemoveUser.id);
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
message: $t('album_user_removed', { values: { user: selectedRemoveUser.name } }),
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_remove_album_users'));
|
||||
} finally {
|
||||
selectedRemoveUser = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateSharedUserRole = async (user: UserResponseDto, role: AlbumUserRole) => {
|
||||
try {
|
||||
await updateAlbumUser({ id: album.id, userId: user.id, updateAlbumUserDto: { role } });
|
||||
const message = $t('user_role_set', {
|
||||
values: { user: user.name, role: role == AlbumUserRole.Viewer ? $t('role_viewer') : $t('role_editor') },
|
||||
});
|
||||
onRefreshAlbum();
|
||||
notificationController.show({ type: NotificationType.Info, message });
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_change_album_user_role'));
|
||||
} finally {
|
||||
selectedRemoveUser = null;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if !selectedRemoveUser}
|
||||
<Modal title={$t('options')} {onClose} size="small">
|
||||
<ModalBody>
|
||||
<div class="items-center justify-center">
|
||||
<div class="py-2">
|
||||
<h2 class="text-gray text-sm mb-2">{$t('settings').toUpperCase()}</h2>
|
||||
<div class="grid p-2 gap-y-2">
|
||||
{#if order}
|
||||
<SettingDropdown
|
||||
title={$t('display_order')}
|
||||
options={Object.values(options)}
|
||||
selectedOption={options[order]}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
{/if}
|
||||
<SettingSwitch
|
||||
title={$t('comments_and_likes')}
|
||||
subtitle={$t('let_others_respond')}
|
||||
checked={album.isActivityEnabled}
|
||||
onToggle={onToggleEnabledActivity}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-2">
|
||||
<div class="text-gray text-sm mb-3">{$t('people').toUpperCase()}</div>
|
||||
<div class="p-2">
|
||||
<button type="button" class="flex items-center gap-2" onclick={onShowSelectSharedUser}>
|
||||
<div class="rounded-full w-10 h-10 border border-gray-500 flex items-center justify-center">
|
||||
<div><Icon path={mdiPlus} size="25" /></div>
|
||||
</div>
|
||||
<div>{$t('invite_people')}</div>
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2 py-2 mt-2">
|
||||
<div>
|
||||
<UserAvatar {user} size="md" />
|
||||
</div>
|
||||
<div class="w-full">{user.name}</div>
|
||||
<div>{$t('owner')}</div>
|
||||
</div>
|
||||
|
||||
{#each album.albumUsers as { user, role } (user.id)}
|
||||
<div class="flex items-center gap-2 py-2">
|
||||
<div>
|
||||
<UserAvatar {user} size="md" />
|
||||
</div>
|
||||
<div class="w-full">{user.name}</div>
|
||||
{#if role === AlbumUserRole.Viewer}
|
||||
{$t('role_viewer')}
|
||||
{:else}
|
||||
{$t('role_editor')}
|
||||
{/if}
|
||||
{#if user.id !== album.ownerId}
|
||||
<ButtonContextMenu icon={mdiDotsVertical} size="medium" title={$t('options')}>
|
||||
{#if role === AlbumUserRole.Viewer}
|
||||
<MenuOption
|
||||
onClick={() => handleUpdateSharedUserRole(user, AlbumUserRole.Editor)}
|
||||
text={$t('allow_edits')}
|
||||
/>
|
||||
{:else}
|
||||
<MenuOption
|
||||
onClick={() => handleUpdateSharedUserRole(user, AlbumUserRole.Viewer)}
|
||||
text={$t('disallow_edits')}
|
||||
/>
|
||||
{/if}
|
||||
<!-- Allow deletion for non-owners -->
|
||||
<MenuOption onClick={() => handleMenuRemove(user)} text={$t('remove')} />
|
||||
</ButtonContextMenu>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
{#if selectedRemoveUser}
|
||||
<ConfirmModal
|
||||
title={$t('album_remove_user')}
|
||||
prompt={$t('album_remove_user_confirmation', { values: { user: selectedRemoveUser.name } })}
|
||||
confirmText={$t('remove_user')}
|
||||
onClose={(confirmed) => (confirmed ? handleRemoveUser() : (selectedRemoveUser = null))}
|
||||
/>
|
||||
{/if}
|
||||
@@ -4,15 +4,16 @@
|
||||
import AlbumMap from '$lib/components/album-page/album-map.svelte';
|
||||
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
|
||||
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
|
||||
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { cancelMultiselect, downloadAlbum } from '$lib/utils/asset-utils';
|
||||
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import { mdiFileImagePlusOutline, mdiFolderDownloadOutline } from '@mdi/js';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
@@ -22,7 +23,6 @@
|
||||
import ImmichLogoSmallLink from '../shared-components/immich-logo-small-link.svelte';
|
||||
import ThemeButton from '../shared-components/theme-button.svelte';
|
||||
import AlbumSummary from './album-summary.svelte';
|
||||
import { IconButton } from '@immich/ui';
|
||||
|
||||
interface Props {
|
||||
sharedLink: SharedLinkResponseDto;
|
||||
@@ -35,9 +35,9 @@
|
||||
|
||||
let { isViewing: showAssetViewer } = assetViewingStore;
|
||||
|
||||
const assetStore = new AssetStore();
|
||||
$effect(() => void assetStore.updateOptions({ albumId: album.id, order: album.order }));
|
||||
onDestroy(() => assetStore.destroy());
|
||||
const timelineManager = new TimelineManager();
|
||||
$effect(() => void timelineManager.updateOptions({ albumId: album.id, order: album.order }));
|
||||
onDestroy(() => timelineManager.destroy());
|
||||
|
||||
const assetInteraction = new AssetInteraction();
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
/>
|
||||
|
||||
<main class="relative h-dvh overflow-hidden px-2 md:px-6 max-md:pt-(--navbar-height-md) pt-(--navbar-height)">
|
||||
<AssetGrid enableRouting={true} {album} {assetStore} {assetInteraction}>
|
||||
<AssetGrid enableRouting={true} {album} {timelineManager} {assetInteraction}>
|
||||
<section class="pt-8 md:pt-24 px-2 md:px-0">
|
||||
<!-- ALBUM TITLE -->
|
||||
<h1
|
||||
@@ -93,7 +93,7 @@
|
||||
assets={assetInteraction.selectedAssets}
|
||||
clearSelect={() => assetInteraction.clearMultiselect()}
|
||||
>
|
||||
<SelectAllAssets {assetStore} {assetInteraction} />
|
||||
<SelectAllAssets {timelineManager} {assetInteraction} />
|
||||
{#if sharedLink.allowDownload}
|
||||
<DownloadAction filename="{album.albumName}.zip" />
|
||||
{/if}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
|
||||
import AlbumsTable from '$lib/components/album-page/albums-table.svelte';
|
||||
import EditAlbumForm from '$lib/components/forms/edit-album-form.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import RightClickContextMenu from '$lib/components/shared-components/context-menu/right-click-context-menu.svelte';
|
||||
import {
|
||||
@@ -11,6 +10,7 @@
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||
import AlbumEditModal from '$lib/modals/AlbumEditModal.svelte';
|
||||
import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte';
|
||||
import QrCodeModal from '$lib/modals/QrCodeModal.svelte';
|
||||
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
||||
@@ -258,7 +258,7 @@
|
||||
|
||||
const handleEdit = async (album: AlbumResponseDto) => {
|
||||
closeAlbumContextMenu();
|
||||
const editedAlbum = await modalManager.show(EditAlbumForm, {
|
||||
const editedAlbum = await modalManager.show(AlbumEditModal, {
|
||||
album,
|
||||
});
|
||||
if (editedAlbum) {
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
{@const isCollapsed = isAlbumGroupCollapsed($albumViewSettings, albumGroup.id)}
|
||||
{@const iconRotation = isCollapsed ? 'rotate-0' : 'rotate-90'}
|
||||
<tbody
|
||||
class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg"
|
||||
class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg mt-4"
|
||||
>
|
||||
<tr
|
||||
class="flex w-full place-items-center p-2 md:ps-5 md:pe-5 md:pt-3 md:pb-3"
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import type { OnAction } from '$lib/components/asset-viewer/actions/action';
|
||||
import AlbumSelectionModal from '$lib/components/shared-components/album-selection/album-selection-modal.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import Portal from '$lib/components/shared-components/portal/portal.svelte';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { addAssetsToAlbum, addAssetsToNewAlbum } from '$lib/utils/asset-utils';
|
||||
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
|
||||
import { addAssetsToAlbum } from '$lib/utils/asset-utils';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -19,40 +19,22 @@
|
||||
|
||||
let { asset, onAction, shared = false }: Props = $props();
|
||||
|
||||
let showSelectionModal = $state(false);
|
||||
const onClick = async () => {
|
||||
const album = await modalManager.show(AlbumPickerModal, { shared });
|
||||
|
||||
const handleAddToNewAlbum = async (albumName: string) => {
|
||||
showSelectionModal = false;
|
||||
const album = await addAssetsToNewAlbum(albumName, [asset.id]);
|
||||
if (album) {
|
||||
onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album });
|
||||
if (!album) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToAlbum = async (album: AlbumResponseDto) => {
|
||||
showSelectionModal = false;
|
||||
await addAssetsToAlbum(album.id, [asset.id]);
|
||||
onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album });
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:document
|
||||
use:shortcut={{ shortcut: { key: 'l', shift: shared }, onShortcut: () => (showSelectionModal = true) }}
|
||||
/>
|
||||
<svelte:document use:shortcut={{ shortcut: { key: 'l', shift: shared }, onShortcut: onClick }} />
|
||||
|
||||
<MenuOption
|
||||
icon={shared ? mdiShareVariantOutline : mdiImageAlbum}
|
||||
text={shared ? $t('add_to_shared_album') : $t('add_to_album')}
|
||||
onClick={() => (showSelectionModal = true)}
|
||||
{onClick}
|
||||
/>
|
||||
|
||||
{#if showSelectionModal}
|
||||
<Portal target="body">
|
||||
<AlbumSelectionModal
|
||||
{shared}
|
||||
onNewAlbum={handleAddToNewAlbum}
|
||||
onAlbumClick={handleAddToAlbum}
|
||||
onClose={() => (showSelectionModal = false)}
|
||||
/>
|
||||
</Portal>
|
||||
{/if}
|
||||
|
||||
@@ -23,8 +23,9 @@
|
||||
|
||||
{#if !menuItem}
|
||||
<IconButton
|
||||
color="primary"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
icon={mdiFolderDownloadOutline}
|
||||
aria-label={$t('download')}
|
||||
onclick={onDownloadFile}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import Portal from '$lib/components/shared-components/portal/portal.svelte';
|
||||
import ProfileImageCropper from '$lib/components/shared-components/profile-image-cropper.svelte';
|
||||
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||
import ProfileImageCropperModal from '$lib/modals/ProfileImageCropperModal.svelte';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { mdiAccountCircleOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
@@ -11,18 +11,10 @@
|
||||
}
|
||||
|
||||
let { asset }: Props = $props();
|
||||
|
||||
let showProfileImageCrop = $state(false);
|
||||
</script>
|
||||
|
||||
<MenuOption
|
||||
icon={mdiAccountCircleOutline}
|
||||
onClick={() => (showProfileImageCrop = true)}
|
||||
onClick={() => modalManager.show(ProfileImageCropperModal, { asset })}
|
||||
text={$t('set_as_profile_picture')}
|
||||
/>
|
||||
|
||||
{#if showProfileImageCrop}
|
||||
<Portal target="body">
|
||||
<ProfileImageCropper {asset} onClose={() => (showProfileImageCrop = false)} />
|
||||
</Portal>
|
||||
{/if}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
prompt: isLocked ? $t('remove_from_locked_folder_confirmation') : $t('move_to_locked_folder_confirmation'),
|
||||
confirmText: $t('move'),
|
||||
confirmColor: isLocked ? 'danger' : 'primary',
|
||||
icon: isLocked ? mdiLockOpenVariantOutline : mdiLockOutline,
|
||||
});
|
||||
|
||||
if (!isConfirmed) {
|
||||
|
||||
@@ -247,7 +247,7 @@
|
||||
<div class="flex items-center justify-center p-2" bind:clientHeight={chatHeight}>
|
||||
<div class="flex p-2 gap-4 h-fit bg-gray-200 text-immich-dark-gray rounded-3xl w-full">
|
||||
<div>
|
||||
<UserAvatar {user} size="md" showTitle={false} />
|
||||
<UserAvatar {user} size="md" noTitle />
|
||||
</div>
|
||||
<form class="flex w-full max-h-56 gap-1" {onsubmit}>
|
||||
<div class="flex w-full items-center gap-4">
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
import { AssetAction, ProjectionType } from '$lib/constants';
|
||||
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { isShowDetail } from '$lib/stores/preferences.store';
|
||||
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
@@ -91,6 +91,8 @@
|
||||
slideshowState,
|
||||
slideshowTransition,
|
||||
} = slideshowStore;
|
||||
const stackThumbnailSize = 60;
|
||||
const stackSelectedThumbnailSize = 65;
|
||||
|
||||
let appearsInAlbums: AlbumResponseDto[] = $state([]);
|
||||
let shouldPlayMotionPhoto = $state(false);
|
||||
@@ -138,16 +140,6 @@
|
||||
}
|
||||
};
|
||||
|
||||
const updateComments = async () => {
|
||||
if (album) {
|
||||
try {
|
||||
await activityManager.refreshActivities(album.id, asset.id);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_get_comments_number'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onAssetUpdate = ({ asset: assetUpdate }: { event: 'upload' | 'update'; asset: AssetResponseDto }) => {
|
||||
if (assetUpdate.id === asset.id) {
|
||||
asset = assetUpdate;
|
||||
@@ -180,10 +172,6 @@
|
||||
if (!sharedLink) {
|
||||
await handleGetAllAlbums();
|
||||
}
|
||||
|
||||
if (album) {
|
||||
activityManager.init(album.id, asset.id);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@@ -314,8 +302,10 @@
|
||||
|
||||
const handleStopSlideshow = async () => {
|
||||
try {
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
if (document.fullscreenElement) {
|
||||
document.body.style.cursor = '';
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -370,8 +360,8 @@
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
if (isShared && asset.id) {
|
||||
handlePromiseError(updateComments());
|
||||
if (album && isShared && asset.id) {
|
||||
handlePromiseError(activityManager.init(album.id, asset.id));
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
@@ -510,7 +500,7 @@
|
||||
onVideoStarted={handleVideoStarted}
|
||||
/>
|
||||
{/if}
|
||||
{#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || activityManager.commentCount > 0)}
|
||||
{#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || activityManager.commentCount > 0) && !activityManager.isLoading}
|
||||
<div class="absolute bottom-0 end-0 mb-20 me-8">
|
||||
<ActivityStatus
|
||||
disabled={!album?.isActivityEnabled}
|
||||
@@ -556,11 +546,8 @@
|
||||
|
||||
{#if stack && withStacked}
|
||||
{@const stackedAssets = stack.assets}
|
||||
<div
|
||||
id="stack-slideshow"
|
||||
class="flex place-item-center place-content-center absolute bottom-0 w-full col-span-4 col-start-1 overflow-x-auto horizontal-scrollbar"
|
||||
>
|
||||
<div class="relative w-full">
|
||||
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1">
|
||||
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
|
||||
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
||||
<div
|
||||
class={['inline-block px-1 relative transition-all pb-2']}
|
||||
@@ -577,7 +564,7 @@
|
||||
}}
|
||||
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
||||
readonly
|
||||
thumbnailSize={stackedAsset.id === asset.id ? 65 : 60}
|
||||
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
|
||||
showStackedIcon={false}
|
||||
disableLinkMouseOver
|
||||
/>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
{#each tags as tag (tag.id)}
|
||||
<div class="flex group transition-all">
|
||||
<a
|
||||
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-immich-primary dark:bg-immich-dark-primary rounded-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
|
||||
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary rounded-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
|
||||
href={encodeURI(`${AppRoute.TAGS}/?path=${tag.value}`)}
|
||||
>
|
||||
<p class="text-sm">
|
||||
|
||||
@@ -53,12 +53,10 @@
|
||||
|
||||
img.src = getAssetOriginalUrl({ id: asset.id, cacheKey: asset.thumbhash });
|
||||
|
||||
img.addEventListener('load', () => onImageLoad(true));
|
||||
img.addEventListener('error', (error) => {
|
||||
handleError(error, $t('error_loading_image'));
|
||||
});
|
||||
img.addEventListener('load', () => onImageLoad(true), { passive: true });
|
||||
img.addEventListener('error', (error) => handleError(error, $t('error_loading_image')), { passive: true });
|
||||
|
||||
globalThis.addEventListener('mousemove', handleMouseMove);
|
||||
globalThis.addEventListener('mousemove', handleMouseMove, { passive: true });
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
|
||||
@@ -31,8 +31,8 @@ export function onImageLoad(resetSize: boolean = false) {
|
||||
cropFrameEl?.classList.add('transition');
|
||||
cropSettings.update((crop) => normalizeCropArea(crop, img, scale));
|
||||
cropFrameEl?.classList.add('transition');
|
||||
cropFrameEl?.addEventListener('transitionend', () => {
|
||||
cropFrameEl?.classList.remove('transition');
|
||||
cropFrameEl?.addEventListener('transitionend', () => cropFrameEl?.classList.remove('transition'), {
|
||||
passive: true,
|
||||
});
|
||||
}
|
||||
cropImageScale.set(scale);
|
||||
|
||||
@@ -58,7 +58,7 @@ export function handleMouseDown(e: MouseEvent) {
|
||||
}
|
||||
|
||||
document.body.style.userSelect = 'none';
|
||||
globalThis.addEventListener('mouseup', handleMouseUp);
|
||||
globalThis.addEventListener('mouseup', handleMouseUp, { passive: true });
|
||||
}
|
||||
|
||||
export function handleMouseMove(e: MouseEvent) {
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
canvas = new Canvas(canvasEl);
|
||||
configureControlStyle();
|
||||
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
faceRect = new Rect({
|
||||
fill: 'rgba(66,80,175,0.25)',
|
||||
stroke: 'rgb(66,80,175)',
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
type AdapterConstructor,
|
||||
type PluginConstructor,
|
||||
} from '@photo-sphere-viewer/core';
|
||||
import { SettingsPlugin } from '@photo-sphere-viewer/settings-plugin';
|
||||
import { ResolutionPlugin } from '@photo-sphere-viewer/resolution-plugin';
|
||||
import '@photo-sphere-viewer/core/index.css';
|
||||
import { ResolutionPlugin } from '@photo-sphere-viewer/resolution-plugin';
|
||||
import { SettingsPlugin } from '@photo-sphere-viewer/settings-plugin';
|
||||
import '@photo-sphere-viewer/settings-plugin/index.css';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
|
||||
@@ -68,18 +68,20 @@
|
||||
fisheye: false,
|
||||
});
|
||||
const resolutionPlugin = viewer.getPlugin(ResolutionPlugin) as ResolutionPlugin;
|
||||
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
|
||||
// zoomLevel range: [0, 100]
|
||||
if (Math.round(zoomLevel) >= 75) {
|
||||
// Replace the preview with the original
|
||||
void resolutionPlugin.setResolution('original');
|
||||
viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler);
|
||||
}
|
||||
};
|
||||
|
||||
if (originalPanorama && !$alwaysLoadOriginalFile) {
|
||||
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
|
||||
// zoomLevel range: [0, 100]
|
||||
if (Math.round(zoomLevel) >= 75) {
|
||||
// Replace the preview with the original
|
||||
void resolutionPlugin.setResolution('original');
|
||||
viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler);
|
||||
}
|
||||
};
|
||||
viewer.addEventListener(events.ZoomUpdatedEvent.type, zoomHandler);
|
||||
viewer.addEventListener(events.ZoomUpdatedEvent.type, zoomHandler, { passive: true });
|
||||
}
|
||||
|
||||
return () => viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
import { zoomImageAction } from '$lib/actions/zoom-image';
|
||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||
import { assetViewerFadeDuration } from '$lib/constants';
|
||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
|
||||
@@ -192,8 +193,8 @@
|
||||
if (loader?.complete) {
|
||||
onload();
|
||||
}
|
||||
loader?.addEventListener('load', onload);
|
||||
loader?.addEventListener('error', onerror);
|
||||
loader?.addEventListener('load', onload, { passive: true });
|
||||
loader?.addEventListener('error', onerror, { passive: true });
|
||||
return () => {
|
||||
loader?.removeEventListener('load', onload);
|
||||
loader?.removeEventListener('error', onerror);
|
||||
@@ -240,7 +241,7 @@
|
||||
use:swipe={() => ({})}
|
||||
onswipe={onSwipe}
|
||||
class="h-full w-full"
|
||||
transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}
|
||||
transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }}
|
||||
>
|
||||
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
|
||||
<img
|
||||
|
||||
@@ -101,11 +101,37 @@
|
||||
};
|
||||
|
||||
const onShowSettings = async () => {
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
if (document.fullscreenElement) {
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
await modalManager.show(SlideshowSettingsModal, {});
|
||||
await modalManager.show(SlideshowSettingsModal);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
function exitFullscreenHandler() {
|
||||
const doc = document as Document & {
|
||||
webkitIsFullScreen?: boolean;
|
||||
};
|
||||
|
||||
if (
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
!document.fullscreenElement &&
|
||||
!doc.webkitIsFullScreen
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('fullscreenchange', exitFullscreenHandler);
|
||||
document.addEventListener('webkitfullscreenchange', exitFullscreenHandler);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', exitFullscreenHandler);
|
||||
document.removeEventListener('webkitfullscreenchange', exitFullscreenHandler);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:document
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||
import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte';
|
||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||
import { assetViewerFadeDuration } from '$lib/constants';
|
||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { loopVideo as loopVideoPreference, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
|
||||
@@ -41,8 +42,11 @@
|
||||
let assetFileUrl = $state('');
|
||||
let forceMuted = $state(false);
|
||||
let isScrubbing = $state(false);
|
||||
let showVideo = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
// Show video after mount to ensure fading in.
|
||||
showVideo = true;
|
||||
assetFileUrl = getAssetPlaybackUrl({ id: assetId, cacheKey });
|
||||
if (videoPlayer) {
|
||||
forceMuted = false;
|
||||
@@ -102,59 +106,61 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
transition:fade={{ duration: 150 }}
|
||||
class="flex h-full select-none place-content-center place-items-center"
|
||||
bind:clientWidth={containerWidth}
|
||||
bind:clientHeight={containerHeight}
|
||||
>
|
||||
{#if castManager.isCasting}
|
||||
<div class="place-content-center h-full place-items-center">
|
||||
<VideoRemoteViewer
|
||||
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
|
||||
{onVideoStarted}
|
||||
{onVideoEnded}
|
||||
{assetFileUrl}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<video
|
||||
bind:this={videoPlayer}
|
||||
loop={$loopVideoPreference && loopVideo}
|
||||
autoplay
|
||||
playsinline
|
||||
controls
|
||||
class="h-full object-contain"
|
||||
use:swipe={() => ({})}
|
||||
onswipe={onSwipe}
|
||||
oncanplay={(e) => handleCanPlay(e.currentTarget)}
|
||||
onended={onVideoEnded}
|
||||
onvolumechange={(e) => {
|
||||
if (!forceMuted) {
|
||||
$videoViewerMuted = e.currentTarget.muted;
|
||||
}
|
||||
}}
|
||||
onseeking={() => (isScrubbing = true)}
|
||||
onseeked={() => (isScrubbing = false)}
|
||||
onplaying={(e) => {
|
||||
e.currentTarget.focus();
|
||||
}}
|
||||
onclose={() => onClose()}
|
||||
muted={forceMuted || $videoViewerMuted}
|
||||
bind:volume={$videoViewerVolume}
|
||||
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
|
||||
src={assetFileUrl}
|
||||
>
|
||||
</video>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="absolute flex place-content-center place-items-center">
|
||||
<LoadingSpinner />
|
||||
{#if showVideo}
|
||||
<div
|
||||
transition:fade={{ duration: assetViewerFadeDuration }}
|
||||
class="flex h-full select-none place-content-center place-items-center"
|
||||
bind:clientWidth={containerWidth}
|
||||
bind:clientHeight={containerHeight}
|
||||
>
|
||||
{#if castManager.isCasting}
|
||||
<div class="place-content-center h-full place-items-center">
|
||||
<VideoRemoteViewer
|
||||
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
|
||||
{onVideoStarted}
|
||||
{onVideoEnded}
|
||||
{assetFileUrl}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<video
|
||||
bind:this={videoPlayer}
|
||||
loop={$loopVideoPreference && loopVideo}
|
||||
autoplay
|
||||
playsinline
|
||||
controls
|
||||
class="h-full object-contain"
|
||||
use:swipe={() => ({})}
|
||||
onswipe={onSwipe}
|
||||
oncanplay={(e) => handleCanPlay(e.currentTarget)}
|
||||
onended={onVideoEnded}
|
||||
onvolumechange={(e) => {
|
||||
if (!forceMuted) {
|
||||
$videoViewerMuted = e.currentTarget.muted;
|
||||
}
|
||||
}}
|
||||
onseeking={() => (isScrubbing = true)}
|
||||
onseeked={() => (isScrubbing = false)}
|
||||
onplaying={(e) => {
|
||||
e.currentTarget.focus();
|
||||
}}
|
||||
onclose={() => onClose()}
|
||||
muted={forceMuted || $videoViewerMuted}
|
||||
bind:volume={$videoViewerVolume}
|
||||
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
|
||||
src={assetFileUrl}
|
||||
>
|
||||
</video>
|
||||
|
||||
{#if isFaceEditMode.value}
|
||||
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
|
||||
{#if isLoading}
|
||||
<div class="absolute flex place-content-center place-items-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isFaceEditMode.value}
|
||||
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -123,17 +123,21 @@
|
||||
mouseOver = false;
|
||||
};
|
||||
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const preventContextMenu = (evt: Event) => evt.preventDefault();
|
||||
let disposeables: (() => void)[] = [];
|
||||
const disposeables: (() => void)[] = [];
|
||||
|
||||
const clearLongPressTimer = () => {
|
||||
if (!timer) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
for (const dispose of disposeables) {
|
||||
dispose();
|
||||
}
|
||||
disposeables = [];
|
||||
disposeables.length = 0;
|
||||
};
|
||||
|
||||
let startX: number = 0;
|
||||
@@ -162,7 +166,7 @@
|
||||
};
|
||||
element.addEventListener('click', click);
|
||||
element.addEventListener('pointerdown', start, true);
|
||||
element.addEventListener('pointerup', clearLongPressTimer, true);
|
||||
element.addEventListener('pointerup', clearLongPressTimer, { capture: true, passive: true });
|
||||
return {
|
||||
destroy: () => {
|
||||
element.removeEventListener('click', click);
|
||||
@@ -172,17 +176,15 @@
|
||||
};
|
||||
}
|
||||
function moveHandler(e: PointerEvent) {
|
||||
var diffX = Math.abs(startX - e.clientX);
|
||||
var diffY = Math.abs(startY - e.clientY);
|
||||
if (diffX >= 10 || diffY >= 10) {
|
||||
if (Math.abs(startX - e.clientX) >= 10 || Math.abs(startY - e.clientY) >= 10) {
|
||||
clearLongPressTimer();
|
||||
}
|
||||
}
|
||||
onMount(() => {
|
||||
document.addEventListener('scroll', clearLongPressTimer, true);
|
||||
document.addEventListener('wheel', clearLongPressTimer, true);
|
||||
document.addEventListener('contextmenu', clearLongPressTimer, true);
|
||||
document.addEventListener('pointermove', moveHandler, true);
|
||||
document.addEventListener('scroll', clearLongPressTimer, { capture: true, passive: true });
|
||||
document.addEventListener('wheel', clearLongPressTimer, { capture: true, passive: true });
|
||||
document.addEventListener('contextmenu', clearLongPressTimer, { capture: true, passive: true });
|
||||
document.addEventListener('pointermove', moveHandler, { capture: true, passive: true });
|
||||
return () => {
|
||||
document.removeEventListener('scroll', clearLongPressTimer, true);
|
||||
document.removeEventListener('wheel', clearLongPressTimer, true);
|
||||
@@ -373,7 +375,7 @@
|
||||
<Icon path={mdiCheckCircle} size="24" class="text-zinc-800" />
|
||||
{:else if selected}
|
||||
<div class="rounded-full bg-[#D9DCEF] dark:bg-[#232932]">
|
||||
<Icon path={mdiCheckCircle} size="24" class="text-immich-primary" />
|
||||
<Icon path={mdiCheckCircle} size="24" class="text-primary" />
|
||||
</div>
|
||||
{:else}
|
||||
<Icon path={mdiCheckCircle} size="24" class="text-white/80 hover:text-white" />
|
||||
|
||||
@@ -1,29 +1,16 @@
|
||||
<script lang="ts" module>
|
||||
export type Color = 'primary' | 'secondary';
|
||||
export type Rounded = false | true | 'full';
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
color?: Color;
|
||||
rounded?: Rounded;
|
||||
rounded?: boolean | 'full';
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { color = 'primary', rounded = true, children }: Props = $props();
|
||||
|
||||
const colorClasses: Record<Color, string> = {
|
||||
primary: 'text-gray-100 dark:text-immich-dark-gray bg-immich-primary dark:bg-immich-dark-primary',
|
||||
secondary: 'text-immich-dark-bg dark:text-immich-gray dark:bg-gray-600 bg-gray-300 dark:text-immich-gray',
|
||||
};
|
||||
let { rounded = true, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="inline-block h-min whitespace-nowrap px-3 py-1 text-center align-baseline text-xs leading-none {colorClasses[
|
||||
color
|
||||
]}"
|
||||
class="bg-primary text-subtle inline-block h-min whitespace-nowrap px-3 py-1 text-center align-baseline text-xs leading-none"
|
||||
class:rounded-md={rounded === true}
|
||||
class:rounded-full={rounded === 'full'}
|
||||
>
|
||||
|
||||
@@ -169,19 +169,9 @@
|
||||
>
|
||||
<td class="w-1/8 text-ellipsis ps-8 text-sm">
|
||||
{#if validatedPath.isValid}
|
||||
<Icon
|
||||
path={mdiCheckCircleOutline}
|
||||
size="24"
|
||||
title={validatedPath.message}
|
||||
class="text-immich-success dark:text-immich-dark-success"
|
||||
/>
|
||||
<Icon path={mdiCheckCircleOutline} size="24" title={validatedPath.message} class="text-success" />
|
||||
{:else}
|
||||
<Icon
|
||||
path={mdiAlertOutline}
|
||||
size="24"
|
||||
title={validatedPath.message}
|
||||
class="text-immich-warning dark:text-immich-dark-warning"
|
||||
/>
|
||||
<Icon path={mdiAlertOutline} size="24" title={validatedPath.message} class="text-warning" />
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
children,
|
||||
}: Props = $props();
|
||||
|
||||
let scrollbarClass = $derived(scrollbar ? 'immich-scrollbar p-2' : 'scrollbar-hidden');
|
||||
let scrollbarClass = $derived(scrollbar ? 'immich-scrollbar' : 'scrollbar-hidden');
|
||||
let hasTitleClass = $derived(title ? 'top-16 h-[calc(100%-(--spacing(16)))]' : 'top-0 h-full');
|
||||
</script>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
{/if}
|
||||
|
||||
<main class="relative">
|
||||
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto" use:useActions={use}>
|
||||
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2" use:useActions={use}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||
import { assetViewerFadeDuration } from '$lib/constants';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { AssetMediaSize } from '@immich/sdk';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
asset: TimelineAsset;
|
||||
}
|
||||
|
||||
const { asset }: Props = $props();
|
||||
|
||||
let assetFileUrl: string = $state('');
|
||||
let imageLoaded: boolean = $state(false);
|
||||
let loader = $state<HTMLImageElement>();
|
||||
|
||||
const onload = () => {
|
||||
imageLoaded = true;
|
||||
assetFileUrl = imageLoaderUrl;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
if (loader?.complete) {
|
||||
onload();
|
||||
}
|
||||
loader?.addEventListener('load', onload);
|
||||
return () => {
|
||||
loader?.removeEventListener('load', onload);
|
||||
};
|
||||
});
|
||||
|
||||
const imageLoaderUrl = $derived(getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Preview }));
|
||||
</script>
|
||||
|
||||
{#if !imageLoaded}
|
||||
<!-- svelte-ignore a11y_missing_attribute -->
|
||||
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
|
||||
{/if}
|
||||
|
||||
{#if !imageLoaded}
|
||||
<div id="spinner" class="flex h-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{:else if imageLoaded}
|
||||
<div transition:fade={{ duration: assetViewerFadeDuration }} class="h-full w-full">
|
||||
<img
|
||||
class="h-full w-full rounded-2xl object-contain transition-all"
|
||||
src={assetFileUrl}
|
||||
alt={$getAltText(asset)}
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes delayedVisibility {
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
#spinner {
|
||||
visibility: hidden;
|
||||
animation: 0s linear 0.4s forwards delayedVisibility;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { assetViewerFadeDuration } from '$lib/constants';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { AssetMediaSize } from '@immich/sdk';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
asset: TimelineAsset;
|
||||
videoPlayer: HTMLVideoElement | undefined;
|
||||
videoViewerMuted?: boolean;
|
||||
videoViewerVolume?: number;
|
||||
}
|
||||
|
||||
let { asset, videoPlayer = $bindable(), videoViewerVolume, videoViewerMuted }: Props = $props();
|
||||
|
||||
let showVideo: boolean = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
// Show video after mount to ensure fading in.
|
||||
showVideo = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if showVideo}
|
||||
<div class="h-full w-full bg-pink-9000" transition:fade={{ duration: assetViewerFadeDuration }}>
|
||||
<video
|
||||
bind:this={videoPlayer}
|
||||
autoplay
|
||||
playsinline
|
||||
class="h-full w-full rounded-2xl object-contain transition-all"
|
||||
src={getAssetPlaybackUrl({ id: asset.id })}
|
||||
poster={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Preview })}
|
||||
draggable="false"
|
||||
muted={videoViewerMuted}
|
||||
volume={videoViewerVolume}
|
||||
></video>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -4,6 +4,8 @@
|
||||
import { intersectionObserver } from '$lib/actions/intersection-observer';
|
||||
import { resizeObserver } from '$lib/actions/resize-observer';
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import MemoryPhotoViewer from '$lib/components/memory-page/memory-photo-viewer.svelte';
|
||||
import MemoryVideoViewer from '$lib/components/memory-page/memory-video-viewer.svelte';
|
||||
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
|
||||
import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
|
||||
import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
|
||||
@@ -25,16 +27,14 @@
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import { AppRoute, QueryParameter } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import type { Viewport } from '$lib/managers/timeline-manager/types';
|
||||
import { type MemoryAsset, memoryStore } from '$lib/stores/memory.store.svelte';
|
||||
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { getAssetPlaybackUrl, getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
|
||||
import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
|
||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { AssetMediaSize, getAssetInfo } from '@immich/sdk';
|
||||
import { IconButton } from '@immich/ui';
|
||||
@@ -60,7 +60,6 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { Tween } from 'svelte/motion';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let memoryGallery: HTMLElement | undefined = $state();
|
||||
let memoryWrapper: HTMLElement | undefined = $state();
|
||||
@@ -261,12 +260,7 @@
|
||||
playerInitialized = true;
|
||||
};
|
||||
|
||||
afterNavigate(({ from, to, type }) => {
|
||||
if (type === 'enter') {
|
||||
// afterNavigate triggers twice on first page load (once when mounted with 'enter' and then a second time
|
||||
// with the actual 'goto' to URL).
|
||||
return;
|
||||
}
|
||||
afterNavigate(({ from, to }) => {
|
||||
memoryStore.initialize().then(
|
||||
() => {
|
||||
let target = null;
|
||||
@@ -369,15 +363,16 @@
|
||||
{/snippet}
|
||||
|
||||
<div class="flex place-content-center place-items-center gap-2 overflow-hidden">
|
||||
<IconButton
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
aria-label={paused ? $t('play_memories') : $t('pause_memories')}
|
||||
icon={paused ? mdiPlay : mdiPause}
|
||||
onclick={() => handlePromiseError(handleAction('PlayPauseButtonClick', paused ? 'play' : 'pause'))}
|
||||
class="hover:text-black"
|
||||
/>
|
||||
<div class="w-[50px] dark">
|
||||
<IconButton
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
aria-label={paused ? $t('play_memories') : $t('pause_memories')}
|
||||
icon={paused ? mdiPlay : mdiPause}
|
||||
onclick={() => handlePromiseError(handleAction('PlayPauseButtonClick', paused ? 'play' : 'pause'))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#each current.memory.assets as asset, index (asset.id)}
|
||||
<a class="relative w-full py-2" href={asHref(asset)} aria-label={$t('view')}>
|
||||
@@ -391,20 +386,23 @@
|
||||
{(current.assetIndex + 1).toLocaleString($locale)}/{current.memory.assets.length.toLocaleString($locale)}
|
||||
</p>
|
||||
</div>
|
||||
<IconButton
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
aria-label={$videoViewerMuted ? $t('unmute_memories') : $t('mute_memories')}
|
||||
icon={$videoViewerMuted ? mdiVolumeOff : mdiVolumeHigh}
|
||||
onclick={() => ($videoViewerMuted = !$videoViewerMuted)}
|
||||
/>
|
||||
|
||||
<div class="w-[50px] dark">
|
||||
<IconButton
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
aria-label={$videoViewerMuted ? $t('unmute_memories') : $t('mute_memories')}
|
||||
icon={$videoViewerMuted ? mdiVolumeOff : mdiVolumeHigh}
|
||||
onclick={() => ($videoViewerMuted = !$videoViewerMuted)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ControlAppBar>
|
||||
|
||||
{#if galleryInView}
|
||||
<div
|
||||
class="fixed top-20 start-1/2 -translate-x-1/2 transition-opacity"
|
||||
class="fixed top-10 start-1/2 -translate-x-1/2 transition-opacity dark z-1"
|
||||
class:opacity-0={!galleryInView}
|
||||
class:opacity-100={galleryInView}
|
||||
>
|
||||
@@ -415,7 +413,6 @@
|
||||
>
|
||||
<IconButton
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
aria-label={$t('hide_gallery')}
|
||||
icon={mdiChevronUp}
|
||||
@@ -469,30 +466,16 @@
|
||||
>
|
||||
<div class="relative h-full w-full rounded-2xl bg-black">
|
||||
{#key current.asset.id}
|
||||
<div transition:fade class="h-full w-full">
|
||||
{#if current.asset.isVideo}
|
||||
<video
|
||||
bind:this={videoPlayer}
|
||||
autoplay
|
||||
playsinline
|
||||
class="h-full w-full rounded-2xl object-contain transition-all"
|
||||
src={getAssetPlaybackUrl({ id: current.asset.id })}
|
||||
poster={getAssetThumbnailUrl({ id: current.asset.id, size: AssetMediaSize.Preview })}
|
||||
draggable="false"
|
||||
muted={$videoViewerMuted}
|
||||
volume={$videoViewerVolume}
|
||||
transition:fade
|
||||
></video>
|
||||
{:else}
|
||||
<img
|
||||
class="h-full w-full rounded-2xl object-contain transition-all"
|
||||
src={getAssetThumbnailUrl({ id: current.asset.id, size: AssetMediaSize.Preview })}
|
||||
alt={$getAltText(current.asset)}
|
||||
draggable="false"
|
||||
transition:fade
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{#if current.asset.isVideo}
|
||||
<MemoryVideoViewer
|
||||
asset={current.asset}
|
||||
bind:videoPlayer
|
||||
videoViewerMuted={$videoViewerMuted}
|
||||
videoViewerVolume={$videoViewerVolume}
|
||||
/>
|
||||
{:else}
|
||||
<MemoryPhotoViewer asset={current.asset} />
|
||||
{/if}
|
||||
{/key}
|
||||
|
||||
<div
|
||||
@@ -549,26 +532,28 @@
|
||||
</div>
|
||||
<!-- CONTROL BUTTONS -->
|
||||
{#if current.previous}
|
||||
<div class="absolute top-1/2 start-0 ms-4">
|
||||
<div class="absolute top-1/2 start-0 ms-4 dark">
|
||||
<IconButton
|
||||
shape="round"
|
||||
aria-label={$t('previous_memory')}
|
||||
icon={mdiChevronLeft}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="giant"
|
||||
onclick={handlePreviousAsset}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if current.next}
|
||||
<div class="absolute top-1/2 end-0 me-4">
|
||||
<div class="absolute top-1/2 end-0 me-4 dark">
|
||||
<IconButton
|
||||
shape="round"
|
||||
aria-label={$t('next_memory')}
|
||||
icon={mdiChevronRight}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="giant"
|
||||
onclick={handleNextAsset}
|
||||
/>
|
||||
</div>
|
||||
@@ -632,13 +617,12 @@
|
||||
<!-- GALLERY VIEWER -->
|
||||
<section class="bg-immich-dark-gray p-4">
|
||||
<div
|
||||
class="sticky mb-10 flex place-content-center place-items-center transition-all"
|
||||
class="sticky mb-10 flex place-content-center place-items-center transition-all dark"
|
||||
class:opacity-0={galleryInView}
|
||||
class:opacity-100={!galleryInView}
|
||||
>
|
||||
<IconButton
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
aria-label={$t('show_gallery')}
|
||||
icon={mdiChevronDown}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getConfig, type SystemConfigDto } from '@immich/sdk';
|
||||
import { onMount } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
|
||||
let config: SystemConfigDto | undefined = $state();
|
||||
let adminSettingsComponent = $state<ReturnType<typeof AdminSettings>>();
|
||||
@@ -14,14 +14,12 @@
|
||||
config = await getConfig();
|
||||
});
|
||||
|
||||
export const save = async () => {
|
||||
await adminSettingsComponent?.handleSave({ storageTemplate: config?.storageTemplate });
|
||||
};
|
||||
onDestroy(() => adminSettingsComponent?.handleSave({ storageTemplate: config?.storageTemplate }));
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<p>
|
||||
<FormatMessage key="admin.storage_template_onboarding_description">
|
||||
<FormatMessage key="admin.storage_template_onboarding_description_v2">
|
||||
{#snippet children({ message })}
|
||||
<a class="underline" href="https://immich.app/docs/administration/storage-template">{message}</a>
|
||||
{/snippet}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import AlbumSelectionModal from '$lib/components/shared-components/album-selection/album-selection-modal.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { addAssetsToAlbum, addAssetsToNewAlbum } from '$lib/utils/asset-utils';
|
||||
import type { AlbumResponseDto } from '@immich/sdk';
|
||||
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
||||
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
|
||||
import type { OnAddToAlbum } from '$lib/utils/actions';
|
||||
import { addAssetsToAlbum } from '$lib/utils/asset-utils';
|
||||
import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { OnAddToAlbum } from '$lib/utils/actions';
|
||||
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
||||
|
||||
interface Props {
|
||||
shared?: boolean;
|
||||
@@ -15,46 +15,24 @@
|
||||
|
||||
let { shared = false, onAddToAlbum = () => {} }: Props = $props();
|
||||
|
||||
let showAlbumPicker = $state(false);
|
||||
|
||||
const { getAssets } = getAssetControlContext();
|
||||
|
||||
const handleHideAlbumPicker = () => {
|
||||
showAlbumPicker = false;
|
||||
};
|
||||
const onClick = async () => {
|
||||
const album = await modalManager.show(AlbumPickerModal, { shared });
|
||||
|
||||
const handleAddToNewAlbum = async (albumName: string) => {
|
||||
showAlbumPicker = false;
|
||||
|
||||
const assetIds = [...getAssets()].map((asset) => asset.id);
|
||||
const album = await addAssetsToNewAlbum(albumName, assetIds);
|
||||
if (!album) {
|
||||
return;
|
||||
}
|
||||
|
||||
onAddToAlbum(assetIds, album.id);
|
||||
};
|
||||
|
||||
const handleAddToAlbum = async (album: AlbumResponseDto) => {
|
||||
showAlbumPicker = false;
|
||||
const assetIds = [...getAssets()].map((asset) => asset.id);
|
||||
const assetIds = [...getAssets()].map(({ id }) => id);
|
||||
await addAssetsToAlbum(album.id, assetIds);
|
||||
onAddToAlbum(assetIds, album.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<MenuOption
|
||||
onClick={() => (showAlbumPicker = true)}
|
||||
{onClick}
|
||||
text={shared ? $t('add_to_shared_album') : $t('add_to_album')}
|
||||
icon={shared ? mdiShareVariantOutline : mdiImageAlbum}
|
||||
shortcut={{ key: 'l', shift: shared }}
|
||||
/>
|
||||
|
||||
{#if showAlbumPicker}
|
||||
<AlbumSelectionModal
|
||||
{shared}
|
||||
onNewAlbum={handleAddToNewAlbum}
|
||||
onAlbumClick={handleAddToAlbum}
|
||||
onClose={handleHideAlbumPicker}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
import type { OnArchive } from '$lib/utils/actions';
|
||||
import { archiveAssets } from '$lib/utils/asset-utils';
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline, mdiTimerSand } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
||||
import { IconButton } from '@immich/ui';
|
||||
|
||||
interface Props {
|
||||
onArchive?: OnArchive;
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
||||
|
||||
const handleUpdateDescription = async () => {
|
||||
const description = await modalManager.show(AssetUpdateDecriptionConfirmModal, {});
|
||||
const description = await modalManager.show(AssetUpdateDecriptionConfirmModal);
|
||||
if (description) {
|
||||
const ids = getSelectedAssets(getOwnedAssets(), $user);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { moveFocus } from '$lib/utils/focus-util';
|
||||
import { InvocationTracker } from '$lib/utils/invocationTracker';
|
||||
@@ -31,7 +31,7 @@ export const setFocusToAsset = (scrollToAsset: (asset: TimelineAsset) => boolean
|
||||
|
||||
export const setFocusTo = async (
|
||||
scrollToAsset: (asset: TimelineAsset) => boolean,
|
||||
store: AssetStore,
|
||||
store: TimelineManager,
|
||||
direction: 'earlier' | 'later',
|
||||
interval: 'day' | 'month' | 'year' | 'asset',
|
||||
) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
|
||||
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||
import { cancelMultiselect, selectAllAssets } from '$lib/utils/asset-utils';
|
||||
import { Button, IconButton } from '@immich/ui';
|
||||
@@ -8,15 +8,15 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
assetStore: AssetStore;
|
||||
timelineManager: TimelineManager;
|
||||
assetInteraction: AssetInteraction;
|
||||
withText?: boolean;
|
||||
}
|
||||
|
||||
let { assetStore, assetInteraction, withText = false }: Props = $props();
|
||||
let { timelineManager, assetInteraction, withText = false }: Props = $props();
|
||||
|
||||
const handleSelectAll = async () => {
|
||||
await selectAllAssets(assetStore, assetInteraction);
|
||||
await selectAllAssets(timelineManager, assetInteraction);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
prompt: unlock ? $t('remove_from_locked_folder_confirmation') : $t('move_to_locked_folder_confirmation'),
|
||||
confirmText: $t('move'),
|
||||
confirmColor: unlock ? 'danger' : 'primary',
|
||||
icon: unlock ? mdiLockOpenVariantOutline : mdiLockOutline,
|
||||
});
|
||||
|
||||
if (!isConfirmed) {
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<script lang="ts">
|
||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||
import type { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
|
||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { assetSnapshot, assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
|
||||
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
||||
import { fly, scale } from 'svelte/transition';
|
||||
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
||||
|
||||
import type { AssetBucket } from '$lib/managers/timeline-manager/asset-bucket.svelte';
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { fly, scale } from 'svelte/transition';
|
||||
|
||||
let { isUploading } = uploadAssetsStore;
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
singleSelect: boolean;
|
||||
withStacked: boolean;
|
||||
showArchiveIcon: boolean;
|
||||
bucket: AssetBucket;
|
||||
assetStore: AssetStore;
|
||||
monthGroup: MonthGroup;
|
||||
timelineManager: TimelineManager;
|
||||
assetInteraction: AssetInteraction;
|
||||
|
||||
onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
|
||||
@@ -37,9 +37,9 @@
|
||||
singleSelect,
|
||||
withStacked,
|
||||
showArchiveIcon,
|
||||
bucket = $bindable(),
|
||||
monthGroup = $bindable(),
|
||||
assetInteraction,
|
||||
assetStore,
|
||||
timelineManager,
|
||||
onSelect,
|
||||
onSelectAssets,
|
||||
onSelectAssetCandidates,
|
||||
@@ -47,13 +47,20 @@
|
||||
}: Props = $props();
|
||||
|
||||
let isMouseOverGroup = $state(false);
|
||||
let hoveredDateGroup = $state();
|
||||
let hoveredDayGroup = $state();
|
||||
|
||||
const transitionDuration = $derived.by(() => (bucket.store.suspendTransitions && !$isUploading ? 0 : 150));
|
||||
const transitionDuration = $derived.by(() =>
|
||||
monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150,
|
||||
);
|
||||
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
||||
const onClick = (assetStore: AssetStore, assets: TimelineAsset[], groupTitle: string, asset: TimelineAsset) => {
|
||||
const onClick = (
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
groupTitle: string,
|
||||
asset: TimelineAsset,
|
||||
) => {
|
||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||
assetSelectHandler(assetStore, asset, assets, groupTitle);
|
||||
assetSelectHandler(timelineManager, asset, assets, groupTitle);
|
||||
return;
|
||||
}
|
||||
void navigate({ targetRoute: 'current', assetId: asset.id });
|
||||
@@ -62,26 +69,26 @@
|
||||
const handleSelectGroup = (title: string, assets: TimelineAsset[]) => onSelect({ title, assets });
|
||||
|
||||
const assetSelectHandler = (
|
||||
assetStore: AssetStore,
|
||||
timelineManager: TimelineManager,
|
||||
asset: TimelineAsset,
|
||||
assetsInDateGroup: TimelineAsset[],
|
||||
assetsInDayGroup: TimelineAsset[],
|
||||
groupTitle: string,
|
||||
) => {
|
||||
onSelectAssets(asset);
|
||||
|
||||
// Check if all assets are selected in a group to toggle the group selection's icon
|
||||
let selectedAssetsInGroupCount = assetsInDateGroup.filter((asset) =>
|
||||
let selectedAssetsInGroupCount = assetsInDayGroup.filter((asset) =>
|
||||
assetInteraction.hasSelectedAsset(asset.id),
|
||||
).length;
|
||||
|
||||
// if all assets are selected in a group, add the group to selected group
|
||||
if (selectedAssetsInGroupCount == assetsInDateGroup.length) {
|
||||
if (selectedAssetsInGroupCount == assetsInDayGroup.length) {
|
||||
assetInteraction.addGroupToMultiselectGroup(groupTitle);
|
||||
} else {
|
||||
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
|
||||
}
|
||||
|
||||
if (assetStore.count == assetInteraction.selectedAssets.length) {
|
||||
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
|
||||
isSelectingAllAssets.set(true);
|
||||
} else {
|
||||
isSelectingAllAssets.set(false);
|
||||
@@ -90,7 +97,7 @@
|
||||
|
||||
const assetMouseEventHandler = (groupTitle: string, asset: TimelineAsset | null) => {
|
||||
// Show multi select icon on hover on date group
|
||||
hoveredDateGroup = groupTitle;
|
||||
hoveredDayGroup = groupTitle;
|
||||
|
||||
if (assetInteraction.selectionActive) {
|
||||
onSelectAssetCandidates(asset);
|
||||
@@ -102,56 +109,56 @@
|
||||
}
|
||||
|
||||
$effect.root(() => {
|
||||
if (assetStore.scrollCompensation.bucket === bucket) {
|
||||
onScrollCompensation(assetStore.scrollCompensation);
|
||||
assetStore.clearScrollCompensation();
|
||||
if (timelineManager.scrollCompensation.monthGroup === monthGroup) {
|
||||
onScrollCompensation(timelineManager.scrollCompensation);
|
||||
timelineManager.clearScrollCompensation();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#each filterIntersecting(bucket.dateGroups) as dateGroup, groupIndex (dateGroup.day)}
|
||||
{@const absoluteWidth = dateGroup.left}
|
||||
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
|
||||
{@const absoluteWidth = dayGroup.left}
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<section
|
||||
class={[
|
||||
{ 'transition-all': !bucket.store.suspendTransitions },
|
||||
!bucket.store.suspendTransitions && `delay-${transitionDuration}`,
|
||||
{ 'transition-all': !monthGroup.timelineManager.suspendTransitions },
|
||||
!monthGroup.timelineManager.suspendTransitions && `delay-${transitionDuration}`,
|
||||
]}
|
||||
data-group
|
||||
style:position="absolute"
|
||||
style:transform={`translate3d(${absoluteWidth}px,${dateGroup.top}px,0)`}
|
||||
style:transform={`translate3d(${absoluteWidth}px,${dayGroup.top}px,0)`}
|
||||
onmouseenter={() => {
|
||||
isMouseOverGroup = true;
|
||||
assetMouseEventHandler(dateGroup.groupTitle, null);
|
||||
assetMouseEventHandler(dayGroup.groupTitle, null);
|
||||
}}
|
||||
onmouseleave={() => {
|
||||
isMouseOverGroup = false;
|
||||
assetMouseEventHandler(dateGroup.groupTitle, null);
|
||||
assetMouseEventHandler(dayGroup.groupTitle, null);
|
||||
}}
|
||||
>
|
||||
<!-- Date group title -->
|
||||
<div
|
||||
class="flex pt-7 pb-5 max-md:pt-5 max-md:pb-3 h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm"
|
||||
style:width={dateGroup.width + 'px'}
|
||||
style:width={dayGroup.width + 'px'}
|
||||
>
|
||||
{#if !singleSelect && ((hoveredDateGroup === dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dateGroup.groupTitle))}
|
||||
{#if !singleSelect && ((hoveredDayGroup === dayGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dayGroup.groupTitle))}
|
||||
<div
|
||||
transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
|
||||
class="inline-block px-2 hover:cursor-pointer"
|
||||
onclick={() => handleSelectGroup(dateGroup.groupTitle, assetsSnapshot(dateGroup.getAssets()))}
|
||||
onkeydown={() => handleSelectGroup(dateGroup.groupTitle, assetsSnapshot(dateGroup.getAssets()))}
|
||||
class="inline-block pe-2 hover:cursor-pointer"
|
||||
onclick={() => handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))}
|
||||
onkeydown={() => handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))}
|
||||
>
|
||||
{#if assetInteraction.selectedGroup.has(dateGroup.groupTitle)}
|
||||
<Icon path={mdiCheckCircle} size="24" color="#4250af" />
|
||||
{#if assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
|
||||
<Icon path={mdiCheckCircle} size="24" class="text-primary" />
|
||||
{:else}
|
||||
<Icon path={mdiCircleOutline} size="24" color="#757575" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<span class="w-full truncate first-letter:capitalize ms-2.5" title={dateGroup.groupTitle}>
|
||||
{dateGroup.groupTitle}
|
||||
<span class="w-full truncate first-letter:capitalize" title={dayGroup.groupTitle}>
|
||||
{dayGroup.groupTitle}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -159,14 +166,14 @@
|
||||
<div
|
||||
data-image-grid
|
||||
class="relative overflow-clip"
|
||||
style:height={dateGroup.height + 'px'}
|
||||
style:width={dateGroup.width + 'px'}
|
||||
style:height={dayGroup.height + 'px'}
|
||||
style:width={dayGroup.width + 'px'}
|
||||
>
|
||||
{#each filterIntersecting(dateGroup.intersectingAssets) as intersectingAsset (intersectingAsset.id)}
|
||||
{@const position = intersectingAsset.position!}
|
||||
{@const asset = intersectingAsset.asset!}
|
||||
{#each filterIntersecting(dayGroup.viewerAssets) as viewerAsset (viewerAsset.id)}
|
||||
{@const position = viewerAsset.position!}
|
||||
{@const asset = viewerAsset.asset!}
|
||||
|
||||
<!-- {#if intersectingAsset.intersecting} -->
|
||||
<!-- {#if viewerAsset.intersecting} -->
|
||||
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
|
||||
<div
|
||||
data-asset-id={asset.id}
|
||||
@@ -183,12 +190,13 @@
|
||||
{showArchiveIcon}
|
||||
{asset}
|
||||
{groupIndex}
|
||||
onClick={(asset) => onClick(assetStore, dateGroup.getAssets(), dateGroup.groupTitle, asset)}
|
||||
onSelect={(asset) => assetSelectHandler(assetStore, asset, dateGroup.getAssets(), dateGroup.groupTitle)}
|
||||
onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, assetSnapshot(asset))}
|
||||
selected={assetInteraction.hasSelectedAsset(asset.id) || dateGroup.bucket.store.albumAssets.has(asset.id)}
|
||||
onClick={(asset) => onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset)}
|
||||
onSelect={(asset) => assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle)}
|
||||
onMouseEvent={() => assetMouseEventHandler(dayGroup.groupTitle, assetSnapshot(asset))}
|
||||
selected={assetInteraction.hasSelectedAsset(asset.id) ||
|
||||
dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)}
|
||||
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
|
||||
disabled={dateGroup.bucket.store.albumAssets.has(asset.id)}
|
||||
disabled={dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)}
|
||||
thumbnailWidth={position.width}
|
||||
thumbnailHeight={position.height}
|
||||
/>
|
||||
|
||||
@@ -12,26 +12,30 @@
|
||||
import ChangeDate from '$lib/components/shared-components/change-date.svelte';
|
||||
import Scrubber from '$lib/components/shared-components/scrubber/scrubber.svelte';
|
||||
import { AppRoute, AssetAction } from '$lib/constants';
|
||||
import { albumMapViewManager } from '$lib/managers/album-view-map.manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||
import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||
import { showDeleteModal } from '$lib/stores/preferences.store';
|
||||
import { searchStore } from '$lib/stores/search.svelte';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import type { AssetBucket } from '$lib/managers/timeline-manager/asset-bucket.svelte';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
|
||||
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { toTimelineAsset, type ScrubberListener, type TimelinePlainYearMonth } from '$lib/utils/timeline-util';
|
||||
import {
|
||||
getTimes,
|
||||
toTimelineAsset,
|
||||
type ScrubberListener,
|
||||
type TimelinePlainYearMonth,
|
||||
} from '$lib/utils/timeline-util';
|
||||
import { AssetVisibility, getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
@@ -47,7 +51,7 @@
|
||||
`AssetViewingStore.gridScrollTarget` and load and scroll to the asset specified, and
|
||||
additionally, update the page location/url with the asset as the asset-grid is scrolled */
|
||||
enableRouting: boolean;
|
||||
assetStore: AssetStore;
|
||||
timelineManager: TimelineManager;
|
||||
assetInteraction: AssetInteraction;
|
||||
removeAction?:
|
||||
| AssetAction.UNARCHIVE
|
||||
@@ -72,7 +76,7 @@
|
||||
isSelectionMode = false,
|
||||
singleSelect = false,
|
||||
enableRouting,
|
||||
assetStore = $bindable(),
|
||||
timelineManager = $bindable(),
|
||||
assetInteraction,
|
||||
removeAction = null,
|
||||
withStacked = false,
|
||||
@@ -87,15 +91,15 @@
|
||||
empty,
|
||||
}: Props = $props();
|
||||
|
||||
let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget } = assetViewingStore;
|
||||
let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget, mutex } = assetViewingStore;
|
||||
|
||||
let element: HTMLElement | undefined = $state();
|
||||
|
||||
let timelineElement: HTMLElement | undefined = $state();
|
||||
let showSkeleton = $state(true);
|
||||
let isShowSelectDate = $state(false);
|
||||
let scrubBucketPercent = $state(0);
|
||||
let scrubBucket: TimelinePlainYearMonth | undefined = $state();
|
||||
let scrubberMonthPercent = $state(0);
|
||||
let scrubberMonth: { year: number; month: number } | undefined = $state(undefined);
|
||||
let scrubOverallPercent: number = $state(0);
|
||||
let scrubberWidth = $state(0);
|
||||
|
||||
@@ -116,7 +120,7 @@
|
||||
rowHeight: 235,
|
||||
headerHeight: 48,
|
||||
};
|
||||
assetStore.setLayoutOptions(layoutOptions);
|
||||
timelineManager.setLayoutOptions(layoutOptions);
|
||||
});
|
||||
|
||||
const scrollTo = (top: number) => {
|
||||
@@ -138,35 +142,35 @@
|
||||
scrollTo(0);
|
||||
};
|
||||
|
||||
const getAssetHeight = (assetId: string, bucket: AssetBucket) => {
|
||||
const getAssetHeight = (assetId: string, monthGroup: MonthGroup) => {
|
||||
// the following method may trigger any layouts, so need to
|
||||
// handle any scroll compensation that may have been set
|
||||
const height = bucket!.findAssetAbsolutePosition(assetId);
|
||||
const height = monthGroup!.findAssetAbsolutePosition(assetId);
|
||||
|
||||
while (assetStore.scrollCompensation.bucket) {
|
||||
handleScrollCompensation(assetStore.scrollCompensation);
|
||||
assetStore.clearScrollCompensation();
|
||||
while (timelineManager.scrollCompensation.monthGroup) {
|
||||
handleScrollCompensation(timelineManager.scrollCompensation);
|
||||
timelineManager.clearScrollCompensation();
|
||||
}
|
||||
return height;
|
||||
};
|
||||
|
||||
const scrollToAssetId = async (assetId: string) => {
|
||||
const bucket = await assetStore.findBucketForAsset(assetId);
|
||||
if (!bucket) {
|
||||
const monthGroup = await timelineManager.findMonthGroupForAsset(assetId);
|
||||
if (!monthGroup) {
|
||||
return false;
|
||||
}
|
||||
const height = getAssetHeight(assetId, bucket);
|
||||
const height = getAssetHeight(assetId, monthGroup);
|
||||
scrollTo(height);
|
||||
updateSlidingWindow();
|
||||
return true;
|
||||
};
|
||||
|
||||
const scrollToAsset = (asset: TimelineAsset) => {
|
||||
const bucket = assetStore.getBucketIndexByAssetId(asset.id);
|
||||
if (!bucket) {
|
||||
const monthGroup = timelineManager.getMonthGroupByAssetId(asset.id);
|
||||
if (!monthGroup) {
|
||||
return false;
|
||||
}
|
||||
const height = getAssetHeight(asset.id, bucket);
|
||||
const height = getAssetHeight(asset.id, monthGroup);
|
||||
scrollTo(height);
|
||||
updateSlidingWindow();
|
||||
return true;
|
||||
@@ -185,7 +189,7 @@
|
||||
showSkeleton = false;
|
||||
};
|
||||
|
||||
beforeNavigate(() => (assetStore.suspendTransitions = true));
|
||||
beforeNavigate(() => (timelineManager.suspendTransitions = true));
|
||||
|
||||
afterNavigate((nav) => {
|
||||
const { complete } = nav;
|
||||
@@ -224,7 +228,7 @@
|
||||
import.meta.hot?.on('vite:beforeUpdate', (payload) => {
|
||||
const assetGridUpdate = payload.updates.some((update) => update.path.endsWith('asset-grid.svelte'));
|
||||
if (assetGridUpdate) {
|
||||
assetStore.destroy();
|
||||
timelineManager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -233,9 +237,9 @@
|
||||
return () => void 0;
|
||||
};
|
||||
|
||||
const updateIsScrolling = () => (assetStore.scrolling = true);
|
||||
const updateIsScrolling = () => (timelineManager.scrolling = true);
|
||||
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
|
||||
const updateSlidingWindow = () => assetStore.updateSlidingWindow(element?.scrollTop || 0);
|
||||
const updateSlidingWindow = () => timelineManager.updateSlidingWindow(element?.scrollTop || 0);
|
||||
|
||||
const handleScrollCompensation = ({ heightDelta, scrollTop }: { heightDelta?: number; scrollTop?: number }) => {
|
||||
if (heightDelta !== undefined) {
|
||||
@@ -245,12 +249,12 @@
|
||||
}
|
||||
// Yes, updateSlideWindow() is called by the onScroll event triggered as a result of
|
||||
// the above calls. However, this delay is enough time to set the intersecting property
|
||||
// of the bucket to false, then true, which causes the DOM nodes to be recreated,
|
||||
// of the monthGroup to false, then true, which causes the DOM nodes to be recreated,
|
||||
// causing bad perf, and also, disrupting focus of those elements.
|
||||
updateSlidingWindow();
|
||||
};
|
||||
|
||||
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (assetStore.topSectionHeight = height);
|
||||
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
|
||||
|
||||
onMount(() => {
|
||||
if (!enableRouting) {
|
||||
@@ -263,21 +267,23 @@
|
||||
});
|
||||
|
||||
const getMaxScrollPercent = () => {
|
||||
const totalHeight = assetStore.timelineHeight + bottomSectionHeight + assetStore.topSectionHeight;
|
||||
return (totalHeight - assetStore.viewportHeight) / totalHeight;
|
||||
const totalHeight = timelineManager.timelineHeight + bottomSectionHeight + timelineManager.topSectionHeight;
|
||||
return (totalHeight - timelineManager.viewportHeight) / totalHeight;
|
||||
};
|
||||
|
||||
const getMaxScroll = () => {
|
||||
if (!element || !timelineElement) {
|
||||
return 0;
|
||||
}
|
||||
return assetStore.topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight);
|
||||
return (
|
||||
timelineManager.topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight)
|
||||
);
|
||||
};
|
||||
|
||||
const scrollToBucketAndOffset = (bucket: AssetBucket, bucketScrollPercent: number) => {
|
||||
const topOffset = bucket.top;
|
||||
const scrollToMonthGroupAndOffset = (monthGroup: MonthGroup, monthGroupScrollPercent: number) => {
|
||||
const topOffset = monthGroup.top;
|
||||
const maxScrollPercent = getMaxScrollPercent();
|
||||
const delta = bucket.bucketHeight * bucketScrollPercent;
|
||||
const delta = monthGroup.height * monthGroupScrollPercent;
|
||||
const scrollToTop = (topOffset + delta) * maxScrollPercent;
|
||||
|
||||
scrollTop(scrollToTop);
|
||||
@@ -285,23 +291,23 @@
|
||||
|
||||
// note: don't throttle, debounch, or otherwise make this function async - it causes flicker
|
||||
const onScrub: ScrubberListener = (
|
||||
bucketDate: { year: number; month: number } | undefined,
|
||||
scrollPercent: number,
|
||||
bucketScrollPercent: number,
|
||||
scrubMonth: { year: number; month: number },
|
||||
overallScrollPercent: number,
|
||||
scrubberMonthScrollPercent: number,
|
||||
) => {
|
||||
if (!bucketDate || assetStore.timelineHeight < assetStore.viewportHeight * 2) {
|
||||
if (!scrubMonth || timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
|
||||
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
|
||||
const maxScroll = getMaxScroll();
|
||||
const offset = maxScroll * scrollPercent;
|
||||
const offset = maxScroll * overallScrollPercent;
|
||||
scrollTop(offset);
|
||||
} else {
|
||||
const bucket = assetStore.buckets.find(
|
||||
(bucket) => bucket.yearMonth.year === bucketDate.year && bucket.yearMonth.month === bucketDate.month,
|
||||
const monthGroup = timelineManager.months.find(
|
||||
({ yearMonth: { year, month } }) => year === scrubMonth.year && month === scrubMonth.month,
|
||||
);
|
||||
if (!bucket) {
|
||||
if (!monthGroup) {
|
||||
return;
|
||||
}
|
||||
scrollToBucketAndOffset(bucket, bucketScrollPercent);
|
||||
scrollToMonthGroupAndOffset(monthGroup, scrubberMonthScrollPercent);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -313,19 +319,19 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (assetStore.timelineHeight < assetStore.viewportHeight * 2) {
|
||||
if (timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
|
||||
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead
|
||||
const maxScroll = getMaxScroll();
|
||||
scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll);
|
||||
|
||||
scrubBucket = undefined;
|
||||
scrubBucketPercent = 0;
|
||||
scrubberMonth = undefined;
|
||||
scrubberMonthPercent = 0;
|
||||
} else {
|
||||
let top = element.scrollTop;
|
||||
if (top < assetStore.topSectionHeight) {
|
||||
if (top < timelineManager.topSectionHeight) {
|
||||
// in the lead-in area
|
||||
scrubBucket = undefined;
|
||||
scrubBucketPercent = 0;
|
||||
scrubberMonth = undefined;
|
||||
scrubberMonthPercent = 0;
|
||||
const maxScroll = getMaxScroll();
|
||||
|
||||
scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll);
|
||||
@@ -335,33 +341,33 @@
|
||||
let maxScrollPercent = getMaxScrollPercent();
|
||||
let found = false;
|
||||
|
||||
const bucketsLength = assetStore.buckets.length;
|
||||
for (let i = -1; i < bucketsLength + 1; i++) {
|
||||
let bucket: TimelinePlainYearMonth | undefined;
|
||||
let bucketHeight = 0;
|
||||
const monthsLength = timelineManager.months.length;
|
||||
for (let i = -1; i < monthsLength + 1; i++) {
|
||||
let monthGroup: TimelinePlainYearMonth | undefined;
|
||||
let monthGroupHeight = 0;
|
||||
if (i === -1) {
|
||||
// lead-in
|
||||
bucketHeight = assetStore.topSectionHeight;
|
||||
} else if (i === bucketsLength) {
|
||||
monthGroupHeight = timelineManager.topSectionHeight;
|
||||
} else if (i === monthsLength) {
|
||||
// lead-out
|
||||
bucketHeight = bottomSectionHeight;
|
||||
monthGroupHeight = bottomSectionHeight;
|
||||
} else {
|
||||
bucket = assetStore.buckets[i].yearMonth;
|
||||
bucketHeight = assetStore.buckets[i].bucketHeight;
|
||||
monthGroup = timelineManager.months[i].yearMonth;
|
||||
monthGroupHeight = timelineManager.months[i].height;
|
||||
}
|
||||
|
||||
let next = top - bucketHeight * maxScrollPercent;
|
||||
let next = top - monthGroupHeight * maxScrollPercent;
|
||||
// instead of checking for < 0, add a little wiggle room for subpixel resolution
|
||||
if (next < -1 && bucket) {
|
||||
scrubBucket = bucket;
|
||||
if (next < -1 && monthGroup) {
|
||||
scrubberMonth = monthGroup;
|
||||
|
||||
// allowing next to be at least 1 may cause percent to go negative, so ensure positive percentage
|
||||
scrubBucketPercent = Math.max(0, top / (bucketHeight * maxScrollPercent));
|
||||
scrubberMonthPercent = Math.max(0, top / (monthGroupHeight * maxScrollPercent));
|
||||
|
||||
// compensate for lost precision/rounding errors advance to the next bucket, if present
|
||||
if (scrubBucketPercent > 0.9999 && i + 1 < bucketsLength - 1) {
|
||||
scrubBucket = assetStore.buckets[i + 1].yearMonth;
|
||||
scrubBucketPercent = 0;
|
||||
if (scrubberMonthPercent > 0.9999 && i + 1 < monthsLength - 1) {
|
||||
scrubberMonth = timelineManager.months[i + 1].yearMonth;
|
||||
scrubberMonthPercent = 0;
|
||||
}
|
||||
|
||||
found = true;
|
||||
@@ -371,8 +377,8 @@
|
||||
}
|
||||
if (!found) {
|
||||
leadout = true;
|
||||
scrubBucket = undefined;
|
||||
scrubBucketPercent = 0;
|
||||
scrubberMonth = undefined;
|
||||
scrubberMonthPercent = 0;
|
||||
scrubOverallPercent = 1;
|
||||
}
|
||||
}
|
||||
@@ -382,9 +388,9 @@
|
||||
isShowDeleteConfirmation = false;
|
||||
await deleteAssets(
|
||||
!(isTrashEnabled && !force),
|
||||
(assetIds) => assetStore.removeAssets(assetIds),
|
||||
(assetIds) => timelineManager.removeAssets(assetIds),
|
||||
assetInteraction.selectedAssets,
|
||||
!isTrashEnabled || force ? undefined : (assets) => assetStore.addAssets(assets),
|
||||
!isTrashEnabled || force ? undefined : (assets) => timelineManager.addAssets(assets),
|
||||
);
|
||||
assetInteraction.clearMultiselect();
|
||||
};
|
||||
@@ -410,53 +416,59 @@
|
||||
const onStackAssets = async () => {
|
||||
const result = await stackAssets(assetInteraction.selectedAssets);
|
||||
|
||||
updateStackedAssetInTimeline(assetStore, result);
|
||||
updateStackedAssetInTimeline(timelineManager, result);
|
||||
|
||||
onEscape();
|
||||
};
|
||||
|
||||
const toggleArchive = async () => {
|
||||
await archiveAssets(
|
||||
assetInteraction.selectedAssets,
|
||||
assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive,
|
||||
);
|
||||
assetStore.updateAssets(assetInteraction.selectedAssets);
|
||||
const visibility = assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive;
|
||||
const ids = await archiveAssets(assetInteraction.selectedAssets, visibility);
|
||||
timelineManager.updateAssetOperation(ids, (asset) => {
|
||||
asset.visibility = visibility;
|
||||
return { remove: false };
|
||||
});
|
||||
deselectAllAssets();
|
||||
};
|
||||
|
||||
const handleSelectAsset = (asset: TimelineAsset) => {
|
||||
if (!assetStore.albumAssets.has(asset.id)) {
|
||||
if (!timelineManager.albumAssets.has(asset.id)) {
|
||||
assetInteraction.selectAsset(asset);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = async () => {
|
||||
const laterAsset = await assetStore.getLaterAsset($viewingAsset);
|
||||
const release = await mutex.acquire();
|
||||
const laterAsset = await timelineManager.getLaterAsset($viewingAsset);
|
||||
|
||||
if (laterAsset) {
|
||||
const preloadAsset = await assetStore.getLaterAsset(laterAsset);
|
||||
const preloadAsset = await timelineManager.getLaterAsset(laterAsset);
|
||||
const asset = await getAssetInfo({ id: laterAsset.id, key: authManager.key });
|
||||
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
|
||||
await navigate({ targetRoute: 'current', assetId: laterAsset.id });
|
||||
}
|
||||
|
||||
release();
|
||||
return !!laterAsset;
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
const earlierAsset = await assetStore.getEarlierAsset($viewingAsset);
|
||||
const release = await mutex.acquire();
|
||||
const earlierAsset = await timelineManager.getEarlierAsset($viewingAsset);
|
||||
|
||||
if (earlierAsset) {
|
||||
const preloadAsset = await assetStore.getEarlierAsset(earlierAsset);
|
||||
const preloadAsset = await timelineManager.getEarlierAsset(earlierAsset);
|
||||
const asset = await getAssetInfo({ id: earlierAsset.id, key: authManager.key });
|
||||
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
|
||||
await navigate({ targetRoute: 'current', assetId: earlierAsset.id });
|
||||
}
|
||||
|
||||
release();
|
||||
return !!earlierAsset;
|
||||
};
|
||||
|
||||
const handleRandom = async () => {
|
||||
const randomAsset = await assetStore.getRandomAsset();
|
||||
const randomAsset = await timelineManager.getRandomAsset();
|
||||
|
||||
if (randomAsset) {
|
||||
const asset = await getAssetInfo({ id: randomAsset.id, key: authManager.key });
|
||||
@@ -487,7 +499,7 @@
|
||||
(await handleNext()) || (await handlePrevious()) || (await handleClose(action.asset));
|
||||
|
||||
// delete after find the next one
|
||||
assetStore.removeAssets([action.asset.id]);
|
||||
timelineManager.removeAssets([action.asset.id]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -498,26 +510,26 @@
|
||||
case AssetAction.UNARCHIVE:
|
||||
case AssetAction.FAVORITE:
|
||||
case AssetAction.UNFAVORITE: {
|
||||
assetStore.updateAssets([action.asset]);
|
||||
timelineManager.updateAssets([action.asset]);
|
||||
break;
|
||||
}
|
||||
|
||||
case AssetAction.ADD: {
|
||||
assetStore.addAssets([action.asset]);
|
||||
timelineManager.addAssets([action.asset]);
|
||||
break;
|
||||
}
|
||||
|
||||
case AssetAction.UNSTACK: {
|
||||
updateUnstackedAssetInTimeline(assetStore, action.assets);
|
||||
updateUnstackedAssetInTimeline(timelineManager, action.assets);
|
||||
break;
|
||||
}
|
||||
case AssetAction.SET_STACK_PRIMARY_ASSET: {
|
||||
//Have to unstack then restack assets in timeline in order for the currently removed new primary asset to be made visible.
|
||||
updateUnstackedAssetInTimeline(
|
||||
assetStore,
|
||||
timelineManager,
|
||||
action.stack.assets.map((asset) => toTimelineAsset(asset)),
|
||||
);
|
||||
updateStackedAssetInTimeline(assetStore, {
|
||||
updateStackedAssetInTimeline(timelineManager, {
|
||||
stack: action.stack,
|
||||
toDeleteIds: action.stack.assets
|
||||
.filter((asset) => asset.id !== action.stack.primaryAssetId)
|
||||
@@ -565,7 +577,7 @@
|
||||
lastAssetMouseEvent = asset;
|
||||
};
|
||||
|
||||
const handleGroupSelect = (assetStore: AssetStore, group: string, assets: TimelineAsset[]) => {
|
||||
const handleGroupSelect = (timelineManager: TimelineManager, group: string, assets: TimelineAsset[]) => {
|
||||
if (assetInteraction.selectedGroup.has(group)) {
|
||||
assetInteraction.removeGroupFromMultiselectGroup(group);
|
||||
for (const asset of assets) {
|
||||
@@ -578,7 +590,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
if (assetStore.count == assetInteraction.selectedAssets.length) {
|
||||
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
|
||||
isSelectingAllAssets.set(true);
|
||||
} else {
|
||||
isSelectingAllAssets.set(false);
|
||||
@@ -615,8 +627,8 @@
|
||||
assetInteraction.clearAssetSelectionCandidates();
|
||||
|
||||
if (assetInteraction.assetSelectionStart && rangeSelection) {
|
||||
let startBucket = assetStore.getBucketIndexByAssetId(assetInteraction.assetSelectionStart.id);
|
||||
let endBucket = assetStore.getBucketIndexByAssetId(asset.id);
|
||||
let startBucket = timelineManager.getMonthGroupByAssetId(assetInteraction.assetSelectionStart.id);
|
||||
let endBucket = timelineManager.getMonthGroupByAssetId(asset.id);
|
||||
|
||||
if (startBucket === null || endBucket === null) {
|
||||
return;
|
||||
@@ -624,13 +636,13 @@
|
||||
|
||||
// Select/deselect assets in range (start,end)
|
||||
let started = false;
|
||||
for (const bucket of assetStore.buckets) {
|
||||
if (bucket === endBucket) {
|
||||
for (const monthGroup of timelineManager.months) {
|
||||
if (monthGroup === endBucket) {
|
||||
break;
|
||||
}
|
||||
if (started) {
|
||||
await assetStore.loadBucket(bucket.yearMonth);
|
||||
for (const asset of bucket.assetsIterator()) {
|
||||
await timelineManager.loadMonthGroup(monthGroup.yearMonth);
|
||||
for (const asset of monthGroup.assetsIterator()) {
|
||||
if (deselect) {
|
||||
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
|
||||
} else {
|
||||
@@ -638,29 +650,29 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bucket === startBucket) {
|
||||
if (monthGroup === startBucket) {
|
||||
started = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Update date group selection in range [start,end]
|
||||
started = false;
|
||||
for (const bucket of assetStore.buckets) {
|
||||
if (bucket === startBucket) {
|
||||
for (const monthGroup of timelineManager.months) {
|
||||
if (monthGroup === startBucket) {
|
||||
started = true;
|
||||
}
|
||||
if (started) {
|
||||
// Split bucket into date groups and check each group
|
||||
for (const dateGroup of bucket.dateGroups) {
|
||||
const dateGroupTitle = dateGroup.groupTitle;
|
||||
if (dateGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
|
||||
assetInteraction.addGroupToMultiselectGroup(dateGroupTitle);
|
||||
// Split month group into day groups and check each group
|
||||
for (const dayGroup of monthGroup.dayGroups) {
|
||||
const dayGroupTitle = dayGroup.groupTitle;
|
||||
if (dayGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
|
||||
assetInteraction.addGroupToMultiselectGroup(dayGroupTitle);
|
||||
} else {
|
||||
assetInteraction.removeGroupFromMultiselectGroup(dateGroupTitle);
|
||||
assetInteraction.removeGroupFromMultiselectGroup(dayGroupTitle);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bucket === endBucket) {
|
||||
if (monthGroup === endBucket) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -679,7 +691,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const assets = assetsSnapshot(await assetStore.retrieveRange(startAsset, endAsset));
|
||||
const assets = assetsSnapshot(await timelineManager.retrieveRange(startAsset, endAsset));
|
||||
assetInteraction.setAssetSelectionCandidates(assets);
|
||||
};
|
||||
|
||||
@@ -690,7 +702,7 @@
|
||||
};
|
||||
|
||||
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
|
||||
let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0);
|
||||
let isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
||||
let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
|
||||
let isShortcutModalOpen = false;
|
||||
|
||||
@@ -700,7 +712,7 @@
|
||||
}
|
||||
|
||||
isShortcutModalOpen = true;
|
||||
await modalManager.show(ShortcutsModal, {});
|
||||
await modalManager.show(ShortcutsModal);
|
||||
isShortcutModalOpen = false;
|
||||
};
|
||||
|
||||
@@ -710,7 +722,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, assetStore);
|
||||
const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, timelineManager);
|
||||
const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset);
|
||||
|
||||
let shortcutList = $derived(
|
||||
@@ -723,7 +735,7 @@
|
||||
{ shortcut: { key: 'Escape' }, onShortcut: onEscape },
|
||||
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
|
||||
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
|
||||
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteraction) },
|
||||
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(timelineManager, assetInteraction) },
|
||||
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => setFocusTo('earlier', 'asset') },
|
||||
{ shortcut: { key: 'ArrowLeft' }, onShortcut: () => setFocusTo('later', 'asset') },
|
||||
{ shortcut: { key: 'D' }, onShortcut: () => setFocusTo('earlier', 'day') },
|
||||
@@ -766,6 +778,13 @@
|
||||
void selectAssetCandidates(lastAssetMouseEvent);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if ($showAssetViewer) {
|
||||
const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60);
|
||||
void timelineManager.loadMonthGroup({ year: localDateTime.year, month: localDateTime.month });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} onselectstart={onSelectStart} use:shortcuts={shortcutList} />
|
||||
@@ -785,7 +804,9 @@
|
||||
timezoneInput={false}
|
||||
onConfirm={async (dateString: string) => {
|
||||
isShowSelectDate = false;
|
||||
const asset = await assetStore.getClosestAssetToDate((DateTime.fromISO(dateString) as DateTime<true>).toObject());
|
||||
const asset = await timelineManager.getClosestAssetToDate(
|
||||
(DateTime.fromISO(dateString) as DateTime<true>).toObject(),
|
||||
);
|
||||
if (asset) {
|
||||
setFocusAsset(asset);
|
||||
}
|
||||
@@ -794,16 +815,16 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if assetStore.buckets.length > 0}
|
||||
{#if timelineManager.months.length > 0}
|
||||
<Scrubber
|
||||
{assetStore}
|
||||
height={assetStore.viewportHeight}
|
||||
timelineTopOffset={assetStore.topSectionHeight}
|
||||
{timelineManager}
|
||||
height={timelineManager.viewportHeight}
|
||||
timelineTopOffset={timelineManager.topSectionHeight}
|
||||
timelineBottomOffset={bottomSectionHeight}
|
||||
{leadout}
|
||||
{scrubOverallPercent}
|
||||
{scrubBucketPercent}
|
||||
{scrubBucket}
|
||||
{scrubberMonthPercent}
|
||||
{scrubberMonth}
|
||||
{onScrub}
|
||||
bind:scrubberWidth
|
||||
onScrubKeyDown={(evt) => {
|
||||
@@ -824,14 +845,14 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Right margin MUST be equal to the width of immich-scrubbable-scrollbar -->
|
||||
<!-- Right margin MUST be equal to the width of scrubber -->
|
||||
<section
|
||||
id="asset-grid"
|
||||
class={['scrollbar-hidden h-full overflow-y-auto outline-none', { 'm-0': isEmpty }, { 'ms-0': !isEmpty }]}
|
||||
style:margin-right={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
|
||||
tabindex="-1"
|
||||
bind:clientHeight={assetStore.viewportHeight}
|
||||
bind:clientWidth={null, (v) => ((assetStore.viewportWidth = v), updateSlidingWindow())}
|
||||
bind:clientHeight={timelineManager.viewportHeight}
|
||||
bind:clientWidth={null, (v) => ((timelineManager.viewportWidth = v), updateSlidingWindow())}
|
||||
bind:this={element}
|
||||
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
|
||||
>
|
||||
@@ -839,7 +860,7 @@
|
||||
bind:this={timelineElement}
|
||||
id="virtual-timeline"
|
||||
class:invisible={showSkeleton}
|
||||
style:height={assetStore.timelineHeight + 'px'}
|
||||
style:height={timelineManager.timelineHeight + 'px'}
|
||||
>
|
||||
<section
|
||||
use:resizeObserver={topSectionResizeObserver}
|
||||
@@ -855,23 +876,26 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#each assetStore.buckets as bucket (bucket.viewId)}
|
||||
{@const display = bucket.intersecting}
|
||||
{@const absoluteHeight = bucket.top}
|
||||
{#each timelineManager.months as monthGroup (monthGroup.viewId)}
|
||||
{@const display = monthGroup.intersecting}
|
||||
{@const absoluteHeight = monthGroup.top}
|
||||
|
||||
{#if !bucket.isLoaded}
|
||||
{#if !monthGroup.isLoaded}
|
||||
<div
|
||||
style:height={bucket.bucketHeight + 'px'}
|
||||
style:height={monthGroup.height + 'px'}
|
||||
style:position="absolute"
|
||||
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
||||
style:width="100%"
|
||||
>
|
||||
<Skeleton height={bucket.bucketHeight - bucket.store.headerHeight} title={bucket.bucketDateFormatted} />
|
||||
<Skeleton
|
||||
height={monthGroup.height - monthGroup.timelineManager.headerHeight}
|
||||
title={monthGroup.monthGroupTitle}
|
||||
/>
|
||||
</div>
|
||||
{:else if display}
|
||||
<div
|
||||
class="bucket"
|
||||
style:height={bucket.bucketHeight + 'px'}
|
||||
class="month-group"
|
||||
style:height={monthGroup.height + 'px'}
|
||||
style:position="absolute"
|
||||
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
||||
style:width="100%"
|
||||
@@ -880,11 +904,11 @@
|
||||
{withStacked}
|
||||
{showArchiveIcon}
|
||||
{assetInteraction}
|
||||
{assetStore}
|
||||
{timelineManager}
|
||||
{isSelectionMode}
|
||||
{singleSelect}
|
||||
{bucket}
|
||||
onSelect={({ title, assets }) => handleGroupSelect(assetStore, title, assets)}
|
||||
{monthGroup}
|
||||
onSelect={({ title, assets }) => handleGroupSelect(timelineManager, title, assets)}
|
||||
onSelectAssetCandidates={handleSelectAssetCandidates}
|
||||
onSelectAssets={handleSelectAssets}
|
||||
onScrollCompensation={handleScrollCompensation}
|
||||
@@ -898,33 +922,31 @@
|
||||
style:position="absolute"
|
||||
style:left="0"
|
||||
style:right="0"
|
||||
style:transform={`translate3d(0,${assetStore.timelineHeight}px,0)`}
|
||||
style:transform={`translate3d(0,${timelineManager.timelineHeight}px,0)`}
|
||||
></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{#if !albumMapViewManager.isInMapView}
|
||||
<Portal target="body">
|
||||
{#if $showAssetViewer}
|
||||
{#await import('../asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
||||
<AssetViewer
|
||||
{withStacked}
|
||||
asset={$viewingAsset}
|
||||
preloadAssets={$preloadAssets}
|
||||
{isShared}
|
||||
{album}
|
||||
{person}
|
||||
preAction={handlePreAction}
|
||||
onAction={handleAction}
|
||||
onPrevious={handlePrevious}
|
||||
onNext={handleNext}
|
||||
onRandom={handleRandom}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
</Portal>
|
||||
{/if}
|
||||
<Portal target="body">
|
||||
{#if $showAssetViewer}
|
||||
{#await import('../asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
||||
<AssetViewer
|
||||
{withStacked}
|
||||
asset={$viewingAsset}
|
||||
preloadAssets={$preloadAssets}
|
||||
{isShared}
|
||||
{album}
|
||||
{person}
|
||||
preAction={handlePreAction}
|
||||
onAction={handleAction}
|
||||
onPrevious={handlePrevious}
|
||||
onNext={handleNext}
|
||||
onRandom={handleRandom}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
</Portal>
|
||||
|
||||
<style>
|
||||
#asset-grid {
|
||||
@@ -932,7 +954,7 @@
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.bucket {
|
||||
.month-group {
|
||||
contain: layout size paint;
|
||||
transform-style: flat;
|
||||
backface-visibility: hidden;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
|
||||
import { showDeleteModal } from '$lib/stores/preferences.store';
|
||||
import { Checkbox, Label } from '@immich/ui';
|
||||
import { mdiDeleteForeverOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -26,6 +27,7 @@
|
||||
<ConfirmModal
|
||||
title={$t('permanently_delete_assets_count', { values: { count: size } })}
|
||||
confirmText={$t('delete')}
|
||||
icon={mdiDeleteForeverOutline}
|
||||
onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
|
||||
>
|
||||
{#snippet promptSnippet()}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<section
|
||||
id="memory-lane"
|
||||
bind:this={memoryLaneElement}
|
||||
class="relative mt-5 mx-2 overflow-x-scroll overflow-y-hidden whitespace-nowrap transition-all"
|
||||
class="relative mt-3 overflow-x-scroll overflow-y-hidden whitespace-nowrap transition-all"
|
||||
style="scrollbar-width:none"
|
||||
use:resizeObserver={({ width }) => (offsetWidth = width)}
|
||||
onscroll={onScroll}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
|
||||
import { mdiCalendarEditOutline } from '@mdi/js';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
import DateInput from '../elements/date-input.svelte';
|
||||
@@ -178,6 +179,7 @@
|
||||
<ConfirmModal
|
||||
confirmColor="primary"
|
||||
{title}
|
||||
icon={mdiCalendarEditOutline}
|
||||
prompt="Please select a new date:"
|
||||
disabled={!date.isValid}
|
||||
onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { delay } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { searchPlaces, type AssetResponseDto, type PlacesResponseDto } from '@immich/sdk';
|
||||
import { mdiMapMarkerMultipleOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
interface Point {
|
||||
@@ -113,6 +114,7 @@
|
||||
<ConfirmModal
|
||||
confirmColor="primary"
|
||||
title={$t('change_location')}
|
||||
icon={mdiMapMarkerMultipleOutline}
|
||||
size="medium"
|
||||
onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
|
||||
>
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
hideLabel?: boolean;
|
||||
options?: ComboBoxOption[];
|
||||
selectedOption?: ComboBoxOption | undefined;
|
||||
@@ -54,6 +55,7 @@
|
||||
let {
|
||||
label,
|
||||
hideLabel = false,
|
||||
disabled = false,
|
||||
options = [],
|
||||
selectedOption = $bindable(),
|
||||
placeholder = '',
|
||||
@@ -108,9 +110,9 @@
|
||||
}
|
||||
observer.observe(input);
|
||||
const scrollableAncestor = input?.closest('.overflow-y-auto, .overflow-y-scroll');
|
||||
scrollableAncestor?.addEventListener('scroll', onPositionChange);
|
||||
window.visualViewport?.addEventListener('resize', onPositionChange);
|
||||
window.visualViewport?.addEventListener('scroll', onPositionChange);
|
||||
scrollableAncestor?.addEventListener('scroll', onPositionChange, { passive: true });
|
||||
window.visualViewport?.addEventListener('resize', onPositionChange, { passive: true });
|
||||
window.visualViewport?.addEventListener('scroll', onPositionChange, { passive: true });
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
@@ -278,6 +280,7 @@
|
||||
|
||||
<input
|
||||
{placeholder}
|
||||
{disabled}
|
||||
aria-activedescendant={selectedIndex || selectedIndex === 0 ? `${listboxId}-${selectedIndex}` : ''}
|
||||
aria-autocomplete="list"
|
||||
aria-controls={listboxId}
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
onMount(() => {
|
||||
if (browser) {
|
||||
document.addEventListener('scroll', onScroll);
|
||||
document.addEventListener('scroll', onScroll, { passive: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -62,8 +62,6 @@
|
||||
document.removeEventListener('scroll', onScroll);
|
||||
}
|
||||
});
|
||||
|
||||
let buttonClass = $derived(forceDark ? 'hover:text-immich-dark-gray' : undefined);
|
||||
</script>
|
||||
|
||||
<div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full bg-transparent">
|
||||
@@ -90,7 +88,6 @@
|
||||
variant="ghost"
|
||||
icon={backIcon}
|
||||
size="large"
|
||||
class={buttonClass}
|
||||
/>
|
||||
{/if}
|
||||
{@render leading?.()}
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
const entries: FileSystemEntry[] = [];
|
||||
const files: File[] = [];
|
||||
for (const item of dataTransfer.items) {
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
const entry = item.webkitGetAsEntry();
|
||||
if (entry) {
|
||||
entries.push(entry);
|
||||
@@ -67,6 +68,7 @@
|
||||
return handleFiles([...files, ...directoryFiles]);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
const browserSupportsDirectoryUpload = () => typeof DataTransferItem.prototype.webkitGetAsEntry === 'function';
|
||||
|
||||
const getAllFilesFromTransferEntries = async (transferEntries: FileSystemEntry[]): Promise<File[]> => {
|
||||
|
||||
@@ -5,11 +5,10 @@
|
||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||
import { AppRoute, AssetAction } from '$lib/constants';
|
||||
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
||||
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import type { Viewport } from '$lib/managers/timeline-manager/types';
|
||||
import { showDeleteModal } from '$lib/stores/preferences.store';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { Theme } from '$lib/constants';
|
||||
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||
@@ -22,7 +23,7 @@
|
||||
import { isEqual, omit } from 'lodash-es';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import maplibregl, { GlobeControl, type GeoJSONSource, type LngLatLike } from 'maplibre-gl';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import {
|
||||
AttributionControl,
|
||||
@@ -74,6 +75,8 @@
|
||||
showSimpleControls = true,
|
||||
}: Props = $props();
|
||||
|
||||
const initialCenter = center;
|
||||
|
||||
let map: maplibregl.Map | undefined = $state();
|
||||
let marker: maplibregl.Marker | null = null;
|
||||
let abortController: AbortController;
|
||||
@@ -211,6 +214,17 @@
|
||||
}
|
||||
};
|
||||
|
||||
afterNavigate(() => {
|
||||
if (map) {
|
||||
map.resize();
|
||||
|
||||
if (globalThis.location.hash) {
|
||||
const hashChangeEvent = new HashChangeEvent('hashchange');
|
||||
globalThis.dispatchEvent(hashChangeEvent);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
if (!mapMarkers) {
|
||||
mapMarkers = await loadMapMarkers();
|
||||
@@ -247,6 +261,14 @@
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!center || !zoom) {
|
||||
return;
|
||||
}
|
||||
|
||||
untrack(() => map?.jumpTo({ center, zoom }));
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- We handle style loading ourselves so we set style blank here -->
|
||||
@@ -254,8 +276,8 @@
|
||||
{hash}
|
||||
style=""
|
||||
class="h-full {rounded ? 'rounded-2xl' : 'rounded-none'}"
|
||||
{center}
|
||||
{zoom}
|
||||
center={initialCenter}
|
||||
attributionControl={false}
|
||||
diffStyleUpdates={true}
|
||||
onload={(event) => {
|
||||
@@ -305,7 +327,7 @@
|
||||
features: mapMarkers?.map((marker) => asFeature(marker)) ?? [],
|
||||
}}
|
||||
id="geojson"
|
||||
cluster={{ radius: 35, maxZoom: 17 }}
|
||||
cluster={{ radius: 35, maxZoom: 18 }}
|
||||
>
|
||||
<MarkerLayer
|
||||
applyToClusters
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
shape="round"
|
||||
onclick={async () => {
|
||||
onClose();
|
||||
await modalManager.show(AvatarEditModal, {});
|
||||
await modalManager.show(AvatarEditModal);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
title={`${$user.name} (${$user.email})`}
|
||||
>
|
||||
{#key $user}
|
||||
<UserAvatar user={$user} size="md" showTitle={false} interactive />
|
||||
<UserAvatar user={$user} size="md" noTitle interactive />
|
||||
{/key}
|
||||
</button>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
type ComponentNotification,
|
||||
type Notification,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import { Button, IconButton, type Color } from '@immich/ui';
|
||||
import { mdiCloseCircleOutline, mdiInformationOutline, mdiWindowClose } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
@@ -40,10 +40,10 @@
|
||||
[NotificationType.Warning]: '#D08613',
|
||||
};
|
||||
|
||||
const buttonStyle: Record<NotificationType, string> = {
|
||||
[NotificationType.Info]: 'text-white bg-immich-primary hover:bg-immich-primary/75',
|
||||
[NotificationType.Error]: 'text-white bg-immich-error hover:bg-immich-error/75',
|
||||
[NotificationType.Warning]: 'text-white bg-immich-warning hover:bg-immich-warning/75',
|
||||
const colors: Record<NotificationType, Color> = {
|
||||
[NotificationType.Info]: 'primary',
|
||||
[NotificationType.Error]: 'danger',
|
||||
[NotificationType.Warning]: 'warning',
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
@@ -111,16 +111,16 @@
|
||||
</p>
|
||||
|
||||
{#if notification.button}
|
||||
<p class="ps-[28px] mt-2.5 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
class="{buttonStyle[notification.type]} rounded px-3 pt-1.5 pb-1 transition-all duration-200"
|
||||
<p class="ps-[28px] mt-2.5 light text-light">
|
||||
<Button
|
||||
size="small"
|
||||
color={colors[notification.type]}
|
||||
onclick={handleButtonClick}
|
||||
aria-hidden="true"
|
||||
tabindex={-1}
|
||||
>
|
||||
{notification.button.text}
|
||||
</button>
|
||||
</Button>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
const { value, width, alt = $t('alt_text_qr_code') }: Props = $props();
|
||||
|
||||
let promise = $derived(QRCode.toDataURL(value, { margin: 0, width }));
|
||||
let promise = $derived(QRCode.toDataURL(value, { margin: 4, width }));
|
||||
</script>
|
||||
|
||||
<div style="width: {width}px; height: {width}px">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
|
||||
import type { LiteBucket } from '$lib/managers/timeline-manager/types';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { ScrubberMonth } from '$lib/managers/timeline-manager/types';
|
||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||
import { getTabbable } from '$lib/utils/focus-util';
|
||||
import { type ScrubberListener } from '$lib/utils/timeline-util';
|
||||
@@ -14,10 +14,10 @@
|
||||
timelineTopOffset?: number;
|
||||
timelineBottomOffset?: number;
|
||||
height?: number;
|
||||
assetStore: AssetStore;
|
||||
timelineManager: TimelineManager;
|
||||
scrubOverallPercent?: number;
|
||||
scrubBucketPercent?: number;
|
||||
scrubBucket?: { year: number; month: number };
|
||||
scrubberMonthPercent?: number;
|
||||
scrubberMonth?: { year: number; month: number };
|
||||
leadout?: boolean;
|
||||
scrubberWidth?: number;
|
||||
onScrub?: ScrubberListener;
|
||||
@@ -30,10 +30,10 @@
|
||||
timelineTopOffset = 0,
|
||||
timelineBottomOffset = 0,
|
||||
height = 0,
|
||||
assetStore,
|
||||
timelineManager,
|
||||
scrubOverallPercent = 0,
|
||||
scrubBucketPercent = 0,
|
||||
scrubBucket = undefined,
|
||||
scrubberMonthPercent = 0,
|
||||
scrubberMonth = undefined,
|
||||
leadout = false,
|
||||
onScrub = undefined,
|
||||
onScrubKeyDown = undefined,
|
||||
@@ -69,7 +69,7 @@
|
||||
return '100vw';
|
||||
}
|
||||
if (usingMobileDevice) {
|
||||
if (assetStore.scrolling) {
|
||||
if (timelineManager.scrolling) {
|
||||
return MOBILE_WIDTH + 'px';
|
||||
}
|
||||
return '0px';
|
||||
@@ -80,24 +80,24 @@
|
||||
scrubberWidth = usingMobileDevice ? MOBILE_WIDTH : DESKTOP_WIDTH;
|
||||
});
|
||||
|
||||
const toScrollFromBucketPercentage = (
|
||||
scrubBucket: { year: number; month: number } | undefined,
|
||||
scrubBucketPercent: number,
|
||||
const toScrollFromMonthGroupPercentage = (
|
||||
scrubberMonth: { year: number; month: number } | undefined,
|
||||
scrubberMonthPercent: number,
|
||||
scrubOverallPercent: number,
|
||||
) => {
|
||||
if (scrubBucket) {
|
||||
if (scrubberMonth) {
|
||||
let offset = relativeTopOffset;
|
||||
let match = false;
|
||||
for (const segment of segments) {
|
||||
if (segment.month === scrubBucket.month && segment.year === scrubBucket.year) {
|
||||
offset += scrubBucketPercent * segment.height;
|
||||
if (segment.month === scrubberMonth.month && segment.year === scrubberMonth.year) {
|
||||
offset += scrubberMonthPercent * segment.height;
|
||||
match = true;
|
||||
break;
|
||||
}
|
||||
offset += segment.height;
|
||||
}
|
||||
if (!match) {
|
||||
offset += scrubBucketPercent * relativeBottomOffset;
|
||||
offset += scrubberMonthPercent * relativeBottomOffset;
|
||||
}
|
||||
return offset;
|
||||
} else if (leadout) {
|
||||
@@ -111,8 +111,8 @@
|
||||
return scrubOverallPercent * (height - (PADDING_TOP + PADDING_BOTTOM));
|
||||
}
|
||||
};
|
||||
let scrollY = $derived(toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent));
|
||||
let timelineFullHeight = $derived(assetStore.scrubberTimelineHeight + timelineTopOffset + timelineBottomOffset);
|
||||
let scrollY = $derived(toScrollFromMonthGroupPercentage(scrubberMonth, scrubberMonthPercent, scrubOverallPercent));
|
||||
let timelineFullHeight = $derived(timelineManager.scrubberTimelineHeight + timelineTopOffset + timelineBottomOffset);
|
||||
let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
|
||||
let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
hasDot: boolean;
|
||||
};
|
||||
|
||||
const calculateSegments = (buckets: LiteBucket[]) => {
|
||||
const calculateSegments = (months: ScrubberMonth[]) => {
|
||||
let height = 0;
|
||||
let dotHeight = 0;
|
||||
|
||||
@@ -134,16 +134,16 @@
|
||||
let previousLabeledSegment: Segment | undefined;
|
||||
|
||||
let top = 0;
|
||||
for (const [i, bucket] of buckets.entries()) {
|
||||
const scrollBarPercentage = bucket.bucketHeight / timelineFullHeight;
|
||||
for (const [i, scrubMonth] of months.entries()) {
|
||||
const scrollBarPercentage = scrubMonth.height / timelineFullHeight;
|
||||
|
||||
const segment = {
|
||||
top,
|
||||
count: bucket.assetCount,
|
||||
count: scrubMonth.assetCount,
|
||||
height: toScrollY(scrollBarPercentage),
|
||||
dateFormatted: bucket.bucketDateFormattted,
|
||||
year: bucket.year,
|
||||
month: bucket.month,
|
||||
dateFormatted: scrubMonth.title,
|
||||
year: scrubMonth.year,
|
||||
month: scrubMonth.month,
|
||||
hasLabel: false,
|
||||
hasDot: false,
|
||||
};
|
||||
@@ -172,7 +172,7 @@
|
||||
return segments;
|
||||
};
|
||||
let activeSegment: HTMLElement | undefined = $state();
|
||||
const segments = $derived(calculateSegments(assetStore.scrubberBuckets));
|
||||
const segments = $derived(calculateSegments(timelineManager.scrubberMonths));
|
||||
const hoverLabel = $derived.by(() => {
|
||||
if (isHoverOnPaddingTop) {
|
||||
return segments.at(0)?.dateFormatted;
|
||||
@@ -182,11 +182,11 @@
|
||||
}
|
||||
return activeSegment?.dataset.label;
|
||||
});
|
||||
const bucketDate = $derived.by(() => {
|
||||
if (!activeSegment?.dataset.timeSegmentBucketDate) {
|
||||
const segmentDate = $derived.by(() => {
|
||||
if (!activeSegment?.dataset.segmentYearMonth) {
|
||||
return undefined;
|
||||
}
|
||||
const [year, month] = activeSegment.dataset.timeSegmentBucketDate.split('-').map(Number);
|
||||
const [year, month] = activeSegment.dataset.segmentYearMonth.split('-').map(Number);
|
||||
return { year, month };
|
||||
});
|
||||
const scrollSegment = $derived.by(() => {
|
||||
@@ -241,17 +241,17 @@
|
||||
const boundingClientRect = bestElement.boundingClientRect;
|
||||
const sy = boundingClientRect.y;
|
||||
const relativeY = y - sy;
|
||||
const bucketPercentY = relativeY / boundingClientRect.height;
|
||||
const monthGroupPercentY = relativeY / boundingClientRect.height;
|
||||
return {
|
||||
isOnPaddingTop: false,
|
||||
isOnPaddingBottom: false,
|
||||
segment,
|
||||
bucketPercentY,
|
||||
monthGroupPercentY,
|
||||
};
|
||||
}
|
||||
|
||||
// check if padding
|
||||
const bar = findElementBestY(elements, 0, 'immich-scrubbable-scrollbar');
|
||||
const bar = findElementBestY(elements, 0, 'scrubber');
|
||||
let isOnPaddingTop = false;
|
||||
let isOnPaddingBottom = false;
|
||||
|
||||
@@ -269,7 +269,7 @@
|
||||
isOnPaddingTop,
|
||||
isOnPaddingBottom,
|
||||
segment: undefined,
|
||||
bucketPercentY: 0,
|
||||
monthGroupPercentY: 0,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -288,19 +288,19 @@
|
||||
const upper = rect?.height - (PADDING_TOP + PADDING_BOTTOM);
|
||||
hoverY = clamp(clientY - rect?.top - PADDING_TOP, lower, upper);
|
||||
const x = rect!.left + rect!.width / 2;
|
||||
const { segment, bucketPercentY, isOnPaddingTop, isOnPaddingBottom } = getActive(x, clientY);
|
||||
const { segment, monthGroupPercentY, isOnPaddingTop, isOnPaddingBottom } = getActive(x, clientY);
|
||||
activeSegment = segment;
|
||||
isHoverOnPaddingTop = isOnPaddingTop;
|
||||
isHoverOnPaddingBottom = isOnPaddingBottom;
|
||||
|
||||
const scrollPercent = toTimelineY(hoverY);
|
||||
if (wasDragging === false && isDragging) {
|
||||
void startScrub?.(bucketDate!, scrollPercent, bucketPercentY);
|
||||
void onScrub?.(bucketDate!, scrollPercent, bucketPercentY);
|
||||
void startScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
|
||||
void onScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
|
||||
}
|
||||
|
||||
if (wasDragging && !isDragging) {
|
||||
void stopScrub?.(bucketDate!, scrollPercent, bucketPercentY);
|
||||
void stopScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -308,8 +308,9 @@
|
||||
return;
|
||||
}
|
||||
|
||||
void onScrub?.(bucketDate!, scrollPercent, bucketPercentY);
|
||||
void onScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
|
||||
};
|
||||
/* eslint-disable tscompat/tscompat */
|
||||
const getTouch = (event: TouchEvent) => {
|
||||
if (event.touches.length === 1) {
|
||||
return event.touches[0];
|
||||
@@ -324,7 +325,7 @@
|
||||
}
|
||||
const elements = document.elementsFromPoint(touch.clientX, touch.clientY);
|
||||
const isHoverScrollbar =
|
||||
findElementBestY(elements, 0, 'immich-scrubbable-scrollbar', 'time-label', 'lead-in', 'lead-out') !== undefined;
|
||||
findElementBestY(elements, 0, 'scrubber', 'time-label', 'lead-in', 'lead-out') !== undefined;
|
||||
|
||||
isHover = isHoverScrollbar;
|
||||
|
||||
@@ -354,19 +355,20 @@
|
||||
isHover = false;
|
||||
}
|
||||
};
|
||||
/* eslint-enable tscompat/tscompat */
|
||||
onMount(() => {
|
||||
document.addEventListener('touchmove', onTouchMove, true);
|
||||
document.addEventListener('touchmove', onTouchMove, { capture: true, passive: true });
|
||||
return () => {
|
||||
document.removeEventListener('touchmove', onTouchMove);
|
||||
document.removeEventListener('touchmove', onTouchMove, true);
|
||||
};
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener('touchstart', onTouchStart, true);
|
||||
document.addEventListener('touchend', onTouchEnd, true);
|
||||
document.addEventListener('touchstart', onTouchStart, { capture: true, passive: true });
|
||||
document.addEventListener('touchend', onTouchEnd, { capture: true, passive: true });
|
||||
return () => {
|
||||
document.addEventListener('touchstart', onTouchStart, true);
|
||||
document.addEventListener('touchend', onTouchEnd, true);
|
||||
document.removeEventListener('touchstart', onTouchStart, true);
|
||||
document.removeEventListener('touchend', onTouchEnd, true);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -451,7 +453,7 @@
|
||||
aria-valuenow={scrollY + PADDING_TOP}
|
||||
aria-valuemax={toScrollY(1)}
|
||||
aria-valuemin={toScrollY(0)}
|
||||
data-id="immich-scrubbable-scrollbar"
|
||||
data-id="scrubber"
|
||||
class="absolute end-0 z-1 select-none hover:cursor-row-resize"
|
||||
style:padding-top={PADDING_TOP + 'px'}
|
||||
style:padding-bottom={PADDING_BOTTOM + 'px'}
|
||||
@@ -477,7 +479,7 @@
|
||||
{hoverLabel}
|
||||
</div>
|
||||
{/if}
|
||||
{#if usingMobileDevice && ((assetStore.scrolling && scrollHoverLabel) || isHover || isDragging)}
|
||||
{#if usingMobileDevice && ((timelineManager.scrolling && scrollHoverLabel) || isHover || isDragging)}
|
||||
<div
|
||||
id="time-label"
|
||||
class="rounded-s-full w-[32px] ps-2 text-white bg-immich-primary dark:bg-gray-600 hover:cursor-pointer select-none"
|
||||
@@ -490,7 +492,7 @@
|
||||
>
|
||||
<Icon path={mdiPlay} size="20" class="-rotate-90 relative top-[9px] -end-[2px]" />
|
||||
<Icon path={mdiPlay} size="20" class="rotate-90 relative top-px -end-[2px]" />
|
||||
{#if (assetStore.scrolling && scrollHoverLabel) || isHover || isDragging}
|
||||
{#if (timelineManager.scrolling && scrollHoverLabel) || isHover || isDragging}
|
||||
<p
|
||||
transition:fade={{ duration: 200 }}
|
||||
style:bottom={50 / 2 - 30 / 2 + 'px'}
|
||||
@@ -505,11 +507,8 @@
|
||||
{/if}
|
||||
<!-- Scroll Position Indicator Line -->
|
||||
{#if !usingMobileDevice && !isDragging}
|
||||
<div
|
||||
class="absolute end-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary"
|
||||
style:top="{scrollY + PADDING_TOP - 2}px"
|
||||
>
|
||||
{#if assetStore.scrolling && scrollHoverLabel && !isHover}
|
||||
<div class="absolute end-0 h-[2px] w-10 bg-primary" style:top="{scrollY + PADDING_TOP - 2}px">
|
||||
{#if timelineManager.scrolling && scrollHoverLabel && !isHover}
|
||||
<p
|
||||
transition:fade={{ duration: 200 }}
|
||||
class="truncate pointer-events-none absolute end-0 bottom-0 min-w-20 max-w-64 w-fit rounded-tl-md border-b-2 border-immich-primary bg-subtle/90 z-1 py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:text-immich-dark-fg"
|
||||
@@ -523,7 +522,7 @@
|
||||
class="relative"
|
||||
style:height={relativeTopOffset + 'px'}
|
||||
data-id="lead-in"
|
||||
data-time-segment-bucket-date={segments.at(0)?.year + '-' + segments.at(0)?.month}
|
||||
data-segment-year-month={segments.at(0)?.year + '-' + segments.at(0)?.month}
|
||||
data-label={segments.at(0)?.dateFormatted}
|
||||
>
|
||||
{#if relativeTopOffset > 6}
|
||||
@@ -535,7 +534,7 @@
|
||||
<div
|
||||
class="relative"
|
||||
data-id="time-segment"
|
||||
data-time-segment-bucket-date={segment.year + '-' + segment.month}
|
||||
data-segment-year-month={segment.year + '-' + segment.month}
|
||||
data-label={segment.dateFormatted}
|
||||
style:height={segment.height + 'px'}
|
||||
>
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
|
||||
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
|
||||
import { onDestroy, tick } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import SearchHistoryBox from './search-history-box.svelte';
|
||||
import { IconButton } from '@immich/ui';
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
@@ -93,7 +93,7 @@
|
||||
}
|
||||
|
||||
const result = modalManager.open(SearchFilterModal, { searchQuery });
|
||||
close = result.close;
|
||||
close = () => result.close(undefined);
|
||||
closeDropdown();
|
||||
|
||||
const searchResult = await result.onClose;
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<script lang="ts">
|
||||
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
|
||||
import { getAllTags, type TagResponseDto } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { onMount } from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { mdiClose } from '@mdi/js';
|
||||
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { getAllTags, type TagResponseDto } from '@immich/sdk';
|
||||
import { Checkbox, Label } from '@immich/ui';
|
||||
import { mdiClose } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
selectedTags: SvelteSet<string>;
|
||||
selectedTags: SvelteSet<string> | null;
|
||||
}
|
||||
|
||||
let { selectedTags = $bindable() }: Props = $props();
|
||||
@@ -23,7 +24,7 @@
|
||||
});
|
||||
|
||||
const handleSelect = (option?: ComboBoxOption) => {
|
||||
if (!option || !option.id) {
|
||||
if (!option || !option.id || selectedTags === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -32,6 +33,10 @@
|
||||
};
|
||||
|
||||
const handleRemove = (tag: string) => {
|
||||
if (selectedTags === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedTags.delete(tag);
|
||||
};
|
||||
</script>
|
||||
@@ -41,6 +46,7 @@
|
||||
<form autocomplete="off" id="create-tag-form">
|
||||
<div class="my-4 flex flex-col gap-2">
|
||||
<Combobox
|
||||
disabled={selectedTags === null}
|
||||
onSelect={handleSelect}
|
||||
label={$t('tags').toUpperCase()}
|
||||
defaultFirstOption
|
||||
@@ -49,15 +55,26 @@
|
||||
placeholder={$t('search_tags')}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="untagged-checkbox"
|
||||
size="tiny"
|
||||
checked={selectedTags === null}
|
||||
onCheckedChange={(checked) => {
|
||||
selectedTags = checked ? null : new SvelteSet();
|
||||
}}
|
||||
/>
|
||||
<Label label={$t('untagged')} for="untagged-checkbox" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="flex flex-wrap pt-2 gap-1">
|
||||
{#each selectedTags as tagId (tagId)}
|
||||
{#each selectedTags ?? [] as tagId (tagId)}
|
||||
{@const tag = tagMap[tagId]}
|
||||
{#if tag}
|
||||
<div class="flex group transition-all">
|
||||
<span
|
||||
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-immich-primary dark:bg-immich-dark-primary roudned-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
|
||||
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary roudned-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
|
||||
>
|
||||
<p class="text-sm">
|
||||
{tag.value}
|
||||
@@ -67,7 +84,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-e-full place-items-center place-content-center pe-2 ps-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
|
||||
title="Remove tag"
|
||||
title={$t('remove_tag')}
|
||||
onclick={() => handleRemove(tagId)}
|
||||
>
|
||||
<Icon path={mdiClose} />
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
interface Props {
|
||||
inputType: SettingInputFieldType;
|
||||
value: string | number | undefined;
|
||||
value: string | number | undefined | null;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: string;
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
const { isPurchased } = purchaseStore;
|
||||
|
||||
const openPurchaseModal = async () => {
|
||||
await modalManager.show(PurchaseModal, {});
|
||||
await modalManager.show(PurchaseModal);
|
||||
showMessage = false;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { userInteraction } from '$lib/stores/user.svelte';
|
||||
import { requestServerInfo } from '$lib/utils/auth';
|
||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import LoadingSpinner from '../loading-spinner.svelte';
|
||||
import { userInteraction } from '$lib/stores/user.svelte';
|
||||
|
||||
let usageClasses = $state('');
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
return 'bg-yellow-500';
|
||||
}
|
||||
|
||||
return 'bg-immich-primary dark:bg-immich-dark-primary';
|
||||
return 'bg-primary';
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
|
||||
@@ -44,19 +44,19 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center justify-center">
|
||||
{#if uploadAsset.state === UploadState.PENDING}
|
||||
<Icon path={mdiCircleOutline} size="24" class="text-immich-primary" title={$t('pending')} />
|
||||
<Icon path={mdiCircleOutline} size="24" class="text-primary" title={$t('pending')} />
|
||||
{:else if uploadAsset.state === UploadState.STARTED}
|
||||
<Icon path={mdiLoading} size="24" spin class="text-immich-primary" title={$t('asset_skipped')} />
|
||||
<Icon path={mdiLoading} size="24" spin class="text-primary" title={$t('asset_skipped')} />
|
||||
{:else if uploadAsset.state === UploadState.ERROR}
|
||||
<Icon path={mdiAlertCircle} size="24" class="text-immich-error" title={$t('error')} />
|
||||
<Icon path={mdiAlertCircle} size="24" class="text-danger" title={$t('error')} />
|
||||
{:else if uploadAsset.state === UploadState.DUPLICATED}
|
||||
{#if uploadAsset.isTrashed}
|
||||
<Icon path={mdiTrashCan} size="24" class="text-gray-500" title={$t('asset_skipped_in_trash')} />
|
||||
{:else}
|
||||
<Icon path={mdiAlertCircle} size="24" class="text-immich-warning" title={$t('asset_skipped')} />
|
||||
<Icon path={mdiAlertCircle} size="24" class="text-warning" title={$t('asset_skipped')} />
|
||||
{/if}
|
||||
{:else if uploadAsset.state === UploadState.DONE}
|
||||
<Icon path={mdiCheckCircle} size="24" class="text-immich-success" title={$t('asset_uploaded')} />
|
||||
<Icon path={mdiCheckCircle} size="24" class="text-success" title={$t('asset_uploaded')} />
|
||||
{/if}
|
||||
</div>
|
||||
<!-- <span>[{getByteUnitString(uploadAsset.file.size, $locale)}]</span> -->
|
||||
@@ -105,7 +105,7 @@
|
||||
|
||||
{#if uploadAsset.state === UploadState.ERROR}
|
||||
<div class="flex flex-row justify-between">
|
||||
<p class="w-full rounded-md text-justify text-immich-error">
|
||||
<p class="w-full rounded-md text-justify text-danger">
|
||||
{uploadAsset.error}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -68,13 +68,13 @@
|
||||
</p>
|
||||
<p class="immich-form-label text-xs">
|
||||
{$t('upload_status_uploaded')}
|
||||
<span class="text-immich-success">{$stats.success.toLocaleString($locale)}</span>
|
||||
<span class="text-success">{$stats.success.toLocaleString($locale)}</span>
|
||||
-
|
||||
{$t('upload_status_errors')}
|
||||
<span class="text-immich-error">{$stats.errors.toLocaleString($locale)}</span>
|
||||
<span class="text-danger">{$stats.errors.toLocaleString($locale)}</span>
|
||||
-
|
||||
{$t('upload_status_duplicates')}
|
||||
<span class="text-immich-warning">{$stats.duplicates.toLocaleString($locale)}</span>
|
||||
<span class="text-warning">{$stats.duplicates.toLocaleString($locale)}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-end">
|
||||
@@ -142,7 +142,7 @@
|
||||
type="button"
|
||||
in:scale={{ duration: 250, easing: quartInOut }}
|
||||
onclick={() => (showDetail = true)}
|
||||
class="absolute -start-4 -top-4 flex h-10 w-10 place-content-center place-items-center rounded-full bg-immich-primary p-5 text-xs text-gray-200"
|
||||
class="absolute -start-4 -top-4 flex h-10 w-10 place-content-center place-items-center rounded-full bg-primary p-5 text-xs text-gray-200"
|
||||
>
|
||||
{$remainingUploads.toLocaleString($locale)}
|
||||
</button>
|
||||
@@ -151,7 +151,7 @@
|
||||
type="button"
|
||||
in:scale={{ duration: 250, easing: quartInOut }}
|
||||
onclick={() => (showDetail = true)}
|
||||
class="absolute -end-4 -top-4 flex h-10 w-10 place-content-center place-items-center rounded-full bg-immich-error p-5 text-xs text-gray-200"
|
||||
class="absolute -end-4 -top-4 flex h-10 w-10 place-content-center place-items-center rounded-full bg-danger p-5 text-xs text-gray-200"
|
||||
>
|
||||
{$stats.errors.toLocaleString($locale)}
|
||||
</button>
|
||||
@@ -160,7 +160,7 @@
|
||||
type="button"
|
||||
in:scale={{ duration: 250, easing: quartInOut }}
|
||||
onclick={() => (showDetail = true)}
|
||||
class="flex h-16 w-16 place-content-center place-items-center rounded-full bg-gray-200 p-5 text-sm text-immich-primary shadow-lg dark:bg-gray-600 dark:text-immich-gray"
|
||||
class="flex h-16 w-16 place-content-center place-items-center rounded-full bg-subtle p-5 text-sm text-primary shadow-lg"
|
||||
>
|
||||
<div class="animate-pulse">
|
||||
<Icon path={mdiCloudUploadOutline} size="30" />
|
||||
|
||||
@@ -18,25 +18,13 @@
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
color?: UserAvatarColor | undefined;
|
||||
size?: Size;
|
||||
rounded?: boolean;
|
||||
interactive?: boolean;
|
||||
showTitle?: boolean;
|
||||
showProfileImage?: boolean;
|
||||
noTitle?: boolean;
|
||||
label?: string | undefined;
|
||||
}
|
||||
|
||||
let {
|
||||
user,
|
||||
color = undefined,
|
||||
size = 'full',
|
||||
rounded = true,
|
||||
interactive = false,
|
||||
showTitle = true,
|
||||
showProfileImage = true,
|
||||
label = undefined,
|
||||
}: Props = $props();
|
||||
let { user, size = 'full', interactive = false, noTitle = false, label = undefined }: Props = $props();
|
||||
|
||||
let img: HTMLImageElement | undefined = $state();
|
||||
let showFallback = $state(true);
|
||||
@@ -51,16 +39,16 @@
|
||||
};
|
||||
|
||||
const colorClasses: Record<UserAvatarColor, string> = {
|
||||
primary: 'bg-immich-primary dark:bg-immich-dark-primary text-immich-dark-fg dark:text-immich-fg',
|
||||
pink: 'bg-pink-400 text-immich-bg',
|
||||
red: 'bg-red-500 text-immich-bg',
|
||||
yellow: 'bg-yellow-500 text-immich-bg',
|
||||
blue: 'bg-blue-500 text-immich-bg',
|
||||
green: 'bg-green-600 text-immich-bg',
|
||||
purple: 'bg-purple-600 text-immich-bg',
|
||||
orange: 'bg-orange-600 text-immich-bg',
|
||||
gray: 'bg-gray-600 text-immich-bg',
|
||||
amber: 'bg-amber-600 text-immich-bg',
|
||||
primary: 'bg-primary text-light dark:text-light',
|
||||
pink: 'bg-pink-400 text-light dark:text-dark',
|
||||
red: 'bg-red-500 text-light dark:text-dark',
|
||||
yellow: 'bg-yellow-500 text-light dark:text-dark',
|
||||
blue: 'bg-blue-500 text-light dark:text-dark',
|
||||
green: 'bg-green-600 text-light dark:text-dark',
|
||||
purple: 'bg-purple-600 text-light dark:text-dark',
|
||||
orange: 'bg-orange-600 text-light dark:text-dark',
|
||||
gray: 'bg-gray-600 text-light dark:text-dark',
|
||||
amber: 'bg-amber-600 text-light dark:text-dark',
|
||||
};
|
||||
|
||||
const sizeClasses: Record<Size, string> = {
|
||||
@@ -79,7 +67,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
let colorClass = $derived(colorClasses[color || user.avatarColor]);
|
||||
let colorClass = $derived(colorClasses[user.avatarColor]);
|
||||
let sizeClass = $derived(sizeClasses[size]);
|
||||
let title = $derived(label ?? `${user.name} (${user.email})`);
|
||||
let interactiveClass = $derived(
|
||||
@@ -90,11 +78,10 @@
|
||||
</script>
|
||||
|
||||
<figure
|
||||
class="{sizeClass} {colorClass} {interactiveClass} overflow-hidden shadow-md"
|
||||
class:rounded-full={rounded}
|
||||
title={showTitle ? title : undefined}
|
||||
class="{sizeClass} {colorClass} {interactiveClass} overflow-hidden shadow-md rounded-full"
|
||||
title={noTitle ? undefined : title}
|
||||
>
|
||||
{#if showProfileImage && user.profileImagePath}
|
||||
{#if user.profileImagePath}
|
||||
<img
|
||||
bind:this={img}
|
||||
src={getProfileImageUrl(user)}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
<script lang="ts">
|
||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||
import { websocketStore } from '$lib/stores/websocket';
|
||||
import type { ServerVersionResponseDto } from '@immich/sdk';
|
||||
import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
let showModal = $state(false);
|
||||
|
||||
const { release } = websocketStore;
|
||||
|
||||
const semverToName = ({ major, minor, patch }: ServerVersionResponseDto) => `v${major}.${minor}.${patch}`;
|
||||
|
||||
const onAcknowledge = () => {
|
||||
localStorage.setItem('appVersion', releaseVersion);
|
||||
sessionStorage.setItem('modalAcknowledged', 'true');
|
||||
showModal = false;
|
||||
};
|
||||
|
||||
const handleRelease = () => {
|
||||
try {
|
||||
if (localStorage.getItem('appVersion') === releaseVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
showModal = true;
|
||||
} catch (error) {
|
||||
console.error('Error [VersionAnnouncementBox]:', error);
|
||||
}
|
||||
};
|
||||
let releaseVersion = $derived($release && semverToName($release.releaseVersion));
|
||||
let serverVersion = $derived($release && semverToName($release.serverVersion));
|
||||
$effect(() => {
|
||||
if ($release?.isAvailable && !sessionStorage.getItem('modalAcknowledged')) {
|
||||
handleRelease();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if showModal}
|
||||
<Modal size="small" title="🎉 {$t('new_version_available')}" onClose={() => (showModal = false)} icon={false}>
|
||||
<ModalBody>
|
||||
<div>
|
||||
<FormatMessage key="version_announcement_message">
|
||||
{#snippet children({ tag, message })}
|
||||
{#if tag === 'link'}
|
||||
<span class="font-medium underline">
|
||||
<a
|
||||
href="https://github.com/immich-app/immich/releases/latest"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{message}
|
||||
</a>
|
||||
</span>
|
||||
{:else if tag === 'code'}
|
||||
<code>{message}</code>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 font-medium">{$t('version_announcement_closing')}</div>
|
||||
|
||||
<div class="font-sm mt-8">
|
||||
<code>{$t('server_version')}: {serverVersion}</code>
|
||||
<br />
|
||||
<code>{$t('latest_version')}: {releaseVersion}</code>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button fullWidth shape="round" onclick={onAcknowledge}>{$t('acknowledge')}</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
{/if}
|
||||
@@ -22,7 +22,7 @@
|
||||
mdiFeatureSearchOutline,
|
||||
mdiKeyOutline,
|
||||
mdiLockSmart,
|
||||
mdiOnepassword,
|
||||
mdiFormTextboxPassword,
|
||||
mdiServerOutline,
|
||||
mdiTwoFactorAuthentication,
|
||||
} from '@mdi/js';
|
||||
@@ -124,7 +124,12 @@
|
||||
</SettingAccordion>
|
||||
{/if}
|
||||
|
||||
<SettingAccordion icon={mdiOnepassword} key="password" title={$t('password')} subtitle={$t('change_your_password')}>
|
||||
<SettingAccordion
|
||||
icon={mdiFormTextboxPassword}
|
||||
key="password"
|
||||
title={$t('password')}
|
||||
subtitle={$t('change_your_password')}
|
||||
>
|
||||
<ChangePasswordSettings />
|
||||
</SettingAccordion>
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
|
||||
<div
|
||||
class="max-w-60 rounded-xl border-4 transition-colors font-semibold text-xs {isSelected
|
||||
? 'bg-immich-primary dark:bg-immich-dark-primary border-immich-primary dark:border-immich-dark-primary'
|
||||
: 'bg-gray-200 dark:bg-gray-800 border-gray-200 dark:border-gray-800'}"
|
||||
? 'bg-primary border-primary'
|
||||
: 'bg-subtle border-subtle'}"
|
||||
>
|
||||
<div class="relative w-full">
|
||||
<button
|
||||
|
||||
+16
-16
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import Portal from '$lib/components/shared-components/portal/portal.svelte';
|
||||
import DuplicateAsset from '$lib/components/utilities-page/duplicates/duplicate-asset.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
@@ -117,15 +116,15 @@
|
||||
<div class="flex flex-wrap gap-y-6 mb-4 px-6 w-full place-content-end justify-between">
|
||||
<!-- MARK ALL BUTTONS -->
|
||||
<div class="flex text-xs text-black">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-3 flex place-items-center gap-2 rounded-s-full dark:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/90 bg-immich-primary/25 hover:bg-immich-primary/50"
|
||||
onclick={onSelectAll}><Icon path={mdiCheck} size="20" />{$t('select_keep_all')}</button
|
||||
<Button class="rounded-s-full" size="small" color="primary" leadingIcon={mdiCheck} onclick={onSelectAll}
|
||||
>{$t('select_keep_all')}</Button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-3 flex place-items-center gap-2 rounded-e-full dark:bg-immich-dark-primary/50 hover:dark:bg-immich-dark-primary/70 bg-immich-primary hover:bg-immich-primary/80 text-white"
|
||||
onclick={onSelectNone}><Icon path={mdiTrashCanOutline} size="20" />{$t('select_trash_all')}</button
|
||||
<Button
|
||||
class="rounded-e-full"
|
||||
size="small"
|
||||
color="secondary"
|
||||
leadingIcon={mdiTrashCanOutline}
|
||||
onclick={onSelectNone}>{$t('select_trash_all')}</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -134,32 +133,33 @@
|
||||
{#if trashCount === 0}
|
||||
<Button
|
||||
size="small"
|
||||
leadingIcon={mdiCheck}
|
||||
color="primary"
|
||||
class="flex place-items-center rounded-s-full gap-2"
|
||||
onclick={handleResolve}
|
||||
>
|
||||
<Icon path={mdiCheck} size="20" />{$t('keep_all')}
|
||||
{$t('keep_all')}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
size="small"
|
||||
color="danger"
|
||||
class="flex place-items-center rounded-s-full gap-2 py-3"
|
||||
leadingIcon={mdiTrashCanOutline}
|
||||
class="rounded-s-full"
|
||||
onclick={handleResolve}
|
||||
>
|
||||
<Icon path={mdiTrashCanOutline} size="20" />{trashCount === assets.length
|
||||
? $t('trash_all')
|
||||
: $t('trash_count', { values: { count: trashCount } })}
|
||||
{trashCount === assets.length ? $t('trash_all') : $t('trash_count', { values: { count: trashCount } })}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
size="small"
|
||||
color="primary"
|
||||
class="flex place-items-center rounded-e-full gap-2"
|
||||
leadingIcon={mdiImageMultipleOutline}
|
||||
class="rounded-e-full"
|
||||
onclick={handleStack}
|
||||
disabled={selectedAssetIds.size !== 1}
|
||||
>
|
||||
<Icon path={mdiImageMultipleOutline} size="20" />{$t('stack')}
|
||||
{$t('stack')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -420,3 +420,5 @@ export enum ToggleVisibility {
|
||||
HIDE_UNNANEMD = 'hide-unnamed',
|
||||
SHOW_ALL = 'show-all',
|
||||
}
|
||||
|
||||
export const assetViewerFadeDuration: number = 150;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
createActivity,
|
||||
deleteActivity,
|
||||
@@ -10,8 +11,17 @@ import {
|
||||
type ActivityCreateDto,
|
||||
type ActivityResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
type CacheKey = string;
|
||||
type ActivityCache = {
|
||||
activities: ActivityResponseDto[];
|
||||
commentCount: number;
|
||||
likeCount: number;
|
||||
isLiked: ActivityResponseDto | null;
|
||||
};
|
||||
|
||||
class ActivityManager {
|
||||
#albumId = $state<string | undefined>();
|
||||
#assetId = $state<string | undefined>();
|
||||
@@ -20,6 +30,14 @@ class ActivityManager {
|
||||
#likeCount = $state(0);
|
||||
#isLiked = $state<ActivityResponseDto | null>(null);
|
||||
|
||||
#cache = new Map<CacheKey, ActivityCache>();
|
||||
|
||||
isLoading = $state(false);
|
||||
|
||||
get assetId() {
|
||||
return this.#assetId;
|
||||
}
|
||||
|
||||
get activities() {
|
||||
return this.#activities;
|
||||
}
|
||||
@@ -36,9 +54,27 @@ class ActivityManager {
|
||||
return this.#isLiked;
|
||||
}
|
||||
|
||||
init(albumId: string, assetId?: string) {
|
||||
#getCacheKey(albumId: string, assetId?: string) {
|
||||
return `${albumId}:${assetId ?? ''}`;
|
||||
}
|
||||
|
||||
async init(albumId: string, assetId?: string) {
|
||||
if (assetId && assetId === this.#assetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#albumId = albumId;
|
||||
this.#assetId = assetId;
|
||||
try {
|
||||
await activityManager.refreshActivities(albumId, assetId);
|
||||
} catch (error) {
|
||||
handleError(error, get(t)('errors.unable_to_get_comments_number'));
|
||||
}
|
||||
}
|
||||
|
||||
#invalidateCache(albumId: string, assetId?: string) {
|
||||
this.#cache.delete(this.#getCacheKey(albumId));
|
||||
this.#cache.delete(this.#getCacheKey(albumId, assetId));
|
||||
}
|
||||
|
||||
async addActivity(dto: ActivityCreateDto) {
|
||||
@@ -57,6 +93,7 @@ class ActivityManager {
|
||||
this.#likeCount++;
|
||||
}
|
||||
|
||||
this.#invalidateCache(this.#albumId, this.#assetId);
|
||||
handlePromiseError(this.refreshActivities(this.#albumId, this.#assetId));
|
||||
return activity;
|
||||
}
|
||||
@@ -79,6 +116,7 @@ class ActivityManager {
|
||||
: this.#activities.filter(({ id }) => id !== activity.id);
|
||||
|
||||
await deleteActivity({ id: activity.id });
|
||||
this.#invalidateCache(this.#albumId, this.#assetId);
|
||||
handlePromiseError(this.refreshActivities(this.#albumId, this.#assetId));
|
||||
}
|
||||
|
||||
@@ -100,6 +138,20 @@ class ActivityManager {
|
||||
}
|
||||
|
||||
async refreshActivities(albumId: string, assetId?: string) {
|
||||
this.isLoading = true;
|
||||
|
||||
const cacheKey = this.#getCacheKey(albumId, assetId);
|
||||
|
||||
if (this.#cache.has(cacheKey)) {
|
||||
const cached = this.#cache.get(cacheKey)!;
|
||||
this.#activities = cached.activities;
|
||||
this.#commentCount = cached.commentCount;
|
||||
this.#likeCount = cached.likeCount;
|
||||
this.#isLiked = cached.isLiked ?? null;
|
||||
this.isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.#activities = await getActivities({ albumId, assetId });
|
||||
|
||||
const [liked] = await getActivities({
|
||||
@@ -114,6 +166,15 @@ class ActivityManager {
|
||||
const { comments, likes } = await getActivityStatistics({ albumId, assetId });
|
||||
this.#commentCount = comments;
|
||||
this.#likeCount = likes;
|
||||
|
||||
this.#cache.set(cacheKey, {
|
||||
activities: this.#activities,
|
||||
commentCount: this.#commentCount,
|
||||
likeCount: this.#likeCount,
|
||||
isLiked: this.#isLiked,
|
||||
});
|
||||
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
reset() {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
class AlbumMapViewManager {
|
||||
#isInMapView = $state(false);
|
||||
|
||||
get isInMapView() {
|
||||
return this.#isInMapView;
|
||||
}
|
||||
|
||||
set isInMapView(isInMapView: boolean) {
|
||||
this.#isInMapView = isInMapView;
|
||||
}
|
||||
}
|
||||
|
||||
export const albumMapViewManager = new AlbumMapViewManager();
|
||||
@@ -1,28 +1,39 @@
|
||||
import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
|
||||
import { mount, unmount, type Component, type ComponentProps } from 'svelte';
|
||||
|
||||
type OnCloseData<T> = T extends { onClose: (data?: infer R) => void } ? R : never;
|
||||
type ExtendsEmptyObject<T> = keyof T extends never ? Record<string, never> : T;
|
||||
type OnCloseData<T> = T extends { onClose: (data?: infer R) => void }
|
||||
? R | undefined
|
||||
: T extends { onClose: (data: infer R) => void }
|
||||
? R
|
||||
: never;
|
||||
type ExtendsEmptyObject<T> = keyof T extends never ? never : T;
|
||||
type StripValueIfOptional<T> = T extends undefined ? undefined : T;
|
||||
|
||||
// if the modal does not expect any props, makes the props param optional but also allows passing `{}` and `undefined`
|
||||
type OptionalParamIfEmpty<T> = ExtendsEmptyObject<T> extends never ? [] | [Record<string, never> | undefined] : [T];
|
||||
|
||||
class ModalManager {
|
||||
show<T extends object>(Component: Component<T>, props: ExtendsEmptyObject<Omit<T, 'onClose'>>) {
|
||||
return this.open(Component, props).onClose;
|
||||
show<T extends object>(Component: Component<T>, ...props: OptionalParamIfEmpty<Omit<T, 'onClose'>>) {
|
||||
return this.open(Component, ...props).onClose;
|
||||
}
|
||||
|
||||
open<T extends object, K = OnCloseData<T>>(Component: Component<T>, props: ExtendsEmptyObject<Omit<T, 'onClose'>>) {
|
||||
open<T extends object, K = OnCloseData<T>>(
|
||||
Component: Component<T>,
|
||||
...props: OptionalParamIfEmpty<Omit<T, 'onClose'>>
|
||||
) {
|
||||
let modal: object = {};
|
||||
let onClose: () => Promise<void>;
|
||||
let onClose: (...args: [StripValueIfOptional<K>]) => Promise<void>;
|
||||
|
||||
const deferred = new Promise<K | undefined>((resolve) => {
|
||||
onClose = async (data?: K) => {
|
||||
const deferred = new Promise<StripValueIfOptional<K>>((resolve) => {
|
||||
onClose = async (...args: [StripValueIfOptional<K>]) => {
|
||||
await unmount(modal);
|
||||
resolve(data);
|
||||
setTimeout(() => resolve(args?.[0]), 0);
|
||||
};
|
||||
|
||||
modal = mount(Component, {
|
||||
target: document.body,
|
||||
props: {
|
||||
...(props as T),
|
||||
...((props?.[0] ?? {}) as T),
|
||||
onClose,
|
||||
},
|
||||
});
|
||||
@@ -30,7 +41,7 @@ class ModalManager {
|
||||
|
||||
return {
|
||||
onClose: deferred,
|
||||
close: () => onClose(),
|
||||
close: (...args: [StripValueIfOptional<K>]) => onClose(args[0]),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -52,11 +52,15 @@ class ThemeManager {
|
||||
}
|
||||
|
||||
#onAppInit() {
|
||||
globalThis.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if (this.theme.system) {
|
||||
this.#update('system');
|
||||
}
|
||||
});
|
||||
globalThis.matchMedia('(prefers-color-scheme: dark)').addEventListener(
|
||||
'change',
|
||||
() => {
|
||||
if (this.theme.system) {
|
||||
this.#update('system');
|
||||
}
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
}
|
||||
|
||||
#update(value: Theme | 'system') {
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { TimelinePlainDate } from '$lib/utils/timeline-util';
|
||||
import { AssetOrder } from '@immich/sdk';
|
||||
import type { AssetBucket } from './asset-bucket.svelte';
|
||||
import type { AssetDateGroup } from './asset-date-group.svelte';
|
||||
import type { TimelineAsset } from './types';
|
||||
|
||||
export class AddContext {
|
||||
#lookupCache: {
|
||||
[year: number]: { [month: number]: { [day: number]: AssetDateGroup } };
|
||||
} = {};
|
||||
unprocessedAssets: TimelineAsset[] = [];
|
||||
changedDateGroups = new Set<AssetDateGroup>();
|
||||
newDateGroups = new Set<AssetDateGroup>();
|
||||
|
||||
getDateGroup({ year, month, day }: TimelinePlainDate): AssetDateGroup | undefined {
|
||||
return this.#lookupCache[year]?.[month]?.[day];
|
||||
}
|
||||
|
||||
setDateGroup(dateGroup: AssetDateGroup, { year, month, day }: TimelinePlainDate) {
|
||||
if (!this.#lookupCache[year]) {
|
||||
this.#lookupCache[year] = {};
|
||||
}
|
||||
if (!this.#lookupCache[year][month]) {
|
||||
this.#lookupCache[year][month] = {};
|
||||
}
|
||||
this.#lookupCache[year][month][day] = dateGroup;
|
||||
}
|
||||
|
||||
get existingDateGroups() {
|
||||
return this.changedDateGroups.difference(this.newDateGroups);
|
||||
}
|
||||
|
||||
get updatedBuckets() {
|
||||
const updated = new Set<AssetBucket>();
|
||||
for (const group of this.changedDateGroups) {
|
||||
updated.add(group.bucket);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
get bucketsWithNewDateGroups() {
|
||||
const updated = new Set<AssetBucket>();
|
||||
for (const group of this.newDateGroups) {
|
||||
updated.add(group.bucket);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
sort(bucket: AssetBucket, sortOrder: AssetOrder = AssetOrder.Desc) {
|
||||
for (const group of this.changedDateGroups) {
|
||||
group.sortAssets(sortOrder);
|
||||
}
|
||||
for (const group of this.newDateGroups) {
|
||||
group.sortAssets(sortOrder);
|
||||
}
|
||||
if (this.newDateGroups.size > 0) {
|
||||
bucket.sortDateGroups();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,934 +0,0 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { websocketEvents } from '$lib/stores/websocket';
|
||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
import {
|
||||
plainDateTimeCompare,
|
||||
toISOYearMonthUTC,
|
||||
toTimelineAsset,
|
||||
type TimelinePlainDate,
|
||||
type TimelinePlainDateTime,
|
||||
type TimelinePlainYearMonth,
|
||||
} from '$lib/utils/timeline-util';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import { getAssetInfo, getTimeBucket, getTimeBuckets } from '@immich/sdk';
|
||||
import { clamp, debounce, isEqual, throttle } from 'lodash-es';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { Unsubscriber } from 'svelte/store';
|
||||
import { AddContext } from './add-context.svelte';
|
||||
import { AssetBucket } from './asset-bucket.svelte';
|
||||
import { AssetDateGroup } from './asset-date-group.svelte';
|
||||
import type {
|
||||
AssetDescriptor,
|
||||
AssetOperation,
|
||||
AssetStoreLayoutOptions,
|
||||
AssetStoreOptions,
|
||||
Direction,
|
||||
LiteBucket,
|
||||
PendingChange,
|
||||
TimelineAsset,
|
||||
UpdateGeometryOptions,
|
||||
Viewport,
|
||||
} from './types';
|
||||
import { isMismatched, updateObject } from './utils.svelte';
|
||||
|
||||
const {
|
||||
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
|
||||
} = TUNABLES;
|
||||
|
||||
export class AssetStore {
|
||||
isInitialized = $state(false);
|
||||
buckets: AssetBucket[] = $state([]);
|
||||
topSectionHeight = $state(0);
|
||||
timelineHeight = $derived(
|
||||
this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0) + this.topSectionHeight,
|
||||
);
|
||||
count = $derived(this.buckets.reduce((accumulator, b) => accumulator + b.bucketCount, 0));
|
||||
|
||||
albumAssets: Set<string> = new SvelteSet();
|
||||
|
||||
scrubberBuckets: LiteBucket[] = $state([]);
|
||||
scrubberTimelineHeight: number = $state(0);
|
||||
|
||||
topIntersectingBucket: AssetBucket | undefined = $state();
|
||||
|
||||
visibleWindow = $derived.by(() => ({
|
||||
top: this.#scrollTop,
|
||||
bottom: this.#scrollTop + this.viewportHeight,
|
||||
}));
|
||||
|
||||
initTask = new CancellableTask(
|
||||
() => {
|
||||
this.isInitialized = true;
|
||||
if (this.#options.albumId || this.#options.personId) {
|
||||
return;
|
||||
}
|
||||
this.connect();
|
||||
},
|
||||
() => {
|
||||
this.disconnect();
|
||||
this.isInitialized = false;
|
||||
},
|
||||
() => void 0,
|
||||
);
|
||||
|
||||
static #INIT_OPTIONS = {};
|
||||
#viewportHeight = $state(0);
|
||||
#viewportWidth = $state(0);
|
||||
#scrollTop = $state(0);
|
||||
#pendingChanges: PendingChange[] = [];
|
||||
#unsubscribers: Unsubscriber[] = [];
|
||||
|
||||
#rowHeight = $state(235);
|
||||
#headerHeight = $state(48);
|
||||
#gap = $state(12);
|
||||
|
||||
#options: AssetStoreOptions = AssetStore.#INIT_OPTIONS;
|
||||
|
||||
#scrolling = $state(false);
|
||||
#suspendTransitions = $state(false);
|
||||
#resetScrolling = debounce(() => (this.#scrolling = false), 1000);
|
||||
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
|
||||
scrollCompensation: {
|
||||
heightDelta: number | undefined;
|
||||
scrollTop: number | undefined;
|
||||
bucket: AssetBucket | undefined;
|
||||
} = $state({
|
||||
heightDelta: 0,
|
||||
scrollTop: 0,
|
||||
bucket: undefined,
|
||||
});
|
||||
|
||||
constructor() {}
|
||||
|
||||
setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }: AssetStoreLayoutOptions) {
|
||||
let changed = false;
|
||||
changed ||= this.#setHeaderHeight(headerHeight);
|
||||
changed ||= this.#setGap(gap);
|
||||
changed ||= this.#setRowHeight(rowHeight);
|
||||
if (changed) {
|
||||
this.refreshLayout();
|
||||
}
|
||||
}
|
||||
|
||||
#setHeaderHeight(value: number) {
|
||||
if (this.#headerHeight == value) {
|
||||
return false;
|
||||
}
|
||||
this.#headerHeight = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
get headerHeight() {
|
||||
return this.#headerHeight;
|
||||
}
|
||||
|
||||
#setGap(value: number) {
|
||||
if (this.#gap == value) {
|
||||
return false;
|
||||
}
|
||||
this.#gap = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
get gap() {
|
||||
return this.#gap;
|
||||
}
|
||||
|
||||
#setRowHeight(value: number) {
|
||||
if (this.#rowHeight == value) {
|
||||
return false;
|
||||
}
|
||||
this.#rowHeight = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
get rowHeight() {
|
||||
return this.#rowHeight;
|
||||
}
|
||||
|
||||
set scrolling(value: boolean) {
|
||||
this.#scrolling = value;
|
||||
if (value) {
|
||||
this.suspendTransitions = true;
|
||||
this.#resetScrolling();
|
||||
}
|
||||
}
|
||||
|
||||
get scrolling() {
|
||||
return this.#scrolling;
|
||||
}
|
||||
|
||||
set suspendTransitions(value: boolean) {
|
||||
this.#suspendTransitions = value;
|
||||
if (value) {
|
||||
this.#resetSuspendTransitions();
|
||||
}
|
||||
}
|
||||
|
||||
get suspendTransitions() {
|
||||
return this.#suspendTransitions;
|
||||
}
|
||||
|
||||
set viewportWidth(value: number) {
|
||||
const changed = value !== this.#viewportWidth;
|
||||
this.#viewportWidth = value;
|
||||
this.suspendTransitions = true;
|
||||
void this.#updateViewportGeometry(changed);
|
||||
}
|
||||
|
||||
get viewportWidth() {
|
||||
return this.#viewportWidth;
|
||||
}
|
||||
|
||||
set viewportHeight(value: number) {
|
||||
this.#viewportHeight = value;
|
||||
this.#suspendTransitions = true;
|
||||
void this.#updateViewportGeometry(false);
|
||||
}
|
||||
|
||||
get viewportHeight() {
|
||||
return this.#viewportHeight;
|
||||
}
|
||||
|
||||
async *assetsIterator(options?: {
|
||||
startBucket?: AssetBucket;
|
||||
startDateGroup?: AssetDateGroup;
|
||||
startAsset?: TimelineAsset;
|
||||
direction?: Direction;
|
||||
}) {
|
||||
const direction = options?.direction ?? 'earlier';
|
||||
let { startDateGroup, startAsset } = options ?? {};
|
||||
for (const bucket of this.bucketsIterator({ direction, startBucket: options?.startBucket })) {
|
||||
await this.loadBucket(bucket.yearMonth, { cancelable: false });
|
||||
yield* bucket.assetsIterator({ startDateGroup, startAsset, direction });
|
||||
startDateGroup = startAsset = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
*bucketsIterator(options?: { direction?: Direction; startBucket?: AssetBucket }) {
|
||||
const isEarlier = options?.direction === 'earlier';
|
||||
let startIndex = options?.startBucket
|
||||
? this.buckets.indexOf(options.startBucket)
|
||||
: isEarlier
|
||||
? 0
|
||||
: this.buckets.length - 1;
|
||||
|
||||
while (startIndex >= 0 && startIndex < this.buckets.length) {
|
||||
yield this.buckets[startIndex];
|
||||
startIndex += isEarlier ? 1 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
#addPendingChanges(...changes: PendingChange[]) {
|
||||
this.#pendingChanges.push(...changes);
|
||||
this.#processPendingChanges();
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.#unsubscribers.push(
|
||||
websocketEvents.on('on_upload_success', (asset) =>
|
||||
this.#addPendingChanges({ type: 'add', values: [toTimelineAsset(asset)] }),
|
||||
),
|
||||
websocketEvents.on('on_asset_trash', (ids) => this.#addPendingChanges({ type: 'trash', values: ids })),
|
||||
websocketEvents.on('on_asset_update', (asset) =>
|
||||
this.#addPendingChanges({ type: 'update', values: [toTimelineAsset(asset)] }),
|
||||
),
|
||||
websocketEvents.on('on_asset_delete', (id: string) => this.#addPendingChanges({ type: 'delete', values: [id] })),
|
||||
);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
for (const unsubscribe of this.#unsubscribers) {
|
||||
unsubscribe();
|
||||
}
|
||||
this.#unsubscribers = [];
|
||||
}
|
||||
|
||||
#getPendingChangeBatches() {
|
||||
const batch: {
|
||||
add: TimelineAsset[];
|
||||
update: TimelineAsset[];
|
||||
remove: string[];
|
||||
} = {
|
||||
add: [],
|
||||
update: [],
|
||||
remove: [],
|
||||
};
|
||||
for (const { type, values } of this.#pendingChanges) {
|
||||
switch (type) {
|
||||
case 'add': {
|
||||
batch.add.push(...values);
|
||||
break;
|
||||
}
|
||||
case 'update': {
|
||||
batch.update.push(...values);
|
||||
break;
|
||||
}
|
||||
case 'delete':
|
||||
case 'trash': {
|
||||
batch.remove.push(...values);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return batch;
|
||||
}
|
||||
|
||||
#findBucketForAsset(id: string) {
|
||||
for (const bucket of this.buckets) {
|
||||
const asset = bucket.findAssetById({ id });
|
||||
if (asset) {
|
||||
return { bucket, asset };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#findBucketForDate(targetYearMonth: TimelinePlainYearMonth) {
|
||||
for (const bucket of this.buckets) {
|
||||
const { year, month } = bucket.yearMonth;
|
||||
if (month === targetYearMonth.month && year === targetYearMonth.year) {
|
||||
return bucket;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateSlidingWindow(scrollTop: number) {
|
||||
if (this.#scrollTop !== scrollTop) {
|
||||
this.#scrollTop = scrollTop;
|
||||
this.updateIntersections();
|
||||
}
|
||||
}
|
||||
|
||||
clearScrollCompensation() {
|
||||
this.scrollCompensation = {
|
||||
heightDelta: undefined,
|
||||
scrollTop: undefined,
|
||||
bucket: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
updateIntersections() {
|
||||
if (!this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
|
||||
return;
|
||||
}
|
||||
let topIntersectingBucket = undefined;
|
||||
for (const bucket of this.buckets) {
|
||||
this.#updateIntersection(bucket);
|
||||
if (!topIntersectingBucket && bucket.actuallyIntersecting) {
|
||||
topIntersectingBucket = bucket;
|
||||
}
|
||||
}
|
||||
if (topIntersectingBucket !== undefined && this.topIntersectingBucket !== topIntersectingBucket) {
|
||||
this.topIntersectingBucket = topIntersectingBucket;
|
||||
}
|
||||
for (const bucket of this.buckets) {
|
||||
if (bucket === this.topIntersectingBucket) {
|
||||
this.topIntersectingBucket.percent = clamp(
|
||||
(this.visibleWindow.top - this.topIntersectingBucket.top) / this.topIntersectingBucket.bucketHeight,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
} else {
|
||||
bucket.percent = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#calculateIntersecting(bucket: AssetBucket, expandTop: number, expandBottom: number) {
|
||||
const bucketTop = bucket.top;
|
||||
const bucketBottom = bucketTop + bucket.bucketHeight;
|
||||
const topWindow = this.visibleWindow.top - expandTop;
|
||||
const bottomWindow = this.visibleWindow.bottom + expandBottom;
|
||||
|
||||
return (
|
||||
(bucketTop >= topWindow && bucketTop < bottomWindow) ||
|
||||
(bucketBottom >= topWindow && bucketBottom < bottomWindow) ||
|
||||
(bucketTop < topWindow && bucketBottom >= bottomWindow)
|
||||
);
|
||||
}
|
||||
|
||||
clearDeferredLayout(bucket: AssetBucket) {
|
||||
const hasDeferred = bucket.dateGroups.some((group) => group.deferredLayout);
|
||||
if (hasDeferred) {
|
||||
this.#updateGeometry(bucket, { invalidateHeight: true, noDefer: true });
|
||||
for (const group of bucket.dateGroups) {
|
||||
group.deferredLayout = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#updateIntersection(bucket: AssetBucket) {
|
||||
const actuallyIntersecting = this.#calculateIntersecting(bucket, 0, 0);
|
||||
let preIntersecting = false;
|
||||
if (!actuallyIntersecting) {
|
||||
preIntersecting = this.#calculateIntersecting(bucket, INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM);
|
||||
}
|
||||
bucket.intersecting = actuallyIntersecting || preIntersecting;
|
||||
bucket.actuallyIntersecting = actuallyIntersecting;
|
||||
if (preIntersecting || actuallyIntersecting) {
|
||||
this.clearDeferredLayout(bucket);
|
||||
}
|
||||
}
|
||||
|
||||
#processPendingChanges = throttle(() => {
|
||||
const { add, update, remove } = this.#getPendingChangeBatches();
|
||||
if (add.length > 0) {
|
||||
this.addAssets(add);
|
||||
}
|
||||
if (update.length > 0) {
|
||||
this.updateAssets(update);
|
||||
}
|
||||
if (remove.length > 0) {
|
||||
this.removeAssets(remove);
|
||||
}
|
||||
this.#pendingChanges = [];
|
||||
}, 2500);
|
||||
|
||||
async #initializeTimeBuckets() {
|
||||
const timebuckets = await getTimeBuckets({
|
||||
...this.#options,
|
||||
key: authManager.key,
|
||||
});
|
||||
|
||||
this.buckets = timebuckets.map((bucket) => {
|
||||
const date = new Date(bucket.timeBucket);
|
||||
return new AssetBucket(
|
||||
this,
|
||||
{ year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 },
|
||||
bucket.count,
|
||||
this.#options.order,
|
||||
);
|
||||
});
|
||||
this.albumAssets.clear();
|
||||
this.#updateViewportGeometry(false);
|
||||
}
|
||||
|
||||
async updateOptions(options: AssetStoreOptions) {
|
||||
if (options.deferInit) {
|
||||
return;
|
||||
}
|
||||
if (this.#options !== AssetStore.#INIT_OPTIONS && isEqual(this.#options, options)) {
|
||||
return;
|
||||
}
|
||||
await this.initTask.reset();
|
||||
await this.#init(options);
|
||||
this.#updateViewportGeometry(false);
|
||||
}
|
||||
|
||||
async #init(options: AssetStoreOptions) {
|
||||
this.isInitialized = false;
|
||||
this.buckets = [];
|
||||
this.albumAssets.clear();
|
||||
await this.initTask.execute(async () => {
|
||||
this.#options = options;
|
||||
await this.#initializeTimeBuckets();
|
||||
}, true);
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.disconnect();
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
async updateViewport(viewport: Viewport) {
|
||||
if (viewport.height === 0 && viewport.width === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.viewportHeight === viewport.height && this.viewportWidth === viewport.width) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.initTask.executed) {
|
||||
await (this.initTask.loading ? this.initTask.waitUntilCompletion() : this.#init(this.#options));
|
||||
}
|
||||
|
||||
const changedWidth = viewport.width !== this.viewportWidth;
|
||||
this.viewportHeight = viewport.height;
|
||||
this.viewportWidth = viewport.width;
|
||||
this.#updateViewportGeometry(changedWidth);
|
||||
}
|
||||
|
||||
#updateViewportGeometry(changedWidth: boolean) {
|
||||
if (!this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
if (this.viewportWidth === 0 || this.viewportHeight === 0) {
|
||||
return;
|
||||
}
|
||||
for (const bucket of this.buckets) {
|
||||
this.#updateGeometry(bucket, { invalidateHeight: changedWidth });
|
||||
}
|
||||
this.updateIntersections();
|
||||
this.#createScrubBuckets();
|
||||
}
|
||||
|
||||
#createScrubBuckets() {
|
||||
this.scrubberBuckets = this.buckets.map((bucket) => ({
|
||||
assetCount: bucket.bucketCount,
|
||||
year: bucket.yearMonth.year,
|
||||
month: bucket.yearMonth.month,
|
||||
bucketDateFormattted: bucket.bucketDateFormatted,
|
||||
bucketHeight: bucket.bucketHeight,
|
||||
}));
|
||||
this.scrubberTimelineHeight = this.timelineHeight;
|
||||
}
|
||||
|
||||
createLayoutOptions() {
|
||||
const viewportWidth = this.viewportWidth;
|
||||
|
||||
return {
|
||||
spacing: 2,
|
||||
heightTolerance: 0.15,
|
||||
rowHeight: this.#rowHeight,
|
||||
rowWidth: Math.floor(viewportWidth),
|
||||
};
|
||||
}
|
||||
|
||||
#updateGeometry(bucket: AssetBucket, options: UpdateGeometryOptions) {
|
||||
const { invalidateHeight, noDefer = false } = options;
|
||||
if (invalidateHeight) {
|
||||
bucket.isBucketHeightActual = false;
|
||||
}
|
||||
if (!bucket.isLoaded) {
|
||||
const viewportWidth = this.viewportWidth;
|
||||
if (!bucket.isBucketHeightActual) {
|
||||
const unwrappedWidth = (3 / 2) * bucket.bucketCount * this.#rowHeight * (7 / 10);
|
||||
const rows = Math.ceil(unwrappedWidth / viewportWidth);
|
||||
const height = 51 + Math.max(1, rows) * this.#rowHeight;
|
||||
bucket.bucketHeight = height;
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.#layoutBucket(bucket, noDefer);
|
||||
}
|
||||
|
||||
#layoutBucket(bucket: AssetBucket, noDefer: boolean = false) {
|
||||
let cummulativeHeight = 0;
|
||||
let cummulativeWidth = 0;
|
||||
let lastRowHeight = 0;
|
||||
let lastRow = 0;
|
||||
|
||||
let dateGroupRow = 0;
|
||||
let dateGroupCol = 0;
|
||||
|
||||
const rowSpaceRemaining: number[] = Array.from({ length: bucket.dateGroups.length });
|
||||
rowSpaceRemaining.fill(this.viewportWidth, 0, bucket.dateGroups.length);
|
||||
const options = this.createLayoutOptions();
|
||||
for (const assetGroup of bucket.dateGroups) {
|
||||
assetGroup.layout(options, noDefer);
|
||||
rowSpaceRemaining[dateGroupRow] -= assetGroup.width - 1;
|
||||
if (dateGroupCol > 0) {
|
||||
rowSpaceRemaining[dateGroupRow] -= this.gap;
|
||||
}
|
||||
if (rowSpaceRemaining[dateGroupRow] >= 0) {
|
||||
assetGroup.row = dateGroupRow;
|
||||
assetGroup.col = dateGroupCol;
|
||||
assetGroup.left = cummulativeWidth;
|
||||
assetGroup.top = cummulativeHeight;
|
||||
|
||||
dateGroupCol++;
|
||||
|
||||
cummulativeWidth += assetGroup.width + this.gap;
|
||||
} else {
|
||||
cummulativeWidth = 0;
|
||||
dateGroupRow++;
|
||||
dateGroupCol = 0;
|
||||
assetGroup.row = dateGroupRow;
|
||||
assetGroup.col = dateGroupCol;
|
||||
assetGroup.left = cummulativeWidth;
|
||||
|
||||
rowSpaceRemaining[dateGroupRow] -= assetGroup.width;
|
||||
dateGroupCol++;
|
||||
cummulativeHeight += lastRowHeight;
|
||||
assetGroup.top = cummulativeHeight;
|
||||
cummulativeWidth += assetGroup.width + this.gap;
|
||||
lastRow = assetGroup.row - 1;
|
||||
}
|
||||
lastRowHeight = assetGroup.height + this.headerHeight;
|
||||
}
|
||||
if (lastRow === 0 || lastRow !== bucket.lastDateGroup?.row) {
|
||||
cummulativeHeight += lastRowHeight;
|
||||
}
|
||||
|
||||
bucket.bucketHeight = cummulativeHeight;
|
||||
bucket.isBucketHeightActual = true;
|
||||
}
|
||||
|
||||
async loadBucket(yearMonth: TimelinePlainYearMonth, options?: { cancelable: boolean }): Promise<void> {
|
||||
let cancelable = true;
|
||||
if (options) {
|
||||
cancelable = options.cancelable;
|
||||
}
|
||||
const bucket = this.getBucketByDate(yearMonth);
|
||||
if (!bucket) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (bucket.loader?.executed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await bucket.loader?.execute(async (signal: AbortSignal) => {
|
||||
if (bucket.getFirstAsset()) {
|
||||
return;
|
||||
}
|
||||
const timeBucket = toISOYearMonthUTC(bucket.yearMonth);
|
||||
const key = authManager.key;
|
||||
const bucketResponse = await getTimeBucket(
|
||||
{
|
||||
...this.#options,
|
||||
timeBucket,
|
||||
key,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
if (bucketResponse) {
|
||||
if (this.#options.timelineAlbumId) {
|
||||
const albumAssets = await getTimeBucket(
|
||||
{
|
||||
albumId: this.#options.timelineAlbumId,
|
||||
timeBucket,
|
||||
key,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
for (const id of albumAssets.id) {
|
||||
this.albumAssets.add(id);
|
||||
}
|
||||
}
|
||||
const unprocessedAssets = bucket.addAssets(bucketResponse);
|
||||
if (unprocessedAssets.length > 0) {
|
||||
console.error(
|
||||
`Warning: getTimeBucket API returning assets not in requested month: ${bucket.yearMonth.month}, ${JSON.stringify(
|
||||
unprocessedAssets.map((unprocessed) => ({
|
||||
id: unprocessed.id,
|
||||
localDateTime: unprocessed.localDateTime,
|
||||
})),
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
this.#layoutBucket(bucket);
|
||||
}
|
||||
}, cancelable);
|
||||
if (result === 'LOADED') {
|
||||
this.#updateIntersection(bucket);
|
||||
}
|
||||
}
|
||||
|
||||
addAssets(assets: TimelineAsset[]) {
|
||||
const assetsToUpdate: TimelineAsset[] = [];
|
||||
|
||||
for (const asset of assets) {
|
||||
if (this.isExcluded(asset)) {
|
||||
continue;
|
||||
}
|
||||
assetsToUpdate.push(asset);
|
||||
}
|
||||
|
||||
const notUpdated = this.updateAssets(assetsToUpdate);
|
||||
this.#addAssetsToBuckets([...notUpdated]);
|
||||
}
|
||||
|
||||
#addAssetsToBuckets(assets: TimelineAsset[]) {
|
||||
if (assets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const addContext = new AddContext();
|
||||
const updatedBuckets = new Set<AssetBucket>();
|
||||
const bucketCount = this.buckets.length;
|
||||
for (const asset of assets) {
|
||||
let bucket = this.getBucketByDate(asset.localDateTime);
|
||||
|
||||
if (!bucket) {
|
||||
bucket = new AssetBucket(this, asset.localDateTime, 1, this.#options.order);
|
||||
bucket.isLoaded = true;
|
||||
this.buckets.push(bucket);
|
||||
}
|
||||
|
||||
bucket.addTimelineAsset(asset, addContext);
|
||||
updatedBuckets.add(bucket);
|
||||
}
|
||||
|
||||
if (this.buckets.length !== bucketCount) {
|
||||
this.buckets.sort((a, b) => {
|
||||
return a.yearMonth.year === b.yearMonth.year
|
||||
? b.yearMonth.month - a.yearMonth.month
|
||||
: b.yearMonth.year - a.yearMonth.year;
|
||||
});
|
||||
}
|
||||
|
||||
for (const group of addContext.existingDateGroups) {
|
||||
group.sortAssets(this.#options.order);
|
||||
}
|
||||
|
||||
for (const bucket of addContext.bucketsWithNewDateGroups) {
|
||||
bucket.sortDateGroups();
|
||||
}
|
||||
|
||||
for (const bucket of addContext.updatedBuckets) {
|
||||
bucket.sortDateGroups();
|
||||
this.#updateGeometry(bucket, { invalidateHeight: true });
|
||||
}
|
||||
this.updateIntersections();
|
||||
}
|
||||
|
||||
getBucketByDate(targetYearMonth: TimelinePlainYearMonth): AssetBucket | undefined {
|
||||
return this.buckets.find(
|
||||
(bucket) => bucket.yearMonth.year === targetYearMonth.year && bucket.yearMonth.month === targetYearMonth.month,
|
||||
);
|
||||
}
|
||||
|
||||
async findBucketForAsset(id: string) {
|
||||
if (!this.isInitialized) {
|
||||
await this.initTask.waitUntilCompletion();
|
||||
}
|
||||
let { bucket } = this.#findBucketForAsset(id) ?? {};
|
||||
if (bucket) {
|
||||
return bucket;
|
||||
}
|
||||
const asset = toTimelineAsset(await getAssetInfo({ id, key: authManager.key }));
|
||||
if (!asset || this.isExcluded(asset)) {
|
||||
return;
|
||||
}
|
||||
bucket = await this.#loadBucketAtTime(asset.localDateTime, { cancelable: false });
|
||||
if (bucket?.findAssetById({ id })) {
|
||||
return bucket;
|
||||
}
|
||||
}
|
||||
|
||||
async #loadBucketAtTime(yearMonth: TimelinePlainYearMonth, options?: { cancelable: boolean }) {
|
||||
await this.loadBucket(yearMonth, options);
|
||||
return this.getBucketByDate(yearMonth);
|
||||
}
|
||||
|
||||
getBucketIndexByAssetId(assetId: string) {
|
||||
const bucketInfo = this.#findBucketForAsset(assetId);
|
||||
return bucketInfo?.bucket;
|
||||
}
|
||||
|
||||
async getRandomBucket() {
|
||||
const random = Math.floor(Math.random() * this.buckets.length);
|
||||
const bucket = this.buckets[random];
|
||||
await this.loadBucket(bucket.yearMonth, { cancelable: false });
|
||||
return bucket;
|
||||
}
|
||||
|
||||
async getRandomAsset() {
|
||||
const bucket = await this.getRandomBucket();
|
||||
return bucket?.getRandomAsset();
|
||||
}
|
||||
|
||||
#runAssetOperation(ids: Set<string>, operation: AssetOperation) {
|
||||
if (ids.size === 0) {
|
||||
return { processedIds: new Set(), unprocessedIds: ids, changedGeometry: false };
|
||||
}
|
||||
|
||||
const changedBuckets = new Set<AssetBucket>();
|
||||
let idsToProcess = new Set(ids);
|
||||
const idsProcessed = new Set<string>();
|
||||
const combinedMoveAssets: { asset: TimelineAsset; date: TimelinePlainDate }[][] = [];
|
||||
for (const bucket of this.buckets) {
|
||||
if (idsToProcess.size > 0) {
|
||||
const { moveAssets, processedIds, changedGeometry } = bucket.runAssetOperation(idsToProcess, operation);
|
||||
if (moveAssets.length > 0) {
|
||||
combinedMoveAssets.push(moveAssets);
|
||||
}
|
||||
idsToProcess = idsToProcess.difference(processedIds);
|
||||
for (const id of processedIds) {
|
||||
idsProcessed.add(id);
|
||||
}
|
||||
if (changedGeometry) {
|
||||
changedBuckets.add(bucket);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (combinedMoveAssets.length > 0) {
|
||||
this.#addAssetsToBuckets(combinedMoveAssets.flat().map((a) => a.asset));
|
||||
}
|
||||
const changedGeometry = changedBuckets.size > 0;
|
||||
for (const bucket of changedBuckets) {
|
||||
this.#updateGeometry(bucket, { invalidateHeight: true });
|
||||
}
|
||||
if (changedGeometry) {
|
||||
this.updateIntersections();
|
||||
}
|
||||
return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
|
||||
}
|
||||
|
||||
updateAssetOperation(ids: string[], operation: AssetOperation) {
|
||||
this.#runAssetOperation(new Set(ids), operation);
|
||||
}
|
||||
|
||||
updateAssets(assets: TimelineAsset[]) {
|
||||
const lookup = new Map<string, TimelineAsset>(assets.map((asset) => [asset.id, asset]));
|
||||
const { unprocessedIds } = this.#runAssetOperation(new Set(lookup.keys()), (asset) => {
|
||||
updateObject(asset, lookup.get(asset.id));
|
||||
return { remove: false };
|
||||
});
|
||||
return unprocessedIds.values().map((id) => lookup.get(id)!);
|
||||
}
|
||||
|
||||
removeAssets(ids: string[]) {
|
||||
const { unprocessedIds } = this.#runAssetOperation(new Set(ids), () => {
|
||||
return { remove: true };
|
||||
});
|
||||
return [...unprocessedIds];
|
||||
}
|
||||
|
||||
refreshLayout() {
|
||||
for (const bucket of this.buckets) {
|
||||
this.#updateGeometry(bucket, { invalidateHeight: true });
|
||||
}
|
||||
this.updateIntersections();
|
||||
}
|
||||
|
||||
getFirstAsset(): TimelineAsset | undefined {
|
||||
return this.buckets[0]?.getFirstAsset();
|
||||
}
|
||||
|
||||
async getLaterAsset(
|
||||
assetDescriptor: AssetDescriptor,
|
||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||
): Promise<TimelineAsset | undefined> {
|
||||
return await this.#getAssetWithOffset(assetDescriptor, interval, 'later');
|
||||
}
|
||||
|
||||
async getEarlierAsset(
|
||||
assetDescriptor: AssetDescriptor,
|
||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||
): Promise<TimelineAsset | undefined> {
|
||||
return await this.#getAssetWithOffset(assetDescriptor, interval, 'earlier');
|
||||
}
|
||||
|
||||
async getClosestAssetToDate(dateTime: TimelinePlainDateTime) {
|
||||
const bucket = this.#findBucketForDate(dateTime);
|
||||
if (!bucket) {
|
||||
return;
|
||||
}
|
||||
await this.loadBucket(dateTime, { cancelable: false });
|
||||
const asset = bucket.findClosest(dateTime);
|
||||
if (asset) {
|
||||
return asset;
|
||||
}
|
||||
for await (const asset of this.assetsIterator({ startBucket: bucket })) {
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
|
||||
async retrieveRange(start: AssetDescriptor, end: AssetDescriptor) {
|
||||
let { asset: startAsset, bucket: startBucket } = this.#findBucketForAsset(start.id) ?? {};
|
||||
if (!startBucket || !startAsset) {
|
||||
return [];
|
||||
}
|
||||
let { asset: endAsset, bucket: endBucket } = this.#findBucketForAsset(end.id) ?? {};
|
||||
if (!endBucket || !endAsset) {
|
||||
return [];
|
||||
}
|
||||
let direction: Direction = 'earlier';
|
||||
if (plainDateTimeCompare(true, startAsset.localDateTime, endAsset.localDateTime) < 0) {
|
||||
[startAsset, endAsset] = [endAsset, startAsset];
|
||||
[startBucket, endBucket] = [endBucket, startBucket];
|
||||
direction = 'earlier';
|
||||
}
|
||||
|
||||
const range: TimelineAsset[] = [];
|
||||
const startDateGroup = startBucket.findDateGroupForAsset(startAsset);
|
||||
for await (const targetAsset of this.assetsIterator({
|
||||
startBucket,
|
||||
startDateGroup,
|
||||
startAsset,
|
||||
direction,
|
||||
})) {
|
||||
range.push(targetAsset);
|
||||
if (targetAsset.id === endAsset.id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return range;
|
||||
}
|
||||
|
||||
async #getAssetWithOffset(
|
||||
assetDescriptor: AssetDescriptor,
|
||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||
direction: Direction,
|
||||
): Promise<TimelineAsset | undefined> {
|
||||
const { asset, bucket } = this.#findBucketForAsset(assetDescriptor.id) ?? {};
|
||||
if (!bucket || !asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (interval) {
|
||||
case 'asset': {
|
||||
return this.#getAssetByAssetOffset(asset, bucket, direction);
|
||||
}
|
||||
case 'day': {
|
||||
return this.#getAssetByDayOffset(asset, bucket, direction);
|
||||
}
|
||||
case 'month': {
|
||||
return this.#getAssetByMonthOffset(bucket, direction);
|
||||
}
|
||||
case 'year': {
|
||||
return this.#getAssetByYearOffset(bucket, direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #getAssetByAssetOffset(asset: TimelineAsset, bucket: AssetBucket, direction: Direction) {
|
||||
const dateGroup = bucket.findDateGroupForAsset(asset);
|
||||
for await (const targetAsset of this.assetsIterator({
|
||||
startBucket: bucket,
|
||||
startDateGroup: dateGroup,
|
||||
startAsset: asset,
|
||||
direction,
|
||||
})) {
|
||||
if (asset.id === targetAsset.id) {
|
||||
continue;
|
||||
}
|
||||
return targetAsset;
|
||||
}
|
||||
}
|
||||
|
||||
async #getAssetByDayOffset(asset: TimelineAsset, bucket: AssetBucket, direction: Direction) {
|
||||
const dateGroup = bucket.findDateGroupForAsset(asset);
|
||||
for await (const targetAsset of this.assetsIterator({
|
||||
startBucket: bucket,
|
||||
startDateGroup: dateGroup,
|
||||
startAsset: asset,
|
||||
direction,
|
||||
})) {
|
||||
if (targetAsset.localDateTime.day !== asset.localDateTime.day) {
|
||||
return targetAsset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #getAssetByMonthOffset(bucket: AssetBucket, direction: Direction) {
|
||||
for (const targetBucket of this.bucketsIterator({ startBucket: bucket, direction })) {
|
||||
if (targetBucket.yearMonth.month !== bucket.yearMonth.month) {
|
||||
for await (const targetAsset of this.assetsIterator({ startBucket: targetBucket, direction })) {
|
||||
return targetAsset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #getAssetByYearOffset(bucket: AssetBucket, direction: Direction) {
|
||||
for (const targetBucket of this.bucketsIterator({ startBucket: bucket, direction })) {
|
||||
if (targetBucket.yearMonth.year !== bucket.yearMonth.year) {
|
||||
for await (const targetAsset of this.assetsIterator({ startBucket: targetBucket, direction })) {
|
||||
return targetAsset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isExcluded(asset: TimelineAsset) {
|
||||
return (
|
||||
isMismatched(this.#options.visibility, asset.visibility) ||
|
||||
isMismatched(this.#options.isFavorite, asset.isFavorite) ||
|
||||
isMismatched(this.#options.isTrashed, asset.isTrashed)
|
||||
);
|
||||
}
|
||||
}
|
||||
+32
-30
@@ -1,21 +1,23 @@
|
||||
import { AssetOrder } from '@immich/sdk';
|
||||
|
||||
import type { CommonLayoutOptions } from '$lib/utils/layout-utils';
|
||||
import { getJustifiedLayoutFromAssets, getPosition } from '$lib/utils/layout-utils';
|
||||
import { plainDateTimeCompare } from '$lib/utils/timeline-util';
|
||||
import { AssetOrder } from '@immich/sdk';
|
||||
import type { AssetBucket } from './asset-bucket.svelte';
|
||||
import { IntersectingAsset } from './intersecting-asset.svelte';
|
||||
import type { AssetOperation, Direction, MoveAsset, TimelineAsset } from './types';
|
||||
|
||||
export class AssetDateGroup {
|
||||
readonly bucket: AssetBucket;
|
||||
import type { MonthGroup } from './month-group.svelte';
|
||||
import type { AssetOperation, Direction, MoveAsset, TimelineAsset } from './types';
|
||||
import { ViewerAsset } from './viewer-asset.svelte';
|
||||
|
||||
export class DayGroup {
|
||||
readonly monthGroup: MonthGroup;
|
||||
readonly index: number;
|
||||
readonly groupTitle: string;
|
||||
readonly day: number;
|
||||
intersectingAssets: IntersectingAsset[] = $state([]);
|
||||
viewerAssets: ViewerAsset[] = $state([]);
|
||||
|
||||
height = $state(0);
|
||||
width = $state(0);
|
||||
intersecting = $derived.by(() => this.intersectingAssets.some((asset) => asset.intersecting));
|
||||
intersecting = $derived.by(() => this.viewerAssets.some((viewAsset) => viewAsset.intersecting));
|
||||
|
||||
#top: number = $state(0);
|
||||
#left: number = $state(0);
|
||||
@@ -23,9 +25,9 @@ export class AssetDateGroup {
|
||||
#col = $state(0);
|
||||
#deferredLayout = false;
|
||||
|
||||
constructor(bucket: AssetBucket, index: number, day: number, groupTitle: string) {
|
||||
constructor(monthGroup: MonthGroup, index: number, day: number, groupTitle: string) {
|
||||
this.index = index;
|
||||
this.bucket = bucket;
|
||||
this.monthGroup = monthGroup;
|
||||
this.day = day;
|
||||
this.groupTitle = groupTitle;
|
||||
}
|
||||
@@ -72,35 +74,35 @@ export class AssetDateGroup {
|
||||
|
||||
sortAssets(sortOrder: AssetOrder = AssetOrder.Desc) {
|
||||
const sortFn = plainDateTimeCompare.bind(undefined, sortOrder === AssetOrder.Asc);
|
||||
this.intersectingAssets.sort((a, b) => sortFn(a.asset.fileCreatedAt, b.asset.fileCreatedAt));
|
||||
this.viewerAssets.sort((a, b) => sortFn(a.asset.fileCreatedAt, b.asset.fileCreatedAt));
|
||||
}
|
||||
|
||||
getFirstAsset() {
|
||||
return this.intersectingAssets[0]?.asset;
|
||||
return this.viewerAssets[0]?.asset;
|
||||
}
|
||||
|
||||
getRandomAsset() {
|
||||
const random = Math.floor(Math.random() * this.intersectingAssets.length);
|
||||
return this.intersectingAssets[random];
|
||||
const random = Math.floor(Math.random() * this.viewerAssets.length);
|
||||
return this.viewerAssets[random];
|
||||
}
|
||||
|
||||
*assetsIterator(options: { startAsset?: TimelineAsset; direction?: Direction } = {}) {
|
||||
const isEarlier = (options?.direction ?? 'earlier') === 'earlier';
|
||||
let assetIndex = options?.startAsset
|
||||
? this.intersectingAssets.findIndex((intersectingAsset) => intersectingAsset.asset.id === options.startAsset!.id)
|
||||
? this.viewerAssets.findIndex((viewerAsset) => viewerAsset.asset.id === options.startAsset!.id)
|
||||
: isEarlier
|
||||
? 0
|
||||
: this.intersectingAssets.length - 1;
|
||||
: this.viewerAssets.length - 1;
|
||||
|
||||
while (assetIndex >= 0 && assetIndex < this.intersectingAssets.length) {
|
||||
const intersectingAsset = this.intersectingAssets[assetIndex];
|
||||
yield intersectingAsset.asset;
|
||||
while (assetIndex >= 0 && assetIndex < this.viewerAssets.length) {
|
||||
const viewerAsset = this.viewerAssets[assetIndex];
|
||||
yield viewerAsset.asset;
|
||||
assetIndex += isEarlier ? 1 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
getAssets() {
|
||||
return this.intersectingAssets.map((intersectingasset) => intersectingasset.asset);
|
||||
return this.viewerAssets.map((viewerAsset) => viewerAsset.asset);
|
||||
}
|
||||
|
||||
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
|
||||
@@ -117,12 +119,12 @@ export class AssetDateGroup {
|
||||
const moveAssets: MoveAsset[] = [];
|
||||
let changedGeometry = false;
|
||||
for (const assetId of unprocessedIds) {
|
||||
const index = this.intersectingAssets.findIndex((ia) => ia.id == assetId);
|
||||
const index = this.viewerAssets.findIndex((viewAsset) => viewAsset.id == assetId);
|
||||
if (index === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const asset = this.intersectingAssets[index].asset!;
|
||||
const asset = this.viewerAssets[index].asset!;
|
||||
const oldTime = { ...asset.localDateTime };
|
||||
let { remove } = operation(asset);
|
||||
const newTime = asset.localDateTime;
|
||||
@@ -133,8 +135,8 @@ export class AssetDateGroup {
|
||||
}
|
||||
unprocessedIds.delete(assetId);
|
||||
processedIds.add(assetId);
|
||||
if (remove || this.bucket.store.isExcluded(asset)) {
|
||||
this.intersectingAssets.splice(index, 1);
|
||||
if (remove || this.monthGroup.timelineManager.isExcluded(asset)) {
|
||||
this.viewerAssets.splice(index, 1);
|
||||
changedGeometry = true;
|
||||
}
|
||||
}
|
||||
@@ -142,21 +144,21 @@ export class AssetDateGroup {
|
||||
}
|
||||
|
||||
layout(options: CommonLayoutOptions, noDefer: boolean) {
|
||||
if (!noDefer && !this.bucket.intersecting) {
|
||||
if (!noDefer && !this.monthGroup.intersecting) {
|
||||
this.#deferredLayout = true;
|
||||
return;
|
||||
}
|
||||
const assets = this.intersectingAssets.map((intersetingAsset) => intersetingAsset.asset!);
|
||||
const assets = this.viewerAssets.map((viewerAsset) => viewerAsset.asset!);
|
||||
const geometry = getJustifiedLayoutFromAssets(assets, options);
|
||||
this.width = geometry.containerWidth;
|
||||
this.height = assets.length === 0 ? 0 : geometry.containerHeight;
|
||||
for (let i = 0; i < this.intersectingAssets.length; i++) {
|
||||
for (let i = 0; i < this.viewerAssets.length; i++) {
|
||||
const position = getPosition(geometry, i);
|
||||
this.intersectingAssets[i].position = position;
|
||||
this.viewerAssets[i].position = position;
|
||||
}
|
||||
}
|
||||
|
||||
get absoluteDateGroupTop() {
|
||||
return this.bucket.top + this.#top;
|
||||
get absoluteDayGroupTop() {
|
||||
return this.monthGroup.top + this.#top;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { setDifference, type TimelinePlainDate } from '$lib/utils/timeline-util';
|
||||
import { AssetOrder } from '@immich/sdk';
|
||||
import type { DayGroup } from './day-group.svelte';
|
||||
import type { MonthGroup } from './month-group.svelte';
|
||||
import type { TimelineAsset } from './types';
|
||||
|
||||
export class GroupInsertionCache {
|
||||
#lookupCache: {
|
||||
[year: number]: { [month: number]: { [day: number]: DayGroup } };
|
||||
} = {};
|
||||
unprocessedAssets: TimelineAsset[] = [];
|
||||
changedDayGroups = new Set<DayGroup>();
|
||||
newDayGroups = new Set<DayGroup>();
|
||||
|
||||
getDayGroup({ year, month, day }: TimelinePlainDate): DayGroup | undefined {
|
||||
return this.#lookupCache[year]?.[month]?.[day];
|
||||
}
|
||||
|
||||
setDayGroup(dayGroup: DayGroup, { year, month, day }: TimelinePlainDate) {
|
||||
if (!this.#lookupCache[year]) {
|
||||
this.#lookupCache[year] = {};
|
||||
}
|
||||
if (!this.#lookupCache[year][month]) {
|
||||
this.#lookupCache[year][month] = {};
|
||||
}
|
||||
this.#lookupCache[year][month][day] = dayGroup;
|
||||
}
|
||||
|
||||
get existingDayGroups() {
|
||||
return setDifference(this.changedDayGroups, this.newDayGroups);
|
||||
}
|
||||
|
||||
get updatedBuckets() {
|
||||
const updated = new Set<MonthGroup>();
|
||||
for (const group of this.changedDayGroups) {
|
||||
updated.add(group.monthGroup);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
get bucketsWithNewDayGroups() {
|
||||
const updated = new Set<MonthGroup>();
|
||||
for (const group of this.newDayGroups) {
|
||||
updated.add(group.monthGroup);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
sort(monthGroup: MonthGroup, sortOrder: AssetOrder = AssetOrder.Desc) {
|
||||
for (const group of this.changedDayGroups) {
|
||||
group.sortAssets(sortOrder);
|
||||
}
|
||||
for (const group of this.newDayGroups) {
|
||||
group.sortAssets(sortOrder);
|
||||
}
|
||||
if (this.newDayGroups.size > 0) {
|
||||
monthGroup.sortDayGroups();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import type { MonthGroup } from '../month-group.svelte';
|
||||
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||
|
||||
const {
|
||||
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
|
||||
} = TUNABLES;
|
||||
|
||||
export function updateIntersectionMonthGroup(timelineManager: TimelineManager, month: MonthGroup) {
|
||||
const actuallyIntersecting = calculateMonthGroupIntersecting(timelineManager, month, 0, 0);
|
||||
let preIntersecting = false;
|
||||
if (!actuallyIntersecting) {
|
||||
preIntersecting = calculateMonthGroupIntersecting(
|
||||
timelineManager,
|
||||
month,
|
||||
INTERSECTION_EXPAND_TOP,
|
||||
INTERSECTION_EXPAND_BOTTOM,
|
||||
);
|
||||
}
|
||||
month.intersecting = actuallyIntersecting || preIntersecting;
|
||||
month.actuallyIntersecting = actuallyIntersecting;
|
||||
if (preIntersecting || actuallyIntersecting) {
|
||||
timelineManager.clearDeferredLayout(month);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* General function to check if a rectangular region intersects with a window.
|
||||
* @param regionTop - Top position of the region to check
|
||||
* @param regionBottom - Bottom position of the region to check
|
||||
* @param windowTop - Top position of the window
|
||||
* @param windowBottom - Bottom position of the window
|
||||
* @returns true if the region intersects with the window
|
||||
*/
|
||||
export function isIntersecting(regionTop: number, regionBottom: number, windowTop: number, windowBottom: number) {
|
||||
return (
|
||||
(regionTop >= windowTop && regionTop < windowBottom) ||
|
||||
(regionBottom >= windowTop && regionBottom < windowBottom) ||
|
||||
(regionTop < windowTop && regionBottom >= windowBottom)
|
||||
);
|
||||
}
|
||||
|
||||
export function calculateMonthGroupIntersecting(
|
||||
timelineManager: TimelineManager,
|
||||
monthGroup: MonthGroup,
|
||||
expandTop: number,
|
||||
expandBottom: number,
|
||||
) {
|
||||
const monthGroupTop = monthGroup.top;
|
||||
const monthGroupBottom = monthGroupTop + monthGroup.height;
|
||||
const topWindow = timelineManager.visibleWindow.top - expandTop;
|
||||
const bottomWindow = timelineManager.visibleWindow.bottom + expandBottom;
|
||||
|
||||
return isIntersecting(monthGroupTop, monthGroupBottom, topWindow, bottomWindow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate intersection for viewer assets with additional parameters like header height and scroll compensation
|
||||
*/
|
||||
export function calculateViewerAssetIntersecting(
|
||||
timelineManager: TimelineManager,
|
||||
positionTop: number,
|
||||
positionHeight: number,
|
||||
expandTop: number = INTERSECTION_EXPAND_TOP,
|
||||
expandBottom: number = INTERSECTION_EXPAND_BOTTOM,
|
||||
) {
|
||||
const scrollCompensationHeightDelta = timelineManager.scrollCompensation?.heightDelta ?? 0;
|
||||
|
||||
const topWindow =
|
||||
timelineManager.visibleWindow.top - timelineManager.headerHeight - expandTop + scrollCompensationHeightDelta;
|
||||
const bottomWindow =
|
||||
timelineManager.visibleWindow.bottom + timelineManager.headerHeight + expandBottom + scrollCompensationHeightDelta;
|
||||
|
||||
const positionBottom = positionTop + positionHeight;
|
||||
|
||||
return isIntersecting(positionTop, positionBottom, topWindow, bottomWindow);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { MonthGroup } from '../month-group.svelte';
|
||||
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||
import type { UpdateGeometryOptions } from '../types';
|
||||
|
||||
export function updateGeometry(timelineManager: TimelineManager, month: MonthGroup, options: UpdateGeometryOptions) {
|
||||
const { invalidateHeight, noDefer = false } = options;
|
||||
if (invalidateHeight) {
|
||||
month.isHeightActual = false;
|
||||
}
|
||||
if (!month.isLoaded) {
|
||||
const viewportWidth = timelineManager.viewportWidth;
|
||||
if (!month.isHeightActual) {
|
||||
const unwrappedWidth = (3 / 2) * month.assetsCount * timelineManager.rowHeight * (7 / 10);
|
||||
const rows = Math.ceil(unwrappedWidth / viewportWidth);
|
||||
const height = 51 + Math.max(1, rows) * timelineManager.rowHeight;
|
||||
month.height = height;
|
||||
}
|
||||
return;
|
||||
}
|
||||
layoutMonthGroup(timelineManager, month, noDefer);
|
||||
}
|
||||
|
||||
export function layoutMonthGroup(timelineManager: TimelineManager, month: MonthGroup, noDefer: boolean = false) {
|
||||
let cumulativeHeight = 0;
|
||||
let cumulativeWidth = 0;
|
||||
let currentRowHeight = 0;
|
||||
|
||||
let dayGroupRow = 0;
|
||||
let dayGroupCol = 0;
|
||||
|
||||
const options = timelineManager.createLayoutOptions();
|
||||
for (const dayGroup of month.dayGroups) {
|
||||
dayGroup.layout(options, noDefer);
|
||||
|
||||
// Calculate space needed for this item (including gap if not first in row)
|
||||
const spaceNeeded = dayGroup.width + (dayGroupCol > 0 ? timelineManager.gap : 0);
|
||||
const fitsInCurrentRow = cumulativeWidth + spaceNeeded <= timelineManager.viewportWidth;
|
||||
|
||||
if (fitsInCurrentRow) {
|
||||
dayGroup.row = dayGroupRow;
|
||||
dayGroup.col = dayGroupCol++;
|
||||
dayGroup.left = cumulativeWidth;
|
||||
dayGroup.top = cumulativeHeight;
|
||||
|
||||
cumulativeWidth += dayGroup.width + timelineManager.gap;
|
||||
} else {
|
||||
// Move to next row
|
||||
cumulativeHeight += currentRowHeight;
|
||||
cumulativeWidth = 0;
|
||||
dayGroupRow++;
|
||||
dayGroupCol = 0;
|
||||
|
||||
// Position at start of new row
|
||||
dayGroup.row = dayGroupRow;
|
||||
dayGroup.col = dayGroupCol;
|
||||
dayGroup.left = 0;
|
||||
dayGroup.top = cumulativeHeight;
|
||||
|
||||
dayGroupCol++;
|
||||
cumulativeWidth += dayGroup.width + timelineManager.gap;
|
||||
}
|
||||
currentRowHeight = dayGroup.height + timelineManager.headerHeight;
|
||||
}
|
||||
|
||||
// Add the height of the final row
|
||||
cumulativeHeight += currentRowHeight;
|
||||
|
||||
month.height = cumulativeHeight;
|
||||
month.isHeightActual = true;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { toISOYearMonthUTC } from '$lib/utils/timeline-util';
|
||||
import { getTimeBucket } from '@immich/sdk';
|
||||
|
||||
import type { MonthGroup } from '../month-group.svelte';
|
||||
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||
import type { TimelineManagerOptions } from '../types';
|
||||
import { layoutMonthGroup } from './layout-support.svelte';
|
||||
|
||||
export async function loadFromTimeBuckets(
|
||||
timelineManager: TimelineManager,
|
||||
monthGroup: MonthGroup,
|
||||
options: TimelineManagerOptions,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
if (monthGroup.getFirstAsset()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeBucket = toISOYearMonthUTC(monthGroup.yearMonth);
|
||||
const key = authManager.key;
|
||||
const bucketResponse = await getTimeBucket(
|
||||
{
|
||||
...options,
|
||||
timeBucket,
|
||||
key,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
if (!bucketResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.timelineAlbumId) {
|
||||
const albumAssets = await getTimeBucket(
|
||||
{
|
||||
albumId: options.timelineAlbumId,
|
||||
timeBucket,
|
||||
key,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
for (const id of albumAssets.id) {
|
||||
timelineManager.albumAssets.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
const unprocessedAssets = monthGroup.addAssets(bucketResponse);
|
||||
if (unprocessedAssets.length > 0) {
|
||||
console.error(
|
||||
`Warning: getTimeBucket API returning assets not in requested month: ${monthGroup.yearMonth.month}, ${JSON.stringify(
|
||||
unprocessedAssets.map((unprocessed) => ({
|
||||
id: unprocessed.id,
|
||||
localDateTime: unprocessed.localDateTime,
|
||||
})),
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
layoutMonthGroup(timelineManager, monthGroup);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { setDifference, type TimelinePlainDate } from '$lib/utils/timeline-util';
|
||||
import { AssetOrder } from '@immich/sdk';
|
||||
|
||||
import { GroupInsertionCache } from '../group-insertion-cache.svelte';
|
||||
import { MonthGroup } from '../month-group.svelte';
|
||||
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||
import type { AssetOperation, TimelineAsset } from '../types';
|
||||
import { updateGeometry } from './layout-support.svelte';
|
||||
import { getMonthGroupByDate } from './search-support.svelte';
|
||||
|
||||
export function addAssetsToMonthGroups(
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
options: { order: AssetOrder },
|
||||
) {
|
||||
if (assets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const addContext = new GroupInsertionCache();
|
||||
const updatedMonthGroups = new Set<MonthGroup>();
|
||||
const monthCount = timelineManager.months.length;
|
||||
for (const asset of assets) {
|
||||
let month = getMonthGroupByDate(timelineManager, asset.localDateTime);
|
||||
|
||||
if (!month) {
|
||||
month = new MonthGroup(timelineManager, asset.localDateTime, 1, options.order);
|
||||
month.isLoaded = true;
|
||||
timelineManager.months.push(month);
|
||||
}
|
||||
|
||||
month.addTimelineAsset(asset, addContext);
|
||||
updatedMonthGroups.add(month);
|
||||
}
|
||||
|
||||
if (timelineManager.months.length !== monthCount) {
|
||||
timelineManager.months.sort((a, b) => {
|
||||
return a.yearMonth.year === b.yearMonth.year
|
||||
? b.yearMonth.month - a.yearMonth.month
|
||||
: b.yearMonth.year - a.yearMonth.year;
|
||||
});
|
||||
}
|
||||
|
||||
for (const group of addContext.existingDayGroups) {
|
||||
group.sortAssets(options.order);
|
||||
}
|
||||
|
||||
for (const monthGroup of addContext.bucketsWithNewDayGroups) {
|
||||
monthGroup.sortDayGroups();
|
||||
}
|
||||
|
||||
for (const month of addContext.updatedBuckets) {
|
||||
month.sortDayGroups();
|
||||
updateGeometry(timelineManager, month, { invalidateHeight: true });
|
||||
}
|
||||
timelineManager.updateIntersections();
|
||||
}
|
||||
|
||||
export function runAssetOperation(
|
||||
timelineManager: TimelineManager,
|
||||
ids: Set<string>,
|
||||
operation: AssetOperation,
|
||||
options: { order: AssetOrder },
|
||||
) {
|
||||
if (ids.size === 0) {
|
||||
return { processedIds: new Set(), unprocessedIds: ids, changedGeometry: false };
|
||||
}
|
||||
|
||||
const changedMonthGroups = new Set<MonthGroup>();
|
||||
let idsToProcess = new Set(ids);
|
||||
const idsProcessed = new Set<string>();
|
||||
const combinedMoveAssets: { asset: TimelineAsset; date: TimelinePlainDate }[][] = [];
|
||||
for (const month of timelineManager.months) {
|
||||
if (idsToProcess.size > 0) {
|
||||
const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation);
|
||||
if (moveAssets.length > 0) {
|
||||
combinedMoveAssets.push(moveAssets);
|
||||
}
|
||||
idsToProcess = setDifference(idsToProcess, processedIds);
|
||||
for (const id of processedIds) {
|
||||
idsProcessed.add(id);
|
||||
}
|
||||
if (changedGeometry) {
|
||||
changedMonthGroups.add(month);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (combinedMoveAssets.length > 0) {
|
||||
addAssetsToMonthGroups(
|
||||
timelineManager,
|
||||
combinedMoveAssets.flat().map((a) => a.asset),
|
||||
options,
|
||||
);
|
||||
}
|
||||
const changedGeometry = changedMonthGroups.size > 0;
|
||||
for (const month of changedMonthGroups) {
|
||||
updateGeometry(timelineManager, month, { invalidateHeight: true });
|
||||
}
|
||||
if (changedGeometry) {
|
||||
timelineManager.updateIntersections();
|
||||
}
|
||||
return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { plainDateTimeCompare, type TimelinePlainYearMonth } from '$lib/utils/timeline-util';
|
||||
import type { MonthGroup } from '../month-group.svelte';
|
||||
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||
import type { AssetDescriptor, Direction, TimelineAsset } from '../types';
|
||||
|
||||
export async function getAssetWithOffset(
|
||||
timelineManager: TimelineManager,
|
||||
assetDescriptor: AssetDescriptor,
|
||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||
direction: Direction,
|
||||
): Promise<TimelineAsset | undefined> {
|
||||
const { asset, monthGroup } = findMonthGroupForAsset(timelineManager, assetDescriptor.id) ?? {};
|
||||
if (!monthGroup || !asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (interval) {
|
||||
case 'asset': {
|
||||
return getAssetByAssetOffset(timelineManager, asset, monthGroup, direction);
|
||||
}
|
||||
case 'day': {
|
||||
return getAssetByDayOffset(timelineManager, asset, monthGroup, direction);
|
||||
}
|
||||
case 'month': {
|
||||
return getAssetByMonthOffset(timelineManager, monthGroup, direction);
|
||||
}
|
||||
case 'year': {
|
||||
return getAssetByYearOffset(timelineManager, monthGroup, direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function findMonthGroupForAsset(timelineManager: TimelineManager, id: string) {
|
||||
for (const month of timelineManager.months) {
|
||||
const asset = month.findAssetById({ id });
|
||||
if (asset) {
|
||||
return { monthGroup: month, asset };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getMonthGroupByDate(
|
||||
timelineManager: TimelineManager,
|
||||
targetYearMonth: TimelinePlainYearMonth,
|
||||
): MonthGroup | undefined {
|
||||
return timelineManager.months.find(
|
||||
(month) => month.yearMonth.year === targetYearMonth.year && month.yearMonth.month === targetYearMonth.month,
|
||||
);
|
||||
}
|
||||
|
||||
async function getAssetByAssetOffset(
|
||||
timelineManager: TimelineManager,
|
||||
asset: TimelineAsset,
|
||||
monthGroup: MonthGroup,
|
||||
direction: Direction,
|
||||
) {
|
||||
const dayGroup = monthGroup.findDayGroupForAsset(asset);
|
||||
for await (const targetAsset of timelineManager.assetsIterator({
|
||||
startMonthGroup: monthGroup,
|
||||
startDayGroup: dayGroup,
|
||||
startAsset: asset,
|
||||
direction,
|
||||
})) {
|
||||
if (asset.id !== targetAsset.id) {
|
||||
return targetAsset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getAssetByDayOffset(
|
||||
timelineManager: TimelineManager,
|
||||
asset: TimelineAsset,
|
||||
monthGroup: MonthGroup,
|
||||
direction: Direction,
|
||||
) {
|
||||
const dayGroup = monthGroup.findDayGroupForAsset(asset);
|
||||
for await (const targetAsset of timelineManager.assetsIterator({
|
||||
startMonthGroup: monthGroup,
|
||||
startDayGroup: dayGroup,
|
||||
startAsset: asset,
|
||||
direction,
|
||||
})) {
|
||||
if (targetAsset.localDateTime.day !== asset.localDateTime.day) {
|
||||
return targetAsset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getAssetByMonthOffset(timelineManager: TimelineManager, month: MonthGroup, direction: Direction) {
|
||||
for (const targetMonth of timelineManager.monthGroupIterator({ startMonthGroup: month, direction })) {
|
||||
if (targetMonth.yearMonth.month !== month.yearMonth.month) {
|
||||
const { value, done } = await timelineManager.assetsIterator({ startMonthGroup: targetMonth, direction }).next();
|
||||
return done ? undefined : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getAssetByYearOffset(timelineManager: TimelineManager, month: MonthGroup, direction: Direction) {
|
||||
for (const targetMonth of timelineManager.monthGroupIterator({ startMonthGroup: month, direction })) {
|
||||
if (targetMonth.yearMonth.year !== month.yearMonth.year) {
|
||||
const { value, done } = await timelineManager.assetsIterator({ startMonthGroup: targetMonth, direction }).next();
|
||||
return done ? undefined : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function retrieveRange(timelineManager: TimelineManager, start: AssetDescriptor, end: AssetDescriptor) {
|
||||
let { asset: startAsset, monthGroup: startMonthGroup } = findMonthGroupForAsset(timelineManager, start.id) ?? {};
|
||||
if (!startMonthGroup || !startAsset) {
|
||||
return [];
|
||||
}
|
||||
let { asset: endAsset, monthGroup: endMonthGroup } = findMonthGroupForAsset(timelineManager, end.id) ?? {};
|
||||
if (!endMonthGroup || !endAsset) {
|
||||
return [];
|
||||
}
|
||||
let direction: Direction = 'earlier';
|
||||
if (plainDateTimeCompare(true, startAsset.localDateTime, endAsset.localDateTime) < 0) {
|
||||
[startAsset, endAsset] = [endAsset, startAsset];
|
||||
[startMonthGroup, endMonthGroup] = [endMonthGroup, startMonthGroup];
|
||||
direction = 'earlier';
|
||||
}
|
||||
|
||||
const range: TimelineAsset[] = [];
|
||||
const startDayGroup = startMonthGroup.findDayGroupForAsset(startAsset);
|
||||
for await (const targetAsset of timelineManager.assetsIterator({
|
||||
startMonthGroup,
|
||||
startDayGroup,
|
||||
startAsset,
|
||||
direction,
|
||||
})) {
|
||||
range.push(targetAsset);
|
||||
if (targetAsset.id === endAsset.id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return range;
|
||||
}
|
||||
|
||||
export function findMonthGroupForDate(timelineManager: TimelineManager, targetYearMonth: TimelinePlainYearMonth) {
|
||||
for (const month of timelineManager.months) {
|
||||
const { year, month: monthNum } = month.yearMonth;
|
||||
if (monthNum === targetYearMonth.month && year === targetYearMonth.year) {
|
||||
return month;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function updateObject(target: any, source: any): boolean {
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
let updated = false;
|
||||
for (const key in source) {
|
||||
if (!Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
continue;
|
||||
}
|
||||
if (key === '__proto__' || key === 'constructor') {
|
||||
continue;
|
||||
}
|
||||
const isDate = target[key] instanceof Date;
|
||||
if (typeof target[key] === 'object' && !isDate) {
|
||||
updated = updated || updateObject(target[key], source[key]);
|
||||
} else {
|
||||
if (target[key] !== source[key]) {
|
||||
target[key] = source[key];
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
export function isMismatched<T>(option: T | undefined, value: T): boolean {
|
||||
return option === undefined ? false : option !== value;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { PendingChange, TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { websocketEvents } from '$lib/stores/websocket';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { throttle } from 'lodash-es';
|
||||
import type { Unsubscriber } from 'svelte/store';
|
||||
|
||||
export class WebsocketSupport {
|
||||
#pendingChanges: PendingChange[] = [];
|
||||
#unsubscribers: Unsubscriber[] = [];
|
||||
#timelineManager: TimelineManager;
|
||||
|
||||
#processPendingChanges = throttle(() => {
|
||||
const { add, update, remove } = this.#getPendingChangeBatches();
|
||||
if (add.length > 0) {
|
||||
this.#timelineManager.addAssets(add);
|
||||
}
|
||||
if (update.length > 0) {
|
||||
this.#timelineManager.updateAssets(update);
|
||||
}
|
||||
if (remove.length > 0) {
|
||||
this.#timelineManager.removeAssets(remove);
|
||||
}
|
||||
this.#pendingChanges = [];
|
||||
}, 2500);
|
||||
|
||||
constructor(timeineManager: TimelineManager) {
|
||||
this.#timelineManager = timeineManager;
|
||||
}
|
||||
|
||||
connectWebsocketEvents() {
|
||||
this.#unsubscribers.push(
|
||||
websocketEvents.on('on_upload_success', (asset) =>
|
||||
this.#addPendingChanges({ type: 'add', values: [toTimelineAsset(asset)] }),
|
||||
),
|
||||
websocketEvents.on('on_asset_trash', (ids) => this.#addPendingChanges({ type: 'trash', values: ids })),
|
||||
websocketEvents.on('on_asset_update', (asset) =>
|
||||
this.#addPendingChanges({ type: 'update', values: [toTimelineAsset(asset)] }),
|
||||
),
|
||||
websocketEvents.on('on_asset_delete', (id: string) => this.#addPendingChanges({ type: 'delete', values: [id] })),
|
||||
);
|
||||
}
|
||||
|
||||
disconnectWebsocketEvents() {
|
||||
for (const unsubscribe of this.#unsubscribers) {
|
||||
unsubscribe();
|
||||
}
|
||||
this.#unsubscribers = [];
|
||||
}
|
||||
|
||||
#addPendingChanges(...changes: PendingChange[]) {
|
||||
this.#pendingChanges.push(...changes);
|
||||
this.#processPendingChanges();
|
||||
}
|
||||
|
||||
#getPendingChangeBatches() {
|
||||
const batch: {
|
||||
add: TimelineAsset[];
|
||||
update: TimelineAsset[];
|
||||
remove: string[];
|
||||
} = {
|
||||
add: [],
|
||||
update: [],
|
||||
remove: [],
|
||||
};
|
||||
for (const { type, values } of this.#pendingChanges) {
|
||||
switch (type) {
|
||||
case 'add': {
|
||||
batch.add.push(...values);
|
||||
break;
|
||||
}
|
||||
case 'update': {
|
||||
batch.update.push(...values);
|
||||
break;
|
||||
}
|
||||
case 'delete':
|
||||
case 'trash': {
|
||||
batch.remove.push(...values);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return batch;
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import type { AssetDateGroup } from './asset-date-group.svelte';
|
||||
import type { TimelineAsset } from './types';
|
||||
|
||||
const {
|
||||
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
|
||||
} = TUNABLES;
|
||||
|
||||
export class IntersectingAsset {
|
||||
readonly #group: AssetDateGroup;
|
||||
|
||||
intersecting = $derived.by(() => {
|
||||
if (!this.position) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const store = this.#group.bucket.store;
|
||||
|
||||
const scrollCompensation = store.scrollCompensation;
|
||||
const scrollCompensationHeightDelta = scrollCompensation?.heightDelta ?? 0;
|
||||
|
||||
const topWindow =
|
||||
store.visibleWindow.top - store.headerHeight - INTERSECTION_EXPAND_TOP + scrollCompensationHeightDelta;
|
||||
const bottomWindow =
|
||||
store.visibleWindow.bottom + store.headerHeight + INTERSECTION_EXPAND_BOTTOM + scrollCompensationHeightDelta;
|
||||
const positionTop = this.#group.absoluteDateGroupTop + this.position.top;
|
||||
const positionBottom = positionTop + this.position.height;
|
||||
|
||||
const intersecting =
|
||||
(positionTop >= topWindow && positionTop < bottomWindow) ||
|
||||
(positionBottom >= topWindow && positionBottom < bottomWindow) ||
|
||||
(positionTop < topWindow && positionBottom >= bottomWindow);
|
||||
return intersecting;
|
||||
});
|
||||
|
||||
position: CommonPosition | undefined = $state();
|
||||
asset: TimelineAsset = <TimelineAsset>$state();
|
||||
id: string | undefined = $derived(this.asset?.id);
|
||||
|
||||
constructor(group: AssetDateGroup, asset: TimelineAsset) {
|
||||
this.#group = group;
|
||||
this.asset = asset;
|
||||
}
|
||||
}
|
||||
+100
-95
@@ -1,68 +1,72 @@
|
||||
import { AssetOrder, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||
|
||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
formatBucketTitle,
|
||||
formatGroupTitle,
|
||||
formatMonthGroupTitle,
|
||||
fromTimelinePlainDate,
|
||||
fromTimelinePlainDateTime,
|
||||
fromTimelinePlainYearMonth,
|
||||
getTimes,
|
||||
setDifference,
|
||||
type TimelinePlainDateTime,
|
||||
type TimelinePlainYearMonth,
|
||||
} from '$lib/utils/timeline-util';
|
||||
import { AssetOrder, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||
|
||||
import { t } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { AddContext } from './add-context.svelte';
|
||||
import { AssetDateGroup } from './asset-date-group.svelte';
|
||||
import type { AssetStore } from './asset-store.svelte';
|
||||
import { IntersectingAsset } from './intersecting-asset.svelte';
|
||||
import type { AssetDescriptor, AssetOperation, Direction, MoveAsset, TimelineAsset } from './types';
|
||||
|
||||
export class AssetBucket {
|
||||
import { DayGroup } from './day-group.svelte';
|
||||
import { GroupInsertionCache } from './group-insertion-cache.svelte';
|
||||
import type { TimelineManager } from './timeline-manager.svelte';
|
||||
import type { AssetDescriptor, AssetOperation, Direction, MoveAsset, TimelineAsset } from './types';
|
||||
import { ViewerAsset } from './viewer-asset.svelte';
|
||||
|
||||
export class MonthGroup {
|
||||
#intersecting: boolean = $state(false);
|
||||
actuallyIntersecting: boolean = $state(false);
|
||||
isLoaded: boolean = $state(false);
|
||||
dateGroups: AssetDateGroup[] = $state([]);
|
||||
readonly store: AssetStore;
|
||||
dayGroups: DayGroup[] = $state([]);
|
||||
readonly timelineManager: TimelineManager;
|
||||
|
||||
#bucketHeight: number = $state(0);
|
||||
#height: number = $state(0);
|
||||
#top: number = $state(0);
|
||||
|
||||
#initialCount: number = 0;
|
||||
#sortOrder: AssetOrder = AssetOrder.Desc;
|
||||
percent: number = $state(0);
|
||||
|
||||
bucketCount: number = $derived(
|
||||
assetsCount: number = $derived(
|
||||
this.isLoaded
|
||||
? this.dateGroups.reduce((accumulator, g) => accumulator + g.intersectingAssets.length, 0)
|
||||
? this.dayGroups.reduce((accumulator, g) => accumulator + g.viewerAssets.length, 0)
|
||||
: this.#initialCount,
|
||||
);
|
||||
loader: CancellableTask | undefined;
|
||||
isBucketHeightActual: boolean = $state(false);
|
||||
isHeightActual: boolean = $state(false);
|
||||
|
||||
readonly bucketDateFormatted: string;
|
||||
readonly monthGroupTitle: string;
|
||||
readonly yearMonth: TimelinePlainYearMonth;
|
||||
|
||||
constructor(
|
||||
store: AssetStore,
|
||||
store: TimelineManager,
|
||||
yearMonth: TimelinePlainYearMonth,
|
||||
initialCount: number,
|
||||
order: AssetOrder = AssetOrder.Desc,
|
||||
) {
|
||||
this.store = store;
|
||||
this.timelineManager = store;
|
||||
this.#initialCount = initialCount;
|
||||
this.#sortOrder = order;
|
||||
|
||||
this.yearMonth = yearMonth;
|
||||
this.bucketDateFormatted = formatBucketTitle(fromTimelinePlainYearMonth(yearMonth));
|
||||
this.monthGroupTitle = formatMonthGroupTitle(fromTimelinePlainYearMonth(yearMonth));
|
||||
|
||||
this.loader = new CancellableTask(
|
||||
() => {
|
||||
this.isLoaded = true;
|
||||
},
|
||||
() => {
|
||||
this.dateGroups = [];
|
||||
this.dayGroups = [];
|
||||
this.isLoaded = false;
|
||||
},
|
||||
this.#handleLoadError,
|
||||
@@ -76,7 +80,7 @@ export class AssetBucket {
|
||||
}
|
||||
this.#intersecting = newValue;
|
||||
if (newValue) {
|
||||
void this.store.loadBucket(this.yearMonth);
|
||||
void this.timelineManager.loadMonthGroup(this.yearMonth);
|
||||
} else {
|
||||
this.cancel();
|
||||
}
|
||||
@@ -86,28 +90,25 @@ export class AssetBucket {
|
||||
return this.#intersecting;
|
||||
}
|
||||
|
||||
get lastDateGroup() {
|
||||
return this.dateGroups.at(-1);
|
||||
get lastDayGroup() {
|
||||
return this.dayGroups.at(-1);
|
||||
}
|
||||
|
||||
getFirstAsset() {
|
||||
return this.dateGroups[0]?.getFirstAsset();
|
||||
return this.dayGroups[0]?.getFirstAsset();
|
||||
}
|
||||
|
||||
getAssets() {
|
||||
// eslint-disable-next-line unicorn/no-array-reduce
|
||||
return this.dateGroups.reduce(
|
||||
(accumulator: TimelineAsset[], g: AssetDateGroup) => accumulator.concat(g.getAssets()),
|
||||
[],
|
||||
);
|
||||
return this.dayGroups.reduce((accumulator: TimelineAsset[], g: DayGroup) => accumulator.concat(g.getAssets()), []);
|
||||
}
|
||||
|
||||
sortDateGroups() {
|
||||
sortDayGroups() {
|
||||
if (this.#sortOrder === AssetOrder.Asc) {
|
||||
return this.dateGroups.sort((a, b) => a.day - b.day);
|
||||
return this.dayGroups.sort((a, b) => a.day - b.day);
|
||||
}
|
||||
|
||||
return this.dateGroups.sort((a, b) => b.day - a.day);
|
||||
return this.dayGroups.sort((a, b) => b.day - a.day);
|
||||
}
|
||||
|
||||
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
|
||||
@@ -119,26 +120,26 @@ export class AssetBucket {
|
||||
changedGeometry: false,
|
||||
};
|
||||
}
|
||||
const { dateGroups } = this;
|
||||
const { dayGroups } = this;
|
||||
let combinedChangedGeometry = false;
|
||||
let idsToProcess = new Set(ids);
|
||||
const idsProcessed = new Set<string>();
|
||||
const combinedMoveAssets: MoveAsset[][] = [];
|
||||
let index = dateGroups.length;
|
||||
let index = dayGroups.length;
|
||||
while (index--) {
|
||||
if (idsToProcess.size > 0) {
|
||||
const group = dateGroups[index];
|
||||
const group = dayGroups[index];
|
||||
const { moveAssets, processedIds, changedGeometry } = group.runAssetOperation(ids, operation);
|
||||
if (moveAssets.length > 0) {
|
||||
combinedMoveAssets.push(moveAssets);
|
||||
}
|
||||
idsToProcess = idsToProcess.difference(processedIds);
|
||||
idsToProcess = setDifference(idsToProcess, processedIds);
|
||||
for (const id of processedIds) {
|
||||
idsProcessed.add(id);
|
||||
}
|
||||
combinedChangedGeometry = combinedChangedGeometry || changedGeometry;
|
||||
if (group.intersectingAssets.length === 0) {
|
||||
dateGroups.splice(index, 1);
|
||||
if (group.viewerAssets.length === 0) {
|
||||
dayGroups.splice(index, 1);
|
||||
combinedChangedGeometry = true;
|
||||
}
|
||||
}
|
||||
@@ -152,7 +153,7 @@ export class AssetBucket {
|
||||
}
|
||||
|
||||
addAssets(bucketAssets: TimeBucketAssetResponseDto) {
|
||||
const addContext = new AddContext();
|
||||
const addContext = new GroupInsertionCache();
|
||||
for (let i = 0; i < bucketAssets.id.length; i++) {
|
||||
const { localDateTime, fileCreatedAt } = getTimes(
|
||||
bucketAssets.fileCreatedAt[i],
|
||||
@@ -188,12 +189,12 @@ export class AssetBucket {
|
||||
this.addTimelineAsset(timelineAsset, addContext);
|
||||
}
|
||||
|
||||
for (const group of addContext.existingDateGroups) {
|
||||
for (const group of addContext.existingDayGroups) {
|
||||
group.sortAssets(this.#sortOrder);
|
||||
}
|
||||
|
||||
if (addContext.newDateGroups.size > 0) {
|
||||
this.sortDateGroups();
|
||||
if (addContext.newDayGroups.size > 0) {
|
||||
this.sortDayGroups();
|
||||
}
|
||||
|
||||
addContext.sort(this, this.#sortOrder);
|
||||
@@ -201,7 +202,7 @@ export class AssetBucket {
|
||||
return addContext.unprocessedAssets;
|
||||
}
|
||||
|
||||
addTimelineAsset(timelineAsset: TimelineAsset, addContext: AddContext) {
|
||||
addTimelineAsset(timelineAsset: TimelineAsset, addContext: GroupInsertionCache) {
|
||||
const { localDateTime } = timelineAsset;
|
||||
|
||||
const { year, month } = this.yearMonth;
|
||||
@@ -210,29 +211,29 @@ export class AssetBucket {
|
||||
return;
|
||||
}
|
||||
|
||||
let dateGroup = addContext.getDateGroup(localDateTime) || this.findDateGroupByDay(localDateTime.day);
|
||||
if (dateGroup) {
|
||||
addContext.setDateGroup(dateGroup, localDateTime);
|
||||
let dayGroup = addContext.getDayGroup(localDateTime) || this.findDayGroupByDay(localDateTime.day);
|
||||
if (dayGroup) {
|
||||
addContext.setDayGroup(dayGroup, localDateTime);
|
||||
} else {
|
||||
const groupTitle = formatGroupTitle(fromTimelinePlainDate(localDateTime));
|
||||
dateGroup = new AssetDateGroup(this, this.dateGroups.length, localDateTime.day, groupTitle);
|
||||
this.dateGroups.push(dateGroup);
|
||||
addContext.setDateGroup(dateGroup, localDateTime);
|
||||
addContext.newDateGroups.add(dateGroup);
|
||||
dayGroup = new DayGroup(this, this.dayGroups.length, localDateTime.day, groupTitle);
|
||||
this.dayGroups.push(dayGroup);
|
||||
addContext.setDayGroup(dayGroup, localDateTime);
|
||||
addContext.newDayGroups.add(dayGroup);
|
||||
}
|
||||
|
||||
const intersectingAsset = new IntersectingAsset(dateGroup, timelineAsset);
|
||||
dateGroup.intersectingAssets.push(intersectingAsset);
|
||||
addContext.changedDateGroups.add(dateGroup);
|
||||
const viewerAsset = new ViewerAsset(dayGroup, timelineAsset);
|
||||
dayGroup.viewerAssets.push(viewerAsset);
|
||||
addContext.changedDayGroups.add(dayGroup);
|
||||
}
|
||||
|
||||
getRandomDateGroup() {
|
||||
const random = Math.floor(Math.random() * this.dateGroups.length);
|
||||
return this.dateGroups[random];
|
||||
getRandomDayGroup() {
|
||||
const random = Math.floor(Math.random() * this.dayGroups.length);
|
||||
return this.dayGroups[random];
|
||||
}
|
||||
|
||||
getRandomAsset() {
|
||||
return this.getRandomDateGroup()?.getRandomAsset()?.asset;
|
||||
return this.getRandomDayGroup()?.getRandomAsset()?.asset;
|
||||
}
|
||||
|
||||
get viewId() {
|
||||
@@ -240,55 +241,55 @@ export class AssetBucket {
|
||||
return year + '-' + month;
|
||||
}
|
||||
|
||||
set bucketHeight(height: number) {
|
||||
if (this.#bucketHeight === height) {
|
||||
set height(height: number) {
|
||||
if (this.#height === height) {
|
||||
return;
|
||||
}
|
||||
const { store, percent } = this;
|
||||
const index = store.buckets.indexOf(this);
|
||||
const bucketHeightDelta = height - this.#bucketHeight;
|
||||
this.#bucketHeight = height;
|
||||
const prevBucket = store.buckets[index - 1];
|
||||
if (prevBucket) {
|
||||
const newTop = prevBucket.#top + prevBucket.#bucketHeight;
|
||||
const { timelineManager: store, percent } = this;
|
||||
const index = store.months.indexOf(this);
|
||||
const heightDelta = height - this.#height;
|
||||
this.#height = height;
|
||||
const prevMonthGroup = store.months[index - 1];
|
||||
if (prevMonthGroup) {
|
||||
const newTop = prevMonthGroup.#top + prevMonthGroup.#height;
|
||||
if (this.#top !== newTop) {
|
||||
this.#top = newTop;
|
||||
}
|
||||
}
|
||||
for (let cursor = index + 1; cursor < store.buckets.length; cursor++) {
|
||||
const bucket = this.store.buckets[cursor];
|
||||
const newTop = bucket.#top + bucketHeightDelta;
|
||||
if (bucket.#top !== newTop) {
|
||||
bucket.#top = newTop;
|
||||
for (let cursor = index + 1; cursor < store.months.length; cursor++) {
|
||||
const monthGroup = this.timelineManager.months[cursor];
|
||||
const newTop = monthGroup.#top + heightDelta;
|
||||
if (monthGroup.#top !== newTop) {
|
||||
monthGroup.#top = newTop;
|
||||
}
|
||||
}
|
||||
if (store.topIntersectingBucket) {
|
||||
const currentIndex = store.buckets.indexOf(store.topIntersectingBucket);
|
||||
if (store.topIntersectingMonthGroup) {
|
||||
const currentIndex = store.months.indexOf(store.topIntersectingMonthGroup);
|
||||
if (currentIndex > 0) {
|
||||
if (index < currentIndex) {
|
||||
store.scrollCompensation = {
|
||||
heightDelta: bucketHeightDelta,
|
||||
heightDelta,
|
||||
scrollTop: undefined,
|
||||
bucket: this,
|
||||
monthGroup: this,
|
||||
};
|
||||
} else if (percent > 0) {
|
||||
const top = this.top + height * percent;
|
||||
store.scrollCompensation = {
|
||||
heightDelta: undefined,
|
||||
scrollTop: top,
|
||||
bucket: this,
|
||||
monthGroup: this,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get bucketHeight() {
|
||||
return this.#bucketHeight;
|
||||
get height() {
|
||||
return this.#height;
|
||||
}
|
||||
|
||||
get top(): number {
|
||||
return this.#top + this.store.topSectionHeight;
|
||||
return this.#top + this.timelineManager.topSectionHeight;
|
||||
}
|
||||
|
||||
#handleLoadError(error: unknown) {
|
||||
@@ -296,45 +297,45 @@ export class AssetBucket {
|
||||
handleError(error, _$t('errors.failed_to_load_assets'));
|
||||
}
|
||||
|
||||
findDateGroupForAsset(asset: TimelineAsset) {
|
||||
for (const group of this.dateGroups) {
|
||||
if (group.intersectingAssets.some((IntersectingAsset) => IntersectingAsset.id === asset.id)) {
|
||||
findDayGroupForAsset(asset: TimelineAsset) {
|
||||
for (const group of this.dayGroups) {
|
||||
if (group.viewerAssets.some((viewerAsset) => viewerAsset.id === asset.id)) {
|
||||
return group;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findDateGroupByDay(day: number) {
|
||||
return this.dateGroups.find((group) => group.day === day);
|
||||
findDayGroupByDay(day: number) {
|
||||
return this.dayGroups.find((group) => group.day === day);
|
||||
}
|
||||
|
||||
findAssetAbsolutePosition(assetId: string) {
|
||||
this.store.clearDeferredLayout(this);
|
||||
for (const group of this.dateGroups) {
|
||||
const intersectingAsset = group.intersectingAssets.find((asset) => asset.id === assetId);
|
||||
if (intersectingAsset) {
|
||||
if (!intersectingAsset.position) {
|
||||
this.timelineManager.clearDeferredLayout(this);
|
||||
for (const group of this.dayGroups) {
|
||||
const viewerAsset = group.viewerAssets.find((viewAsset) => viewAsset.id === assetId);
|
||||
if (viewerAsset) {
|
||||
if (!viewerAsset.position) {
|
||||
console.warn('No position for asset');
|
||||
break;
|
||||
}
|
||||
return this.top + group.top + intersectingAsset.position.top + this.store.headerHeight;
|
||||
return this.top + group.top + viewerAsset.position.top + this.timelineManager.headerHeight;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
*assetsIterator(options?: { startDateGroup?: AssetDateGroup; startAsset?: TimelineAsset; direction?: Direction }) {
|
||||
*assetsIterator(options?: { startDayGroup?: DayGroup; startAsset?: TimelineAsset; direction?: Direction }) {
|
||||
const direction = options?.direction ?? 'earlier';
|
||||
let { startAsset } = options ?? {};
|
||||
const isEarlier = direction === 'earlier';
|
||||
let groupIndex = options?.startDateGroup
|
||||
? this.dateGroups.indexOf(options.startDateGroup)
|
||||
let groupIndex = options?.startDayGroup
|
||||
? this.dayGroups.indexOf(options.startDayGroup)
|
||||
: isEarlier
|
||||
? 0
|
||||
: this.dateGroups.length - 1;
|
||||
: this.dayGroups.length - 1;
|
||||
|
||||
while (groupIndex >= 0 && groupIndex < this.dateGroups.length) {
|
||||
const group = this.dateGroups[groupIndex];
|
||||
while (groupIndex >= 0 && groupIndex < this.dayGroups.length) {
|
||||
const group = this.dayGroups[groupIndex];
|
||||
yield* group.assetsIterator({ startAsset, direction });
|
||||
startAsset = undefined;
|
||||
groupIndex += isEarlier ? 1 : -1;
|
||||
@@ -342,7 +343,11 @@ export class AssetBucket {
|
||||
}
|
||||
|
||||
findAssetById(assetDescriptor: AssetDescriptor) {
|
||||
return this.assetsIterator().find((asset) => asset.id === assetDescriptor.id);
|
||||
for (const asset of this.assetsIterator()) {
|
||||
if (asset.id === assetDescriptor.id) {
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findClosest(target: TimelinePlainDateTime) {
|
||||
@@ -0,0 +1,583 @@
|
||||
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
||||
import { getMonthGroupByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
||||
import { AbortError } from '$lib/utils';
|
||||
import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util';
|
||||
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
|
||||
import { TimelineManager } from './timeline-manager.svelte';
|
||||
import type { TimelineAsset } from './types';
|
||||
|
||||
async function getAssets(timelineManager: TimelineManager) {
|
||||
const assets = [];
|
||||
for await (const asset of timelineManager.assetsIterator()) {
|
||||
assets.push(asset);
|
||||
}
|
||||
return assets;
|
||||
}
|
||||
|
||||
function deriveLocalDateTimeFromFileCreatedAt(arg: TimelineAsset): TimelineAsset {
|
||||
return {
|
||||
...arg,
|
||||
localDateTime: arg.fileCreatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
describe('TimelineManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
let timelineManager: TimelineManager;
|
||||
const bucketAssets: Record<string, TimelineAsset[]> = {
|
||||
'2024-03-01T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
|
||||
deriveLocalDateTimeFromFileCreatedAt({
|
||||
...asset,
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-01T00:00:00.000Z'),
|
||||
}),
|
||||
),
|
||||
'2024-02-01T00:00:00.000Z': timelineAssetFactory.buildList(100).map((asset) =>
|
||||
deriveLocalDateTimeFromFileCreatedAt({
|
||||
...asset,
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-01T00:00:00.000Z'),
|
||||
}),
|
||||
),
|
||||
'2024-01-01T00:00:00.000Z': timelineAssetFactory.buildList(3).map((asset) =>
|
||||
deriveLocalDateTimeFromFileCreatedAt({
|
||||
...asset,
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-01T00:00:00.000Z'),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
|
||||
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
|
||||
);
|
||||
|
||||
beforeEach(async () => {
|
||||
timelineManager = new TimelineManager();
|
||||
sdkMock.getTimeBuckets.mockResolvedValue([
|
||||
{ count: 1, timeBucket: '2024-03-01' },
|
||||
{ count: 100, timeBucket: '2024-02-01' },
|
||||
{ count: 3, timeBucket: '2024-01-01' },
|
||||
]);
|
||||
|
||||
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
|
||||
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
||||
});
|
||||
|
||||
it('should load months in viewport', () => {
|
||||
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
|
||||
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('calculates month height', () => {
|
||||
const plainMonths = timelineManager.months.map((month) => ({
|
||||
year: month.yearMonth.year,
|
||||
month: month.yearMonth.month,
|
||||
height: month.height,
|
||||
}));
|
||||
|
||||
expect(plainMonths).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ year: 2024, month: 3, height: 165.5 }),
|
||||
expect.objectContaining({ year: 2024, month: 2, height: 11_996 }),
|
||||
expect.objectContaining({ year: 2024, month: 1, height: 286 }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('calculates timeline height', () => {
|
||||
expect(timelineManager.timelineHeight).toBe(12_447.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadMonthGroup', () => {
|
||||
let timelineManager: TimelineManager;
|
||||
const bucketAssets: Record<string, TimelineAsset[]> = {
|
||||
'2024-01-03T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
|
||||
deriveLocalDateTimeFromFileCreatedAt({
|
||||
...asset,
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-01T00:00:00.000Z'),
|
||||
}),
|
||||
),
|
||||
'2024-01-01T00:00:00.000Z': timelineAssetFactory.buildList(3).map((asset) =>
|
||||
deriveLocalDateTimeFromFileCreatedAt({
|
||||
...asset,
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-01T00:00:00.000Z'),
|
||||
}),
|
||||
),
|
||||
};
|
||||
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
|
||||
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
|
||||
);
|
||||
beforeEach(async () => {
|
||||
timelineManager = new TimelineManager();
|
||||
sdkMock.getTimeBuckets.mockResolvedValue([
|
||||
{ count: 1, timeBucket: '2024-03-01T00:00:00.000Z' },
|
||||
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
|
||||
]);
|
||||
sdkMock.getTimeBucket.mockImplementation(async ({ timeBucket }, { signal } = {}) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
if (signal?.aborted) {
|
||||
throw new AbortError();
|
||||
}
|
||||
return bucketAssetsResponse[timeBucket];
|
||||
});
|
||||
await timelineManager.updateViewport({ width: 1588, height: 0 });
|
||||
});
|
||||
|
||||
it('loads a month', async () => {
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
|
||||
});
|
||||
|
||||
it('ignores invalid months', async () => {
|
||||
await timelineManager.loadMonthGroup({ year: 2023, month: 1 });
|
||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('cancels month loading', async () => {
|
||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })!;
|
||||
void timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||
const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort');
|
||||
month?.cancel();
|
||||
expect(abortSpy).toBeCalledTimes(1);
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
|
||||
});
|
||||
|
||||
it('prevents loading months multiple times', async () => {
|
||||
await Promise.all([
|
||||
timelineManager.loadMonthGroup({ year: 2024, month: 1 }),
|
||||
timelineManager.loadMonthGroup({ year: 2024, month: 1 }),
|
||||
]);
|
||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('allows loading a canceled month', async () => {
|
||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })!;
|
||||
const loadPromise = timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||
|
||||
month.cancel();
|
||||
await loadPromise;
|
||||
expect(month?.getAssets().length).toEqual(0);
|
||||
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||
expect(month!.getAssets().length).toEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addAssets', () => {
|
||||
let timelineManager: TimelineManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
timelineManager = new TimelineManager();
|
||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||
|
||||
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
||||
});
|
||||
|
||||
it('is empty initially', () => {
|
||||
expect(timelineManager.months.length).toEqual(0);
|
||||
expect(timelineManager.assetCount).toEqual(0);
|
||||
});
|
||||
|
||||
it('adds assets to new month', () => {
|
||||
const asset = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([asset]);
|
||||
|
||||
expect(timelineManager.months.length).toEqual(1);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
expect(timelineManager.months[0].getAssets().length).toEqual(1);
|
||||
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.months[0].yearMonth.month).toEqual(1);
|
||||
expect(timelineManager.months[0].getFirstAsset().id).toEqual(asset.id);
|
||||
});
|
||||
|
||||
it('adds assets to existing month', () => {
|
||||
const [assetOne, assetTwo] = timelineAssetFactory
|
||||
.buildList(2, {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
})
|
||||
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
||||
timelineManager.addAssets([assetOne]);
|
||||
timelineManager.addAssets([assetTwo]);
|
||||
|
||||
expect(timelineManager.months.length).toEqual(1);
|
||||
expect(timelineManager.assetCount).toEqual(2);
|
||||
expect(timelineManager.months[0].getAssets().length).toEqual(2);
|
||||
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.months[0].yearMonth.month).toEqual(1);
|
||||
});
|
||||
|
||||
it('orders assets in months by descending date', () => {
|
||||
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
const assetThree = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-16T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([assetOne, assetTwo, assetThree]);
|
||||
|
||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 });
|
||||
expect(month).not.toBeNull();
|
||||
expect(month?.getAssets().length).toEqual(3);
|
||||
expect(month?.getAssets()[0].id).toEqual(assetOne.id);
|
||||
expect(month?.getAssets()[1].id).toEqual(assetThree.id);
|
||||
expect(month?.getAssets()[2].id).toEqual(assetTwo.id);
|
||||
});
|
||||
|
||||
it('orders months by descending date', () => {
|
||||
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-04-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
const assetThree = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2023-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([assetOne, assetTwo, assetThree]);
|
||||
|
||||
expect(timelineManager.months.length).toEqual(3);
|
||||
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.months[0].yearMonth.month).toEqual(4);
|
||||
|
||||
expect(timelineManager.months[1].yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.months[1].yearMonth.month).toEqual(1);
|
||||
|
||||
expect(timelineManager.months[2].yearMonth.year).toEqual(2023);
|
||||
expect(timelineManager.months[2].yearMonth.month).toEqual(1);
|
||||
});
|
||||
|
||||
it('updates existing asset', () => {
|
||||
const updateAssetsSpy = vi.spyOn(timelineManager, 'updateAssets');
|
||||
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build());
|
||||
timelineManager.addAssets([asset]);
|
||||
|
||||
timelineManager.addAssets([asset]);
|
||||
expect(updateAssetsSpy).toBeCalledWith([asset]);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
});
|
||||
|
||||
// disabled due to the wasm Justified Layout import
|
||||
it('ignores trashed assets when isTrashed is true', async () => {
|
||||
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isTrashed: false }));
|
||||
const trashedAsset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isTrashed: true }));
|
||||
|
||||
const timelineManager = new TimelineManager();
|
||||
await timelineManager.updateOptions({ isTrashed: true });
|
||||
timelineManager.addAssets([asset, trashedAsset]);
|
||||
expect(await getAssets(timelineManager)).toEqual([trashedAsset]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAssets', () => {
|
||||
let timelineManager: TimelineManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
timelineManager = new TimelineManager();
|
||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||
|
||||
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
||||
});
|
||||
|
||||
it('ignores non-existing assets', () => {
|
||||
timelineManager.updateAssets([deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build())]);
|
||||
|
||||
expect(timelineManager.months.length).toEqual(0);
|
||||
expect(timelineManager.assetCount).toEqual(0);
|
||||
});
|
||||
|
||||
it('updates an asset', () => {
|
||||
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isFavorite: false }));
|
||||
const updatedAsset = { ...asset, isFavorite: true };
|
||||
|
||||
timelineManager.addAssets([asset]);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(false);
|
||||
|
||||
timelineManager.updateAssets([updatedAsset]);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(true);
|
||||
});
|
||||
|
||||
it('asset moves months when asset date changes', () => {
|
||||
const asset = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
const updatedAsset = deriveLocalDateTimeFromFileCreatedAt({
|
||||
...asset,
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-20T12:00:00.000Z'),
|
||||
});
|
||||
|
||||
timelineManager.addAssets([asset]);
|
||||
expect(timelineManager.months.length).toEqual(1);
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(1);
|
||||
|
||||
timelineManager.updateAssets([updatedAsset]);
|
||||
expect(timelineManager.months.length).toEqual(2);
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })).not.toBeUndefined();
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })?.getAssets().length).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeAssets', () => {
|
||||
let timelineManager: TimelineManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
timelineManager = new TimelineManager();
|
||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||
|
||||
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
||||
});
|
||||
|
||||
it('ignores invalid IDs', () => {
|
||||
timelineManager.addAssets(
|
||||
timelineAssetFactory
|
||||
.buildList(2, {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
})
|
||||
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset)),
|
||||
);
|
||||
timelineManager.removeAssets(['', 'invalid', '4c7d9acc']);
|
||||
|
||||
expect(timelineManager.assetCount).toEqual(2);
|
||||
expect(timelineManager.months.length).toEqual(1);
|
||||
expect(timelineManager.months[0].getAssets().length).toEqual(2);
|
||||
});
|
||||
|
||||
it('removes asset from month', () => {
|
||||
const [assetOne, assetTwo] = timelineAssetFactory
|
||||
.buildList(2, {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
})
|
||||
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
||||
timelineManager.addAssets([assetOne, assetTwo]);
|
||||
timelineManager.removeAssets([assetOne.id]);
|
||||
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
expect(timelineManager.months.length).toEqual(1);
|
||||
expect(timelineManager.months[0].getAssets().length).toEqual(1);
|
||||
});
|
||||
|
||||
it('does not remove month when empty', () => {
|
||||
const assets = timelineAssetFactory
|
||||
.buildList(2, {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
})
|
||||
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
||||
timelineManager.addAssets(assets);
|
||||
timelineManager.removeAssets(assets.map((asset) => asset.id));
|
||||
|
||||
expect(timelineManager.assetCount).toEqual(0);
|
||||
expect(timelineManager.months.length).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('firstAsset', () => {
|
||||
let timelineManager: TimelineManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
timelineManager = new TimelineManager();
|
||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||
await timelineManager.updateViewport({ width: 0, height: 0 });
|
||||
});
|
||||
|
||||
it('empty store returns null', () => {
|
||||
expect(timelineManager.getFirstAsset()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('populated store returns first asset', () => {
|
||||
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([assetOne, assetTwo]);
|
||||
expect(timelineManager.getFirstAsset()).toEqual(assetOne);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLaterAsset', () => {
|
||||
let timelineManager: TimelineManager;
|
||||
const bucketAssets: Record<string, TimelineAsset[]> = {
|
||||
'2024-03-01T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
|
||||
deriveLocalDateTimeFromFileCreatedAt({
|
||||
...asset,
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-01T00:00:00.000Z'),
|
||||
}),
|
||||
),
|
||||
'2024-02-01T00:00:00.000Z': timelineAssetFactory.buildList(6).map((asset) =>
|
||||
deriveLocalDateTimeFromFileCreatedAt({
|
||||
...asset,
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-01T00:00:00.000Z'),
|
||||
}),
|
||||
),
|
||||
'2024-01-01T00:00:00.000Z': timelineAssetFactory.buildList(3).map((asset) =>
|
||||
deriveLocalDateTimeFromFileCreatedAt({
|
||||
...asset,
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-01T00:00:00.000Z'),
|
||||
}),
|
||||
),
|
||||
};
|
||||
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
|
||||
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
|
||||
);
|
||||
|
||||
beforeEach(async () => {
|
||||
timelineManager = new TimelineManager();
|
||||
sdkMock.getTimeBuckets.mockResolvedValue([
|
||||
{ count: 1, timeBucket: '2024-03-01T00:00:00.000Z' },
|
||||
{ count: 6, timeBucket: '2024-02-01T00:00:00.000Z' },
|
||||
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
|
||||
]);
|
||||
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
|
||||
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
||||
});
|
||||
|
||||
it('returns null for invalid assetId', async () => {
|
||||
expect(() => timelineManager.getLaterAsset({ id: 'invalid' } as AssetResponseDto)).not.toThrow();
|
||||
expect(await timelineManager.getLaterAsset({ id: 'invalid' } as AssetResponseDto)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns previous assetId', async () => {
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 });
|
||||
|
||||
const a = month!.getAssets()[0];
|
||||
const b = month!.getAssets()[1];
|
||||
const previous = await timelineManager.getLaterAsset(b);
|
||||
expect(previous).toEqual(a);
|
||||
});
|
||||
|
||||
it('returns previous assetId spanning multiple months', async () => {
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 2 });
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 3 });
|
||||
|
||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 });
|
||||
const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 });
|
||||
const a = month!.getAssets()[0];
|
||||
const b = previousMonth!.getAssets()[0];
|
||||
const previous = await timelineManager.getLaterAsset(a);
|
||||
expect(previous).toEqual(b);
|
||||
});
|
||||
|
||||
it('loads previous month', async () => {
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 2 });
|
||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 });
|
||||
const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 });
|
||||
const a = month!.getFirstAsset();
|
||||
const b = previousMonth!.getFirstAsset();
|
||||
const loadMonthGroupSpy = vi.spyOn(month!.loader!, 'execute');
|
||||
const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute');
|
||||
const previous = await timelineManager.getLaterAsset(a);
|
||||
expect(previous).toEqual(b);
|
||||
expect(loadMonthGroupSpy).toBeCalledTimes(0);
|
||||
expect(previousMonthSpy).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('skips removed assets', async () => {
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 2 });
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 3 });
|
||||
|
||||
const [assetOne, assetTwo, assetThree] = await getAssets(timelineManager);
|
||||
timelineManager.removeAssets([assetTwo.id]);
|
||||
expect(await timelineManager.getLaterAsset(assetThree)).toEqual(assetOne);
|
||||
});
|
||||
|
||||
it('returns null when no more assets', async () => {
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 3 });
|
||||
expect(await timelineManager.getLaterAsset(timelineManager.months[0].getFirstAsset())).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMonthGroupIndexByAssetId', () => {
|
||||
let timelineManager: TimelineManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
timelineManager = new TimelineManager();
|
||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||
|
||||
await timelineManager.updateViewport({ width: 0, height: 0 });
|
||||
});
|
||||
|
||||
it('returns null for invalid months', () => {
|
||||
expect(getMonthGroupByDate(timelineManager, { year: -1, month: -1 })).toBeUndefined();
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the month index', () => {
|
||||
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([assetOne, assetTwo]);
|
||||
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.month).toEqual(2);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
|
||||
});
|
||||
|
||||
it('ignores removed months', () => {
|
||||
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([assetOne, assetTwo]);
|
||||
|
||||
timelineManager.removeAssets([assetTwo.id]);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,543 @@
|
||||
import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk';
|
||||
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
|
||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
import { toTimelineAsset, type TimelinePlainDateTime, type TimelinePlainYearMonth } from '$lib/utils/timeline-util';
|
||||
|
||||
import { clamp, debounce, isEqual } from 'lodash-es';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
||||
import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
|
||||
import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte';
|
||||
import {
|
||||
addAssetsToMonthGroups,
|
||||
runAssetOperation,
|
||||
} from '$lib/managers/timeline-manager/internal/operations-support.svelte';
|
||||
import {
|
||||
findMonthGroupForAsset as findMonthGroupForAssetUtil,
|
||||
findMonthGroupForDate,
|
||||
getAssetWithOffset,
|
||||
getMonthGroupByDate,
|
||||
retrieveRange as retrieveRangeUtil,
|
||||
} from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
||||
import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte';
|
||||
import { DayGroup } from './day-group.svelte';
|
||||
import { isMismatched, updateObject } from './internal/utils.svelte';
|
||||
import { MonthGroup } from './month-group.svelte';
|
||||
import type {
|
||||
AssetDescriptor,
|
||||
AssetOperation,
|
||||
Direction,
|
||||
ScrubberMonth,
|
||||
TimelineAsset,
|
||||
TimelineManagerLayoutOptions,
|
||||
TimelineManagerOptions,
|
||||
Viewport,
|
||||
} from './types';
|
||||
|
||||
export class TimelineManager {
|
||||
isInitialized = $state(false);
|
||||
months: MonthGroup[] = $state([]);
|
||||
topSectionHeight = $state(0);
|
||||
timelineHeight = $derived(this.months.reduce((accumulator, b) => accumulator + b.height, 0) + this.topSectionHeight);
|
||||
assetCount = $derived(this.months.reduce((accumulator, b) => accumulator + b.assetsCount, 0));
|
||||
|
||||
albumAssets: Set<string> = new SvelteSet();
|
||||
|
||||
scrubberMonths: ScrubberMonth[] = $state([]);
|
||||
scrubberTimelineHeight: number = $state(0);
|
||||
|
||||
topIntersectingMonthGroup: MonthGroup | undefined = $state();
|
||||
|
||||
visibleWindow = $derived.by(() => ({
|
||||
top: this.#scrollTop,
|
||||
bottom: this.#scrollTop + this.viewportHeight,
|
||||
}));
|
||||
|
||||
initTask = new CancellableTask(
|
||||
() => {
|
||||
this.isInitialized = true;
|
||||
if (this.#options.albumId || this.#options.personId) {
|
||||
return;
|
||||
}
|
||||
this.connect();
|
||||
},
|
||||
() => {
|
||||
this.disconnect();
|
||||
this.isInitialized = false;
|
||||
},
|
||||
() => void 0,
|
||||
);
|
||||
|
||||
static #INIT_OPTIONS = {};
|
||||
#viewportHeight = $state(0);
|
||||
#viewportWidth = $state(0);
|
||||
#scrollTop = $state(0);
|
||||
#websocketSupport: WebsocketSupport | undefined;
|
||||
|
||||
#rowHeight = $state(235);
|
||||
#headerHeight = $state(48);
|
||||
#gap = $state(12);
|
||||
|
||||
#options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS;
|
||||
|
||||
#scrolling = $state(false);
|
||||
#suspendTransitions = $state(false);
|
||||
#resetScrolling = debounce(() => (this.#scrolling = false), 1000);
|
||||
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
|
||||
scrollCompensation: {
|
||||
heightDelta: number | undefined;
|
||||
scrollTop: number | undefined;
|
||||
monthGroup: MonthGroup | undefined;
|
||||
} = $state({
|
||||
heightDelta: 0,
|
||||
scrollTop: 0,
|
||||
monthGroup: undefined,
|
||||
});
|
||||
|
||||
constructor() {}
|
||||
|
||||
setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }: TimelineManagerLayoutOptions) {
|
||||
let changed = false;
|
||||
changed ||= this.#setHeaderHeight(headerHeight);
|
||||
changed ||= this.#setGap(gap);
|
||||
changed ||= this.#setRowHeight(rowHeight);
|
||||
if (changed) {
|
||||
this.refreshLayout();
|
||||
}
|
||||
}
|
||||
|
||||
#setHeaderHeight(value: number) {
|
||||
if (this.#headerHeight == value) {
|
||||
return false;
|
||||
}
|
||||
this.#headerHeight = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
get headerHeight() {
|
||||
return this.#headerHeight;
|
||||
}
|
||||
|
||||
#setGap(value: number) {
|
||||
if (this.#gap == value) {
|
||||
return false;
|
||||
}
|
||||
this.#gap = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
get gap() {
|
||||
return this.#gap;
|
||||
}
|
||||
|
||||
#setRowHeight(value: number) {
|
||||
if (this.#rowHeight == value) {
|
||||
return false;
|
||||
}
|
||||
this.#rowHeight = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
get rowHeight() {
|
||||
return this.#rowHeight;
|
||||
}
|
||||
|
||||
set scrolling(value: boolean) {
|
||||
this.#scrolling = value;
|
||||
if (value) {
|
||||
this.suspendTransitions = true;
|
||||
this.#resetScrolling();
|
||||
}
|
||||
}
|
||||
|
||||
get scrolling() {
|
||||
return this.#scrolling;
|
||||
}
|
||||
|
||||
set suspendTransitions(value: boolean) {
|
||||
this.#suspendTransitions = value;
|
||||
if (value) {
|
||||
this.#resetSuspendTransitions();
|
||||
}
|
||||
}
|
||||
|
||||
get suspendTransitions() {
|
||||
return this.#suspendTransitions;
|
||||
}
|
||||
|
||||
set viewportWidth(value: number) {
|
||||
const changed = value !== this.#viewportWidth;
|
||||
this.#viewportWidth = value;
|
||||
this.suspendTransitions = true;
|
||||
void this.#updateViewportGeometry(changed);
|
||||
}
|
||||
|
||||
get viewportWidth() {
|
||||
return this.#viewportWidth;
|
||||
}
|
||||
|
||||
set viewportHeight(value: number) {
|
||||
this.#viewportHeight = value;
|
||||
this.#suspendTransitions = true;
|
||||
void this.#updateViewportGeometry(false);
|
||||
}
|
||||
|
||||
get viewportHeight() {
|
||||
return this.#viewportHeight;
|
||||
}
|
||||
|
||||
async *assetsIterator(options?: {
|
||||
startMonthGroup?: MonthGroup;
|
||||
startDayGroup?: DayGroup;
|
||||
startAsset?: TimelineAsset;
|
||||
direction?: Direction;
|
||||
}) {
|
||||
const direction = options?.direction ?? 'earlier';
|
||||
let { startDayGroup, startAsset } = options ?? {};
|
||||
for (const monthGroup of this.monthGroupIterator({ direction, startMonthGroup: options?.startMonthGroup })) {
|
||||
await this.loadMonthGroup(monthGroup.yearMonth, { cancelable: false });
|
||||
yield* monthGroup.assetsIterator({ startDayGroup, startAsset, direction });
|
||||
startDayGroup = startAsset = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
*monthGroupIterator(options?: { direction?: Direction; startMonthGroup?: MonthGroup }) {
|
||||
const isEarlier = options?.direction === 'earlier';
|
||||
let startIndex = options?.startMonthGroup
|
||||
? this.months.indexOf(options.startMonthGroup)
|
||||
: isEarlier
|
||||
? 0
|
||||
: this.months.length - 1;
|
||||
|
||||
while (startIndex >= 0 && startIndex < this.months.length) {
|
||||
yield this.months[startIndex];
|
||||
startIndex += isEarlier ? 1 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.#websocketSupport) {
|
||||
throw new Error('TimelineManager already connected');
|
||||
}
|
||||
this.#websocketSupport = new WebsocketSupport(this);
|
||||
this.#websocketSupport.connectWebsocketEvents();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (!this.#websocketSupport) {
|
||||
return;
|
||||
}
|
||||
this.#websocketSupport.disconnectWebsocketEvents();
|
||||
this.#websocketSupport = undefined;
|
||||
}
|
||||
|
||||
updateSlidingWindow(scrollTop: number) {
|
||||
if (this.#scrollTop !== scrollTop) {
|
||||
this.#scrollTop = scrollTop;
|
||||
this.updateIntersections();
|
||||
}
|
||||
}
|
||||
|
||||
clearScrollCompensation() {
|
||||
this.scrollCompensation = {
|
||||
heightDelta: undefined,
|
||||
scrollTop: undefined,
|
||||
monthGroup: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
updateIntersections() {
|
||||
if (!this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
|
||||
return;
|
||||
}
|
||||
let topIntersectingMonthGroup = undefined;
|
||||
for (const month of this.months) {
|
||||
updateIntersectionMonthGroup(this, month);
|
||||
if (!topIntersectingMonthGroup && month.actuallyIntersecting) {
|
||||
topIntersectingMonthGroup = month;
|
||||
}
|
||||
}
|
||||
if (topIntersectingMonthGroup !== undefined && this.topIntersectingMonthGroup !== topIntersectingMonthGroup) {
|
||||
this.topIntersectingMonthGroup = topIntersectingMonthGroup;
|
||||
}
|
||||
for (const month of this.months) {
|
||||
if (month === this.topIntersectingMonthGroup) {
|
||||
this.topIntersectingMonthGroup.percent = clamp(
|
||||
(this.visibleWindow.top - this.topIntersectingMonthGroup.top) / this.topIntersectingMonthGroup.height,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
} else {
|
||||
month.percent = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearDeferredLayout(month: MonthGroup) {
|
||||
const hasDeferred = month.dayGroups.some((group) => group.deferredLayout);
|
||||
if (hasDeferred) {
|
||||
updateGeometry(this, month, { invalidateHeight: true, noDefer: true });
|
||||
for (const group of month.dayGroups) {
|
||||
group.deferredLayout = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #initializeMonthGroups() {
|
||||
const timebuckets = await getTimeBuckets({
|
||||
...this.#options,
|
||||
key: authManager.key,
|
||||
});
|
||||
|
||||
this.months = timebuckets.map((timeBucket) => {
|
||||
const date = new Date(timeBucket.timeBucket);
|
||||
return new MonthGroup(
|
||||
this,
|
||||
{ year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 },
|
||||
timeBucket.count,
|
||||
this.#options.order,
|
||||
);
|
||||
});
|
||||
this.albumAssets.clear();
|
||||
this.#updateViewportGeometry(false);
|
||||
}
|
||||
|
||||
async updateOptions(options: TimelineManagerOptions) {
|
||||
if (options.deferInit) {
|
||||
return;
|
||||
}
|
||||
if (this.#options !== TimelineManager.#INIT_OPTIONS && isEqual(this.#options, options)) {
|
||||
return;
|
||||
}
|
||||
await this.initTask.reset();
|
||||
await this.#init(options);
|
||||
this.#updateViewportGeometry(false);
|
||||
}
|
||||
|
||||
async #init(options: TimelineManagerOptions) {
|
||||
this.isInitialized = false;
|
||||
this.months = [];
|
||||
this.albumAssets.clear();
|
||||
await this.initTask.execute(async () => {
|
||||
this.#options = options;
|
||||
await this.#initializeMonthGroups();
|
||||
}, true);
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.disconnect();
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
async updateViewport(viewport: Viewport) {
|
||||
if (viewport.height === 0 && viewport.width === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.viewportHeight === viewport.height && this.viewportWidth === viewport.width) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.initTask.executed) {
|
||||
await (this.initTask.loading ? this.initTask.waitUntilCompletion() : this.#init(this.#options));
|
||||
}
|
||||
|
||||
const changedWidth = viewport.width !== this.viewportWidth;
|
||||
this.viewportHeight = viewport.height;
|
||||
this.viewportWidth = viewport.width;
|
||||
this.#updateViewportGeometry(changedWidth);
|
||||
}
|
||||
|
||||
#updateViewportGeometry(changedWidth: boolean) {
|
||||
if (!this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
if (this.viewportWidth === 0 || this.viewportHeight === 0) {
|
||||
return;
|
||||
}
|
||||
for (const month of this.months) {
|
||||
updateGeometry(this, month, { invalidateHeight: changedWidth });
|
||||
}
|
||||
this.updateIntersections();
|
||||
this.#createScrubberMonths();
|
||||
}
|
||||
|
||||
#createScrubberMonths() {
|
||||
this.scrubberMonths = this.months.map((month) => ({
|
||||
assetCount: month.assetsCount,
|
||||
year: month.yearMonth.year,
|
||||
month: month.yearMonth.month,
|
||||
title: month.monthGroupTitle,
|
||||
height: month.height,
|
||||
}));
|
||||
this.scrubberTimelineHeight = this.timelineHeight;
|
||||
}
|
||||
|
||||
createLayoutOptions() {
|
||||
const viewportWidth = this.viewportWidth;
|
||||
|
||||
return {
|
||||
spacing: 2,
|
||||
heightTolerance: 0.15,
|
||||
rowHeight: this.#rowHeight,
|
||||
rowWidth: Math.floor(viewportWidth),
|
||||
};
|
||||
}
|
||||
|
||||
async loadMonthGroup(yearMonth: TimelinePlainYearMonth, options?: { cancelable: boolean }): Promise<void> {
|
||||
let cancelable = true;
|
||||
if (options) {
|
||||
cancelable = options.cancelable;
|
||||
}
|
||||
const monthGroup = getMonthGroupByDate(this, yearMonth);
|
||||
if (!monthGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (monthGroup.loader?.executed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await monthGroup.loader?.execute(async (signal: AbortSignal) => {
|
||||
await loadFromTimeBuckets(this, monthGroup, this.#options, signal);
|
||||
}, cancelable);
|
||||
if (result === 'LOADED') {
|
||||
updateIntersectionMonthGroup(this, monthGroup);
|
||||
}
|
||||
}
|
||||
|
||||
addAssets(assets: TimelineAsset[]) {
|
||||
const assetsToUpdate = assets.filter((asset) => !this.isExcluded(asset));
|
||||
const notUpdated = this.updateAssets(assetsToUpdate);
|
||||
addAssetsToMonthGroups(this, [...notUpdated], { order: this.#options.order ?? AssetOrder.Desc });
|
||||
}
|
||||
|
||||
async findMonthGroupForAsset(id: string) {
|
||||
if (!this.isInitialized) {
|
||||
await this.initTask.waitUntilCompletion();
|
||||
}
|
||||
let { monthGroup } = findMonthGroupForAssetUtil(this, id) ?? {};
|
||||
if (monthGroup) {
|
||||
return monthGroup;
|
||||
}
|
||||
const asset = toTimelineAsset(await getAssetInfo({ id, key: authManager.key }));
|
||||
if (!asset || this.isExcluded(asset)) {
|
||||
return;
|
||||
}
|
||||
monthGroup = await this.#loadMonthGroupAtTime(asset.localDateTime, { cancelable: false });
|
||||
if (monthGroup?.findAssetById({ id })) {
|
||||
return monthGroup;
|
||||
}
|
||||
}
|
||||
|
||||
async #loadMonthGroupAtTime(yearMonth: TimelinePlainYearMonth, options?: { cancelable: boolean }) {
|
||||
await this.loadMonthGroup(yearMonth, options);
|
||||
return getMonthGroupByDate(this, yearMonth);
|
||||
}
|
||||
|
||||
getMonthGroupByAssetId(assetId: string) {
|
||||
const monthGroupInfo = findMonthGroupForAssetUtil(this, assetId);
|
||||
return monthGroupInfo?.monthGroup;
|
||||
}
|
||||
|
||||
async getRandomMonthGroup() {
|
||||
const random = Math.floor(Math.random() * this.months.length);
|
||||
const month = this.months[random];
|
||||
await this.loadMonthGroup(month.yearMonth, { cancelable: false });
|
||||
return month;
|
||||
}
|
||||
|
||||
async getRandomAsset() {
|
||||
const month = await this.getRandomMonthGroup();
|
||||
return month?.getRandomAsset();
|
||||
}
|
||||
|
||||
updateAssetOperation(ids: string[], operation: AssetOperation) {
|
||||
runAssetOperation(this, new Set(ids), operation, { order: this.#options.order ?? AssetOrder.Desc });
|
||||
}
|
||||
|
||||
updateAssets(assets: TimelineAsset[]) {
|
||||
const lookup = new Map<string, TimelineAsset>(assets.map((asset) => [asset.id, asset]));
|
||||
const { unprocessedIds } = runAssetOperation(
|
||||
this,
|
||||
new Set(lookup.keys()),
|
||||
(asset) => {
|
||||
updateObject(asset, lookup.get(asset.id));
|
||||
return { remove: false };
|
||||
},
|
||||
{ order: this.#options.order ?? AssetOrder.Desc },
|
||||
);
|
||||
const result: TimelineAsset[] = [];
|
||||
for (const id of unprocessedIds.values()) {
|
||||
result.push(lookup.get(id)!);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
removeAssets(ids: string[]) {
|
||||
const { unprocessedIds } = runAssetOperation(
|
||||
this,
|
||||
new Set(ids),
|
||||
() => {
|
||||
return { remove: true };
|
||||
},
|
||||
{ order: this.#options.order ?? AssetOrder.Desc },
|
||||
);
|
||||
return [...unprocessedIds];
|
||||
}
|
||||
|
||||
refreshLayout() {
|
||||
for (const month of this.months) {
|
||||
updateGeometry(this, month, { invalidateHeight: true });
|
||||
}
|
||||
this.updateIntersections();
|
||||
}
|
||||
|
||||
getFirstAsset(): TimelineAsset | undefined {
|
||||
return this.months[0]?.getFirstAsset();
|
||||
}
|
||||
|
||||
async getLaterAsset(
|
||||
assetDescriptor: AssetDescriptor,
|
||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||
): Promise<TimelineAsset | undefined> {
|
||||
return await getAssetWithOffset(this, assetDescriptor, interval, 'later');
|
||||
}
|
||||
|
||||
async getEarlierAsset(
|
||||
assetDescriptor: AssetDescriptor,
|
||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||
): Promise<TimelineAsset | undefined> {
|
||||
return await getAssetWithOffset(this, assetDescriptor, interval, 'earlier');
|
||||
}
|
||||
|
||||
async getClosestAssetToDate(dateTime: TimelinePlainDateTime) {
|
||||
const monthGroup = findMonthGroupForDate(this, dateTime);
|
||||
if (!monthGroup) {
|
||||
return;
|
||||
}
|
||||
await this.loadMonthGroup(dateTime, { cancelable: false });
|
||||
const asset = monthGroup.findClosest(dateTime);
|
||||
if (asset) {
|
||||
return asset;
|
||||
}
|
||||
for await (const asset of this.assetsIterator({ startMonthGroup: monthGroup })) {
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
|
||||
async retrieveRange(start: AssetDescriptor, end: AssetDescriptor) {
|
||||
return retrieveRangeUtil(this, start, end);
|
||||
}
|
||||
|
||||
isExcluded(asset: TimelineAsset) {
|
||||
return (
|
||||
isMismatched(this.#options.visibility, asset.visibility) ||
|
||||
isMismatched(this.#options.isFavorite, asset.isFavorite) ||
|
||||
isMismatched(this.#options.isTrashed, asset.isTrashed)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { AssetStackResponseDto, AssetVisibility } from '@immich/sdk';
|
||||
|
||||
export type AssetApiGetTimeBucketsRequest = Parameters<typeof import('@immich/sdk').getTimeBuckets>[0];
|
||||
|
||||
export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'> & {
|
||||
export type TimelineManagerOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'> & {
|
||||
timelineAlbumId?: string;
|
||||
deferInit?: boolean;
|
||||
};
|
||||
@@ -74,15 +74,15 @@ export interface UpdateStackAssets {
|
||||
|
||||
export type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets | UpdateStackAssets;
|
||||
|
||||
export type LiteBucket = {
|
||||
bucketHeight: number;
|
||||
export type ScrubberMonth = {
|
||||
height: number;
|
||||
assetCount: number;
|
||||
year: number;
|
||||
month: number;
|
||||
bucketDateFormattted: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type AssetStoreLayoutOptions = {
|
||||
export type TimelineManagerLayoutOptions = {
|
||||
rowHeight?: number;
|
||||
headerHeight?: number;
|
||||
gap?: number;
|
||||
|
||||
@@ -1,34 +1,4 @@
|
||||
import type { TimelineAsset } from './types';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function updateObject(target: any, source: any): boolean {
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
let updated = false;
|
||||
for (const key in source) {
|
||||
if (!Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
continue;
|
||||
}
|
||||
if (key === '__proto__' || key === 'constructor') {
|
||||
continue;
|
||||
}
|
||||
const isDate = target[key] instanceof Date;
|
||||
if (typeof target[key] === 'object' && !isDate) {
|
||||
updated = updated || updateObject(target[key], source[key]);
|
||||
} else {
|
||||
if (target[key] !== source[key]) {
|
||||
target[key] = source[key];
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function isMismatched<T>(option: T | undefined, value: T): boolean {
|
||||
return option === undefined ? false : option !== value;
|
||||
}
|
||||
|
||||
export const assetSnapshot = (asset: TimelineAsset): TimelineAsset => $state.snapshot(asset);
|
||||
export const assetsSnapshot = (assets: TimelineAsset[]) => assets.map((asset) => $state.snapshot(asset));
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||
|
||||
import type { DayGroup } from './day-group.svelte';
|
||||
import { calculateViewerAssetIntersecting } from './internal/intersection-support.svelte';
|
||||
import type { TimelineAsset } from './types';
|
||||
|
||||
export class ViewerAsset {
|
||||
readonly #group: DayGroup;
|
||||
|
||||
intersecting = $derived.by(() => {
|
||||
if (!this.position) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const store = this.#group.monthGroup.timelineManager;
|
||||
const positionTop = this.#group.absoluteDayGroupTop + this.position.top;
|
||||
|
||||
return calculateViewerAssetIntersecting(store, positionTop, this.position.height);
|
||||
});
|
||||
|
||||
position: CommonPosition | undefined = $state();
|
||||
asset: TimelineAsset = <TimelineAsset>$state();
|
||||
id: string = $derived(this.asset.id);
|
||||
|
||||
constructor(group: DayGroup, asset: TimelineAsset) {
|
||||
this.#group = group;
|
||||
this.asset = asset;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { getSupportedMediaTypes, type ServerMediaTypesResponseDto } from '@immich/sdk';
|
||||
|
||||
class UploadManager {
|
||||
mediaTypes = $state<ServerMediaTypesResponseDto>({ image: [], sidecar: [], video: [] });
|
||||
|
||||
constructor() {
|
||||
eventManager.on('app.init', () => void this.#loadExtensions());
|
||||
}
|
||||
|
||||
async #loadExtensions() {
|
||||
try {
|
||||
this.mediaTypes = await getSupportedMediaTypes();
|
||||
} catch {
|
||||
console.error('Failed to load supported media types');
|
||||
}
|
||||
}
|
||||
|
||||
getExtensions() {
|
||||
return [...this.mediaTypes.image, ...this.mediaTypes.video];
|
||||
}
|
||||
}
|
||||
|
||||
export const uploadManager = new UploadManager();
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
import AlbumCover from '$lib/components/album-page/album-cover.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateAlbumInfo, type AlbumResponseDto } from '@immich/sdk';
|
||||
import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
||||
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
||||
import { mdiRenameOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -68,9 +68,9 @@
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<div class="flex gap-2 w-full">
|
||||
<HStack fullWidth>
|
||||
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
|
||||
<Button shape="round" type="submit" fullWidth disabled={isSubmitting} form="edit-album-form">{$t('save')}</Button>
|
||||
</div>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
@@ -0,0 +1,193 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
|
||||
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
AlbumUserRole,
|
||||
AssetOrder,
|
||||
removeUserFromAlbum,
|
||||
updateAlbumInfo,
|
||||
updateAlbumUser,
|
||||
type AlbumResponseDto,
|
||||
type UserResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { Modal, ModalBody } from '@immich/ui';
|
||||
import { mdiArrowDownThin, mdiArrowUpThin, mdiDotsVertical, mdiPlus } from '@mdi/js';
|
||||
import { findKey } from 'lodash-es';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { RenderedOption } from '../components/elements/dropdown.svelte';
|
||||
import { notificationController, NotificationType } from '../components/shared-components/notification/notification';
|
||||
import SettingDropdown from '../components/shared-components/settings/setting-dropdown.svelte';
|
||||
|
||||
interface Props {
|
||||
album: AlbumResponseDto;
|
||||
order: AssetOrder | undefined;
|
||||
user: UserResponseDto;
|
||||
onClose: (
|
||||
result?: { action: 'changeOrder'; order: AssetOrder } | { action: 'shareUser' } | { action: 'refreshAlbum' },
|
||||
) => void;
|
||||
}
|
||||
|
||||
let { album, order, user, onClose }: Props = $props();
|
||||
|
||||
const options: Record<AssetOrder, RenderedOption> = {
|
||||
[AssetOrder.Asc]: { icon: mdiArrowUpThin, title: $t('oldest_first') },
|
||||
[AssetOrder.Desc]: { icon: mdiArrowDownThin, title: $t('newest_first') },
|
||||
};
|
||||
|
||||
let selectedOption = $derived(order ? options[order] : options[AssetOrder.Desc]);
|
||||
|
||||
const handleToggleOrder = async (returnedOption: RenderedOption): Promise<void> => {
|
||||
if (selectedOption === returnedOption) {
|
||||
return;
|
||||
}
|
||||
let order: AssetOrder = AssetOrder.Desc;
|
||||
order = findKey(options, (option) => option === returnedOption) as AssetOrder;
|
||||
|
||||
try {
|
||||
await updateAlbumInfo({
|
||||
id: album.id,
|
||||
updateAlbumDto: {
|
||||
order,
|
||||
},
|
||||
});
|
||||
onClose({ action: 'changeOrder', order });
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_save_album'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleActivity = async () => {
|
||||
try {
|
||||
album = await updateAlbumInfo({
|
||||
id: album.id,
|
||||
updateAlbumDto: {
|
||||
isActivityEnabled: !album.isActivityEnabled,
|
||||
},
|
||||
});
|
||||
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
message: $t('activity_changed', { values: { enabled: album.isActivityEnabled } }),
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.cant_change_activity', { values: { enabled: album.isActivityEnabled } }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveUser = async (user: UserResponseDto): Promise<void> => {
|
||||
const confirmed = await modalManager.showDialog({
|
||||
title: $t('album_remove_user'),
|
||||
prompt: $t('album_remove_user_confirmation', { values: { user: user.name } }),
|
||||
confirmText: $t('remove_user'),
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await removeUserFromAlbum({ id: album.id, userId: user.id });
|
||||
onClose({ action: 'refreshAlbum' });
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
message: $t('album_user_removed', { values: { user: user.name } }),
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_remove_album_users'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateSharedUserRole = async (user: UserResponseDto, role: AlbumUserRole) => {
|
||||
try {
|
||||
await updateAlbumUser({ id: album.id, userId: user.id, updateAlbumUserDto: { role } });
|
||||
const message = $t('user_role_set', {
|
||||
values: { user: user.name, role: role == AlbumUserRole.Viewer ? $t('role_viewer') : $t('role_editor') },
|
||||
});
|
||||
onClose({ action: 'refreshAlbum' });
|
||||
notificationController.show({ type: NotificationType.Info, message });
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_change_album_user_role'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal title={$t('options')} onClose={() => onClose({ action: 'refreshAlbum' })} size="small">
|
||||
<ModalBody>
|
||||
<div class="items-center justify-center">
|
||||
<div class="py-2">
|
||||
<h2 class="text-gray text-sm mb-2">{$t('settings').toUpperCase()}</h2>
|
||||
<div class="grid p-2 gap-y-2">
|
||||
{#if order}
|
||||
<SettingDropdown
|
||||
title={$t('display_order')}
|
||||
options={Object.values(options)}
|
||||
selectedOption={options[order]}
|
||||
onToggle={handleToggleOrder}
|
||||
/>
|
||||
{/if}
|
||||
<SettingSwitch
|
||||
title={$t('comments_and_likes')}
|
||||
subtitle={$t('let_others_respond')}
|
||||
checked={album.isActivityEnabled}
|
||||
onToggle={handleToggleActivity}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-2">
|
||||
<div class="text-gray text-sm mb-3">{$t('people').toUpperCase()}</div>
|
||||
<div class="p-2">
|
||||
<button type="button" class="flex items-center gap-2" onclick={() => onClose({ action: 'shareUser' })}>
|
||||
<div class="rounded-full w-10 h-10 border border-gray-500 flex items-center justify-center">
|
||||
<div><Icon path={mdiPlus} size="25" /></div>
|
||||
</div>
|
||||
<div>{$t('invite_people')}</div>
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2 py-2 mt-2">
|
||||
<div>
|
||||
<UserAvatar {user} size="md" />
|
||||
</div>
|
||||
<div class="w-full">{user.name}</div>
|
||||
<div>{$t('owner')}</div>
|
||||
</div>
|
||||
|
||||
{#each album.albumUsers as { user, role } (user.id)}
|
||||
<div class="flex items-center gap-2 py-2">
|
||||
<div>
|
||||
<UserAvatar {user} size="md" />
|
||||
</div>
|
||||
<div class="w-full">{user.name}</div>
|
||||
{#if role === AlbumUserRole.Viewer}
|
||||
{$t('role_viewer')}
|
||||
{:else}
|
||||
{$t('role_editor')}
|
||||
{/if}
|
||||
{#if user.id !== album.ownerId}
|
||||
<ButtonContextMenu icon={mdiDotsVertical} size="medium" title={$t('options')}>
|
||||
{#if role === AlbumUserRole.Viewer}
|
||||
<MenuOption
|
||||
onClick={() => handleUpdateSharedUserRole(user, AlbumUserRole.Editor)}
|
||||
text={$t('allow_edits')}
|
||||
/>
|
||||
{:else}
|
||||
<MenuOption
|
||||
onClick={() => handleUpdateSharedUserRole(user, AlbumUserRole.Viewer)}
|
||||
text={$t('disallow_edits')}
|
||||
/>
|
||||
{/if}
|
||||
<!-- Allow deletion for non-owners -->
|
||||
<MenuOption onClick={() => handleRemoveUser(user)} text={$t('remove')} />
|
||||
</ButtonContextMenu>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
+35
-20
@@ -6,12 +6,12 @@
|
||||
isSelectableRowType,
|
||||
} from '$lib/components/shared-components/album-selection/album-selection-utils';
|
||||
import { albumViewSettings } from '$lib/stores/preferences.store';
|
||||
import { type AlbumResponseDto, getAllAlbums } from '@immich/sdk';
|
||||
import { type AlbumResponseDto, createAlbum, getAllAlbums } from '@immich/sdk';
|
||||
import { Modal, ModalBody } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import AlbumListItem from '../../asset-viewer/album-list-item.svelte';
|
||||
import NewAlbumListItem from './new-album-list-item.svelte';
|
||||
import AlbumListItem from '../components/asset-viewer/album-list-item.svelte';
|
||||
import NewAlbumListItem from '../components/shared-components/album-selection/new-album-list-item.svelte';
|
||||
|
||||
let albums: AlbumResponseDto[] = $state([]);
|
||||
let recentAlbums: AlbumResponseDto[] = $state([]);
|
||||
@@ -20,13 +20,11 @@
|
||||
let selectedRowIndex: number = $state(-1);
|
||||
|
||||
interface Props {
|
||||
onNewAlbum: (search: string) => void;
|
||||
onAlbumClick: (album: AlbumResponseDto) => void;
|
||||
shared: boolean;
|
||||
onClose: () => void;
|
||||
onClose: (album?: AlbumResponseDto) => void;
|
||||
}
|
||||
|
||||
let { onNewAlbum, onAlbumClick, shared, onClose }: Props = $props();
|
||||
let { shared, onClose }: Props = $props();
|
||||
|
||||
onMount(async () => {
|
||||
albums = await getAllAlbums({ shared: shared || undefined });
|
||||
@@ -38,7 +36,34 @@
|
||||
const albumModalRows = $derived(rowConverter.toModalRows(search, recentAlbums, albums, selectedRowIndex));
|
||||
const selectableRowCount = $derived(albumModalRows.filter((row) => isSelectableRowType(row.type)).length);
|
||||
|
||||
const onkeydown = (e: KeyboardEvent) => {
|
||||
const onNewAlbum = async (name: string) => {
|
||||
const album = await createAlbum({ createAlbumDto: { albumName: name } });
|
||||
onClose(album);
|
||||
};
|
||||
|
||||
const onEnter = async () => {
|
||||
const item = albumModalRows.find(({ selected }) => selected);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (item.type) {
|
||||
case AlbumModalRowType.NEW_ALBUM: {
|
||||
await onNewAlbum(search);
|
||||
break;
|
||||
}
|
||||
case AlbumModalRowType.ALBUM_ITEM: {
|
||||
if (item.album) {
|
||||
onClose(item.album);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
selectedRowIndex = -1;
|
||||
};
|
||||
|
||||
const onkeydown = async (e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowUp': {
|
||||
e.preventDefault();
|
||||
@@ -60,15 +85,7 @@
|
||||
}
|
||||
case 'Enter': {
|
||||
e.preventDefault();
|
||||
const selectedRow = albumModalRows.find((row) => row.selected);
|
||||
if (selectedRow) {
|
||||
if (selectedRow.type === AlbumModalRowType.NEW_ALBUM) {
|
||||
onNewAlbum(search);
|
||||
} else if (selectedRow.type === AlbumModalRowType.ALBUM_ITEM && selectedRow.album) {
|
||||
onAlbumClick(selectedRow.album);
|
||||
}
|
||||
selectedRowIndex = -1;
|
||||
}
|
||||
await onEnter();
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
@@ -76,8 +93,6 @@
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAlbumClick = (album: AlbumResponseDto) => () => onAlbumClick(album);
|
||||
</script>
|
||||
|
||||
<Modal title={shared ? $t('add_to_shared_album') : $t('add_to_album')} {onClose} size="small">
|
||||
@@ -119,7 +134,7 @@
|
||||
album={row.album}
|
||||
selected={row.selected || false}
|
||||
searchQuery={search}
|
||||
onAlbumClick={handleAlbumClick(row.album)}
|
||||
onAlbumClick={() => onClose(row.album)}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user