diff --git a/.gitignore b/.gitignore index 9acfc8a879..e1a1554b7d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ !.vscode/launch.json !.vscode/extensions.json .idea +.scratch docker/upload docker/library diff --git a/i18n/en.json b/i18n/en.json index f9a5f14ecd..f4ed1fa120 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -669,6 +669,7 @@ "biometric_no_options": "No biometric options available", "biometric_not_available": "Biometric authentication is not available on this device", "birthdate_set_description": "Date of birth is used to calculate the age of this person at the time of a photo.", + "birthday_memory_age": "{age, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} birthday", "birthday_memory_title": "{name}'s birthday", "blurred_background": "Blurred background", "browse_templates": "Browse templates", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eeb904b0fe..640c52e748 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -893,6 +893,9 @@ importers: socket.io-client: specifier: ~4.8.0 version: 4.8.3(supports-color@8.1.1) + svelte-confetti: + specifier: ^2.3.2 + version: 2.3.2(svelte@5.57.0(@typescript-eslint/types@8.70.0)) svelte-gestures: specifier: ^5.2.2 version: 5.2.2 @@ -12199,6 +12202,11 @@ packages: svelte: ^4.0.0 || ^5.0.0-next.0 typescript: ^5.0.0 || ^6.0.0 + svelte-confetti@2.3.2: + resolution: {integrity: sha512-cfIwoGqMPYWRYDUz2g7mG1uHYWy7VBepelQdzCC3j/M42UrAqaBYmIi9xaoQfow4fbINHO9WuARnTyK2bjjGQg==} + peerDependencies: + svelte: '>=5.0.0' + svelte-eslint-parser@1.8.1: resolution: {integrity: sha512-5zgKBqAf6V8Jyrmr1jViksyG4NKT8NheYwkdxRaMRDAqpGWi9wR8ktcGrAZbfMn/PHUp1BWzAp0cYQECkWuAMA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0, pnpm: 10.34.5} @@ -26493,6 +26501,10 @@ snapshots: transitivePeerDependencies: - picomatch + svelte-confetti@2.3.2(svelte@5.57.0(@typescript-eslint/types@8.70.0)): + dependencies: + svelte: 5.57.0(@typescript-eslint/types@8.70.0) + svelte-eslint-parser@1.8.1(svelte@5.57.0(@typescript-eslint/types@8.70.0)): dependencies: eslint-scope: 8.4.0 diff --git a/web/package.json b/web/package.json index fa17e6d81b..13686f3c80 100644 --- a/web/package.json +++ b/web/package.json @@ -58,6 +58,7 @@ "qrcode": "^1.5.4", "simple-icons": "^16.0.0", "socket.io-client": "~4.8.0", + "svelte-confetti": "^2.3.2", "svelte-gestures": "^5.2.2", "svelte-i18n": "^4.0.1", "svelte-jsoneditor": "^3.10.0", diff --git a/web/src/app.css b/web/src/app.css index 4ee7e1b276..a4f8f48b11 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -22,6 +22,12 @@ --font-sans: 'GoogleSans', sans-serif; --font-mono: 'GoogleSansCode', monospace; + --color-logo-red: #fa2921; + --color-logo-pink: #ed79b5; + --color-logo-yellow: #ffb400; + --color-logo-blue: #1e83f7; + --color-logo-green: #18c249; + --spacing-18: 4.5rem; --breakpoint-tall: 800px; diff --git a/web/src/lib/components/memories/BirthdayConfetti.svelte b/web/src/lib/components/memories/BirthdayConfetti.svelte new file mode 100644 index 0000000000..851babc132 --- /dev/null +++ b/web/src/lib/components/memories/BirthdayConfetti.svelte @@ -0,0 +1,16 @@ + + + diff --git a/web/src/lib/components/memories/MemoryCard.svelte b/web/src/lib/components/memories/MemoryCard.svelte new file mode 100644 index 0000000000..8c92afaed0 --- /dev/null +++ b/web/src/lib/components/memories/MemoryCard.svelte @@ -0,0 +1,59 @@ + + + isBirthday && confettiCount++} +> + {item.alt + {#if item.isSaved} +
+ +
+ {/if} +
+

+ {#if isBirthday} + + + {#key confettiCount} + {#if confettiCount > 0} + + + + {/if} + {/key} + + {item.title} + {:else} + {item.title} + {/if} +

+
diff --git a/web/src/lib/managers/memory-manager.svelte.ts b/web/src/lib/managers/memory-manager.svelte.ts index 40a7794a56..a122044040 100644 --- a/web/src/lib/managers/memory-manager.svelte.ts +++ b/web/src/lib/managers/memory-manager.svelte.ts @@ -1,6 +1,7 @@ import { type AssetResponseDto, MemorySearchOrder, + MemoryType, deleteMemory, type MemoryResponseDto, removeMemoryAssets, @@ -351,6 +352,10 @@ class MemoryManager { } } + if (this.#filters.$for) { + this.memories.sort((a, b) => Number(b.type === MemoryType.Birthday) - Number(a.type === MemoryType.Birthday)); + } + if (this.#total === undefined) { const { total } = await memoriesStatistics(this.#filters); this.#total = total; diff --git a/web/src/lib/utils.spec.ts b/web/src/lib/utils.spec.ts index 992cd803e7..adeaba4ee8 100644 --- a/web/src/lib/utils.spec.ts +++ b/web/src/lib/utils.spec.ts @@ -1,5 +1,8 @@ -import { AssetTypeEnum } from '@immich/sdk'; -import { getAssetUrl, semverToName } from '$lib/utils'; +import { AssetTypeEnum, MemoryType, type MemoryResponseDto } from '@immich/sdk'; +import { addMessages, init } from 'svelte-i18n'; +import { get } from 'svelte/store'; +import en from '$i18n/en.json'; +import { getAssetUrl, memoryLaneTitle, semverToName } from '$lib/utils'; import { assetFactory } from '@test-data/factories/asset-factory'; import { sharedLinkFactory } from '@test-data/factories/shared-link-factory'; @@ -170,4 +173,31 @@ describe('utils', () => { expect(semverToName({ major: 3, minor: 0, patch: 0, prerelease: 0 })).toEqual('v3.0.0-rc.0'); }); }); + + describe('memoryLaneTitle', () => { + beforeAll(async () => { + addMessages('en', en); + await init({ fallbackLocale: 'en', initialLocale: 'en' }); + }); + + const birthday = (data: Partial, memoryAt = '2026-09-22T00:00:00.000Z') => + get(memoryLaneTitle)({ + type: MemoryType.Birthday, + memoryAt, + data: { year: 1990, personName: 'Alex', ...data }, + } as MemoryResponseDto); + + it('should name the person whose birthday it is', () => { + expect(birthday({ year: 1990 })).toBe("Alex's birthday"); + }); + + it('should not depend on the age, which the viewer shows per photo', () => { + expect(birthday({ year: 2025 })).toBe("Alex's birthday"); + expect(birthday({ year: 2026 }, '2027-01-04T00:00:00.000Z')).toBe("Alex's birthday"); + }); + + it('should fall back when the person has no name', () => { + expect(birthday({ personName: undefined })).toBe('Unknown'); + }); + }); }); diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index d89a7ca4b6..c49ba30b60 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -397,7 +397,7 @@ export const memoryLaneTitle = derived(t, ($t) => { : memoryDate.toLocaleString(DateTime.DATE_MED, { locale: get(locale) }); } - if (memory.type === MemoryType.Birthday && 'personName' in memory.data) { + if (memory.type === MemoryType.Birthday && memory.data.personName) { return $t('birthday_memory_title', { values: { name: memory.data.personName } }); } diff --git a/web/src/routes/(user)/explore/+page.svelte b/web/src/routes/(user)/explore/+page.svelte index c30f88be2d..3a8a8385e6 100644 --- a/web/src/routes/(user)/explore/+page.svelte +++ b/web/src/routes/(user)/explore/+page.svelte @@ -9,6 +9,7 @@ import { getAssetMediaUrl, getPeopleThumbnailUrl, memoryLaneTitle } from '$lib/utils'; import { getAssetInfo, AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk'; import { authManager } from '$lib/managers/auth-manager.svelte'; + import MemoryCard from '$lib/components/memories/MemoryCard.svelte'; import { Icon, ImageCarousel } from '@immich/ui'; import { mdiHeart } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -40,6 +41,7 @@ href: Route.viewMemory({ id: memory.id, assetId: memory.assets[0].id }), alt: $t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } }), src: getAssetMediaUrl({ id: memory.assets[0].id }), + type: memory.type, })), ); @@ -143,7 +145,11 @@ draggable="false">{$t('view_all')} - + + {#snippet child(item)} + + {/snippet} + {/if} diff --git a/web/src/routes/(user)/memories/+page.svelte b/web/src/routes/(user)/memories/+page.svelte index 1ef87221dc..02cf411304 100644 --- a/web/src/routes/(user)/memories/+page.svelte +++ b/web/src/routes/(user)/memories/+page.svelte @@ -3,6 +3,7 @@ import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte'; import { memoryManager } from '$lib/managers/memory-manager.svelte'; import { userPreferencesManager } from '$lib/managers/user-preferences-manager.svelte'; + import MemoryCard from '$lib/components/memories/MemoryCard.svelte'; import MemoriesSettingsModal from '$lib/modals/MemoriesSettingsModal.svelte'; import { Route } from '$lib/route'; import { locale } from '$lib/stores/preferences.store'; @@ -10,8 +11,8 @@ import { getAltText } from '$lib/utils/thumbnail-util'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import type { MemoryResponseDto } from '@immich/sdk'; - import { Icon, IconButton, LoadingSpinner, modalManager, type CarouselImageItem } from '@immich/ui'; - import { mdiHeart, mdiTune } from '@mdi/js'; + import { IconButton, LoadingSpinner, modalManager } from '@immich/ui'; + import { mdiTune } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -38,26 +39,6 @@ }; -{#snippet card(item: CarouselImageItem & { isSaved?: boolean })} - - {item.alt - {#if item.isSaved} -
- -
- {/if} -
-

- {item.title} -

-
-{/snippet} - {$t('memories_current')} {/if} - {@render card({ - title: $memoryLaneTitle(memory), - href: Route.viewMemory({ + {/each} {:else if memoryManager.loading} diff --git a/web/src/routes/(user)/memories/[id]/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte b/web/src/routes/(user)/memories/[id]/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte index d527890dfc..3a5968ca08 100644 --- a/web/src/routes/(user)/memories/[id]/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte +++ b/web/src/routes/(user)/memories/[id]/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte @@ -2,6 +2,7 @@ import { goto } from '$app/navigation'; import { scrollMemoryClearer } from '$lib/actions/scroll-memory'; import { shortcuts } from '$lib/actions/shortcut'; + import BirthdayConfetti from '$lib/components/memories/BirthdayConfetti.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte'; import GalleryViewer from '$lib/components/shared-components/gallery-viewer/GalleryViewer.svelte'; @@ -25,9 +26,10 @@ import { locale } from '$lib/stores/preferences.store'; import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils'; import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util'; - import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk'; - import { ActionButton, IconButton, Text } from '@immich/ui'; + import { AssetMediaSize, AssetTypeEnum, getAssetInfo, MemoryType } from '@immich/sdk'; + import { ActionButton, Icon, IconButton, Text } from '@immich/ui'; import { + mdiCakeVariant, mdiCardsOutline, mdiChevronDown, mdiChevronLeft, @@ -69,6 +71,15 @@ ); let currentTimelineAssets = $derived(current?.memory.assets ?? []); + const birthdayAge = $derived.by(() => { + if (current?.memory.type !== MemoryType.Birthday) { + return; + } + + const age = current.asset.localDateTime.year - current.memory.data.year; + return age >= 0 ? age : undefined; + }); + let viewerHeight = $state(0); const viewport: Viewport = $state({ width: 0, height: 0 }); @@ -307,6 +318,20 @@ bind:clientWidth={viewport.width} > {#if current} + {#key current.memory.id} + {#if current.memory.type === MemoryType.Birthday} +
+ +
+ {/if} + {/key}
{#if current}
@@ -319,7 +344,10 @@ size="large" onclick={() => goto(memoryManager.memoriesHref)} /> -

+

+ {#if current.memory.type === MemoryType.Birthday} + + {/if} {$memoryLaneTitle(current.memory)}

@@ -533,9 +561,12 @@

- {fromISODateTimeUTC(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, { - locale: $locale, - })} + {fromISODateTimeUTC(current.memory.assets[assetIndex].localDateTime).toLocaleString( + DateTime.DATE_FULL, + { + locale: $locale, + }, + )}

{#await currentMemoryAssetFull then asset} @@ -543,6 +574,13 @@ {asset?.exifInfo?.country || ''} {/await}

+ {#if birthdayAge !== undefined} +

+ + {$t('birthday_memory_age', { values: { age: birthdayAge } })} + +

+ {/if}
diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index d589fa77e6..f28918bbe5 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -32,6 +32,7 @@ import { getAltText } from '$lib/utils/thumbnail-util'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetVisibility } from '@immich/sdk'; + import MemoryCard from '$lib/components/memories/MemoryCard.svelte'; import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel } from '@immich/ui'; import { mdiDotsVertical } from '@mdi/js'; import { DateTime } from 'luxon'; @@ -83,6 +84,7 @@ href: Route.viewMemory({ id: memory.id, assetId: memory.assets[0].id }), alt: $t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } }), src: getAssetMediaUrl({ id: memory.assets[0].id }), + type: memory.type, })), ); @@ -100,7 +102,11 @@ withStacked > {#if authManager.preferences.memories.enabled} - + + {#snippet child(item)} + + {/snippet} + {/if} {#snippet empty()} openFileUploadDialog()} class="mx-auto mt-10" />