refactor: memory lane (#25134)

This commit is contained in:
Jason Rasmussen
2026-01-08 12:40:17 -05:00
committed by GitHub
parent 1f20b6471c
commit fbd49e0b79
9 changed files with 50 additions and 133 deletions

10
pnpm-lock.yaml generated
View File

@@ -726,8 +726,8 @@ importers:
specifier: file:../open-api/typescript-sdk
version: link:../open-api/typescript-sdk
'@immich/ui':
specifier: ^0.53.3
version: 0.53.3(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)
specifier: ^0.54.0
version: 0.54.0(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)
'@mapbox/mapbox-gl-rtl-text':
specifier: 0.2.3
version: 0.2.3(mapbox-gl@1.13.3)
@@ -3075,8 +3075,8 @@ packages:
peerDependencies:
svelte: ^5.0.0
'@immich/ui@0.53.3':
resolution: {integrity: sha512-Ax7ctU9KIZgET58+PoMQnf1XDOIH76Xa341TXDfLwF96F3fQZ/v4TA7Ycb6hmTwIYGU9arIgqGqQDbuuNxc2vA==}
'@immich/ui@0.54.0':
resolution: {integrity: sha512-6jvkvKhgsZ7LvspaJkbht/f8W5IRm+vjYkcZecShFAPaxaowbm7io9sO15MpJdIQfPdXg7vwLI527PV3vlBc6A==}
peerDependencies:
svelte: ^5.0.0
@@ -15078,7 +15078,7 @@ snapshots:
dependencies:
svelte: 5.46.1
'@immich/ui@0.53.3(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)':
'@immich/ui@0.54.0(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)':
dependencies:
'@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.46.1)
'@internationalized/date': 3.10.0

View File

@@ -27,7 +27,7 @@
"@formatjs/icu-messageformat-parser": "^3.0.0",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.53.3",
"@immich/ui": "^0.54.0",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.14.0",

View File

@@ -270,7 +270,7 @@
};
afterNavigate(({ from, to }) => {
memoryStore.initialize().then(
memoryStore.ready().then(
() => {
let target = null;
if (to?.params?.assetId) {

View File

@@ -1,107 +0,0 @@
<script lang="ts">
import { resizeObserver } from '$lib/actions/resize-observer';
import { AppRoute, QueryParameter } from '$lib/constants';
import { memoryStore } from '$lib/stores/memory.store.svelte';
import { getAssetThumbnailUrl, memoryLaneTitle } from '$lib/utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { Icon } from '@immich/ui';
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
let shouldRender = $derived(memoryStore.memories?.length > 0);
onMount(async () => {
await memoryStore.initialize();
});
let memoryLaneElement: HTMLElement | undefined = $state();
let offsetWidth = $state(0);
let innerWidth = $state(0);
let scrollLeftPosition = $state(0);
const onScroll = () => {
scrollLeftPosition = memoryLaneElement?.scrollLeft ?? 0;
};
let canScrollLeft = $derived(scrollLeftPosition > 0);
let canScrollRight = $derived(Math.ceil(scrollLeftPosition) < Math.floor(innerWidth - offsetWidth));
const scrollBy = 400;
const scrollLeft = () => memoryLaneElement?.scrollBy({ left: -scrollBy, behavior: 'smooth' });
const scrollRight = () => memoryLaneElement?.scrollBy({ left: scrollBy, behavior: 'smooth' });
</script>
{#if shouldRender}
<section
id="memory-lane"
bind:this={memoryLaneElement}
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}
>
{#if canScrollLeft || canScrollRight}
<div class="sticky start-0 z-1">
{#if canScrollLeft}
<div class="absolute start-4 max-md:top-19 top-27 -translate-y-1/2" transition:fade={{ duration: 200 }}>
<button
type="button"
class="rounded-full border border-gray-500 bg-gray-100 p-2 text-gray-500 opacity-50 hover:opacity-100"
title={$t('previous')}
aria-label={$t('previous')}
onclick={scrollLeft}
>
<Icon icon={mdiChevronLeft} size="36" aria-label={$t('previous')} /></button
>
</div>
{/if}
{#if canScrollRight}
<div class="absolute end-4 max-md:top-19 top-27 -translate-y-1/2 z-1" transition:fade={{ duration: 200 }}>
<button
type="button"
class="rounded-full border border-gray-500 bg-gray-100 p-2 text-gray-500 opacity-50 hover:opacity-100"
title={$t('next')}
aria-label={$t('next')}
onclick={scrollRight}
>
<Icon icon={mdiChevronRight} size="36" aria-label={$t('next')} /></button
>
</div>
{/if}
</div>
{/if}
<div class="inline-block" use:resizeObserver={({ width }) => (innerWidth = width)}>
{#each memoryStore.memories as memory (memory.id)}
<a
class="memory-card relative me-2 md:me-4 last:me-0 inline-block aspect-3/4 md:aspect-4/3 max-md:h-37.5 xl:aspect-video h-54 rounded-xl"
href="{AppRoute.MEMORY}?{QueryParameter.ID}={memory.assets[0].id}"
>
<img
class="h-full w-full rounded-xl object-cover"
src={getAssetThumbnailUrl(memory.assets[0].id)}
alt={$t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } })}
draggable="false"
/>
<div
class="absolute start-0 top-0 h-full w-full rounded-xl bg-linear-to-t from-black/40 via-transparent to-transparent transition-all hover:bg-black/20"
></div>
<p class="absolute bottom-2 start-4 text-lg text-white max-md:text-sm">
{$memoryLaneTitle(memory)}
</p>
</a>
{/each}
</div>
</section>
{/if}
<style>
.memory-card {
box-shadow:
rgba(60, 64, 67, 0.3) 0px 1px 2px 0px,
rgba(60, 64, 67, 0.15) 0px 1px 3px 1px;
}
</style>

View File

@@ -15,9 +15,11 @@ import type {
export type Events = {
AppInit: [];
UserLogin: [];
AuthLogin: [LoginResponseDto];
AuthLogout: [];
AuthUserLoaded: [UserAdminResponseDto];
LanguageChange: [{ name: string; code: string; rtl?: boolean }];
ThemeChange: [ThemeSetting];

View File

@@ -20,12 +20,18 @@ export type MemoryAsset = MemoryIndex & {
};
class MemoryStoreSvelte {
#loading: Promise<void> | undefined;
constructor() {
eventManager.on('AuthLogout', () => this.clearCache());
eventManager.on('AuthUserLoaded', () => void this.initialize());
}
ready() {
return this.initialize();
}
memories = $state<MemoryResponseDto[]>([]);
private initialized = false;
private memoryAssets = $derived.by(() => {
const memoryAssets: MemoryAsset[] = [];
let previous: MemoryAsset | undefined;
@@ -101,21 +107,20 @@ class MemoryStoreSvelte {
}
}
async initialize() {
if (this.initialized) {
return;
}
this.initialized = true;
await this.loadAllMemories();
}
clearCache() {
this.initialized = false;
private clearCache() {
this.#loading = undefined;
this.memories = [];
}
private async loadAllMemories() {
private initialize() {
if (!this.#loading) {
this.#loading = this.load();
}
return this.#loading;
}
private async load() {
const memories = await searchMemories({ $for: asLocalTimeISO(DateTime.now()) });
this.memories = memories.filter((memory) => memory.assets.length > 0);
}

View File

@@ -1,4 +1,5 @@
import { browser } from '$app/environment';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { purchaseStore } from '$lib/stores/purchase.store';
import { preferences as preferences$, user as user$ } from '$lib/stores/user.store';
import { userInteraction } from '$lib/stores/user.svelte';
@@ -24,6 +25,8 @@ export const loadUser = async () => {
user$.set(user);
preferences$.set(preferences);
eventManager.emit('AuthUserLoaded', user);
// Check for license status
if (serverInfo.licensed || user.license?.activatedAt) {
purchaseStore.setPurchaseStatus(true);

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import { beforeNavigate } from '$app/navigation';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import MemoryLane from '$lib/components/photos-page/memory-lane.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte';
@@ -21,12 +20,14 @@
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
import { AssetAction } from '$lib/constants';
import { AppRoute, AssetAction, QueryParameter } from '$lib/constants';
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 { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { memoryStore } from '$lib/stores/memory.store.svelte';
import { preferences, user } from '$lib/stores/user.store';
import { getAssetThumbnailUrl, memoryLaneTitle } from '$lib/utils';
import {
updateStackedAssetInTimeline,
updateUnstackedAssetInTimeline,
@@ -34,10 +35,11 @@
type OnUnlink,
} from '$lib/utils/actions';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetVisibility } from '@immich/sdk';
import { ImageCarousel } from '@immich/ui';
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
let { isViewing: showAssetViewer } = assetViewingStore;
@@ -85,6 +87,16 @@
beforeNavigate(() => {
isFaceEditMode.value = false;
});
const items = $derived(
memoryStore.memories.map((memory) => ({
id: memory.id,
title: $memoryLaneTitle(memory),
href: `${AppRoute.MEMORY}?${QueryParameter.ID}=${memory.assets[0].id}`,
alt: $t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } }),
src: getAssetThumbnailUrl(memory.assets[0].id),
})),
);
</script>
<UserPageLayout hideNavbar={assetInteraction.selectionActive} showUploadButton scrollbar={false}>
@@ -98,7 +110,7 @@
withStacked
>
{#if $preferences.memories.enabled}
<MemoryLane />
<ImageCarousel {items} />
{/if}
{#snippet empty()}
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} class="mt-10 mx-auto" />

View File

@@ -49,6 +49,8 @@
toast_info_title: $t('info'),
toast_warning_title: $t('warning'),
toast_danger_title: $t('error'),
navigate_next: $t('next'),
navigate_previous: $t('previous'),
});
});