mirror of
https://github.com/immich-app/immich.git
synced 2026-09-30 13:23:21 +08:00
feat: memory UI for birthday (#31798)
* feat: birthday memory UI * chore: untrack .scratch * make sure to render unknown * confetti
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
!.vscode/launch.json
|
!.vscode/launch.json
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
.idea
|
.idea
|
||||||
|
.scratch
|
||||||
|
|
||||||
docker/upload
|
docker/upload
|
||||||
docker/library
|
docker/library
|
||||||
|
|||||||
@@ -669,6 +669,7 @@
|
|||||||
"biometric_no_options": "No biometric options available",
|
"biometric_no_options": "No biometric options available",
|
||||||
"biometric_not_available": "Biometric authentication is not available on this device",
|
"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.",
|
"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",
|
"birthday_memory_title": "{name}'s birthday",
|
||||||
"blurred_background": "Blurred background",
|
"blurred_background": "Blurred background",
|
||||||
"browse_templates": "Browse templates",
|
"browse_templates": "Browse templates",
|
||||||
|
|||||||
Generated
+12
@@ -893,6 +893,9 @@ importers:
|
|||||||
socket.io-client:
|
socket.io-client:
|
||||||
specifier: ~4.8.0
|
specifier: ~4.8.0
|
||||||
version: 4.8.3(supports-color@8.1.1)
|
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:
|
svelte-gestures:
|
||||||
specifier: ^5.2.2
|
specifier: ^5.2.2
|
||||||
version: 5.2.2
|
version: 5.2.2
|
||||||
@@ -12199,6 +12202,11 @@ packages:
|
|||||||
svelte: ^4.0.0 || ^5.0.0-next.0
|
svelte: ^4.0.0 || ^5.0.0-next.0
|
||||||
typescript: ^5.0.0 || ^6.0.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:
|
svelte-eslint-parser@1.8.1:
|
||||||
resolution: {integrity: sha512-5zgKBqAf6V8Jyrmr1jViksyG4NKT8NheYwkdxRaMRDAqpGWi9wR8ktcGrAZbfMn/PHUp1BWzAp0cYQECkWuAMA==}
|
resolution: {integrity: sha512-5zgKBqAf6V8Jyrmr1jViksyG4NKT8NheYwkdxRaMRDAqpGWi9wR8ktcGrAZbfMn/PHUp1BWzAp0cYQECkWuAMA==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0, pnpm: 10.34.5}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0, pnpm: 10.34.5}
|
||||||
@@ -26493,6 +26501,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- picomatch
|
- 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)):
|
svelte-eslint-parser@1.8.1(svelte@5.57.0(@typescript-eslint/types@8.70.0)):
|
||||||
dependencies:
|
dependencies:
|
||||||
eslint-scope: 8.4.0
|
eslint-scope: 8.4.0
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"simple-icons": "^16.0.0",
|
"simple-icons": "^16.0.0",
|
||||||
"socket.io-client": "~4.8.0",
|
"socket.io-client": "~4.8.0",
|
||||||
|
"svelte-confetti": "^2.3.2",
|
||||||
"svelte-gestures": "^5.2.2",
|
"svelte-gestures": "^5.2.2",
|
||||||
"svelte-i18n": "^4.0.1",
|
"svelte-i18n": "^4.0.1",
|
||||||
"svelte-jsoneditor": "^3.10.0",
|
"svelte-jsoneditor": "^3.10.0",
|
||||||
|
|||||||
@@ -22,6 +22,12 @@
|
|||||||
--font-sans: 'GoogleSans', sans-serif;
|
--font-sans: 'GoogleSans', sans-serif;
|
||||||
--font-mono: 'GoogleSansCode', monospace;
|
--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;
|
--spacing-18: 4.5rem;
|
||||||
|
|
||||||
--breakpoint-tall: 800px;
|
--breakpoint-tall: 800px;
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
|
import { Confetti } from 'svelte-confetti';
|
||||||
|
|
||||||
|
const props: ComponentProps<typeof Confetti> = $props();
|
||||||
|
|
||||||
|
const colorArray = [
|
||||||
|
'var(--color-logo-red)',
|
||||||
|
'var(--color-logo-pink)',
|
||||||
|
'var(--color-logo-yellow)',
|
||||||
|
'var(--color-logo-blue)',
|
||||||
|
'var(--color-logo-green)',
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Confetti {colorArray} disableForReducedMotion {...props} />
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { MemoryType } from '@immich/sdk';
|
||||||
|
import { Icon, type CarouselImageItem } from '@immich/ui';
|
||||||
|
import { mdiCakeVariant, mdiHeart } from '@mdi/js';
|
||||||
|
import BirthdayConfetti from './BirthdayConfetti.svelte';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
item: MemoryCardItem;
|
||||||
|
class?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MemoryCardItem = CarouselImageItem & { isSaved?: boolean; type?: MemoryType };
|
||||||
|
|
||||||
|
const { item, class: className = '' }: Props = $props();
|
||||||
|
|
||||||
|
const isBirthday = $derived(item.type === MemoryType.Birthday);
|
||||||
|
|
||||||
|
let confettiCount = $state(0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="relative me-2 inline-block aspect-3/4 h-54 rounded-xl shadow-sm last:me-0 max-md:h-37.5 md:me-4 md:aspect-4/3 xl:aspect-video {className}"
|
||||||
|
href={item.href}
|
||||||
|
onmouseenter={() => isBirthday && confettiCount++}
|
||||||
|
>
|
||||||
|
<img class="size-full rounded-xl object-cover" src={item.src} alt={item.alt ?? item.title} draggable="false" />
|
||||||
|
{#if item.isSaved}
|
||||||
|
<div class="absolute inset-s-2 top-2 p-1">
|
||||||
|
<Icon data-icon-favorite icon={mdiHeart} size="32" class="text-white" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div
|
||||||
|
class="absolute inset-s-0 top-0 size-full rounded-xl bg-linear-to-t from-black/40 via-transparent to-transparent transition-all hover:bg-black/20"
|
||||||
|
></div>
|
||||||
|
<p class="absolute inset-s-4 bottom-2 flex items-center gap-2 text-lg text-white max-md:text-sm">
|
||||||
|
{#if isBirthday}
|
||||||
|
<span class="relative">
|
||||||
|
<Icon data-icon-birthday icon={mdiCakeVariant} size="1.25em" />
|
||||||
|
{#key confettiCount}
|
||||||
|
{#if confettiCount > 0}
|
||||||
|
<span class="absolute inset-s-1/2 top-1/2">
|
||||||
|
<BirthdayConfetti
|
||||||
|
amount={40}
|
||||||
|
x={[-0.25, 1]}
|
||||||
|
y={[0.25, 0.9]}
|
||||||
|
size={8}
|
||||||
|
duration={1500}
|
||||||
|
fallDistance="50px"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{/key}
|
||||||
|
</span>
|
||||||
|
<span class="min-w-0 truncate rounded-sm bg-logo-yellow px-1.5 py-0.5 text-black">{item.title}</span>
|
||||||
|
{:else}
|
||||||
|
{item.title}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
type AssetResponseDto,
|
type AssetResponseDto,
|
||||||
MemorySearchOrder,
|
MemorySearchOrder,
|
||||||
|
MemoryType,
|
||||||
deleteMemory,
|
deleteMemory,
|
||||||
type MemoryResponseDto,
|
type MemoryResponseDto,
|
||||||
removeMemoryAssets,
|
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) {
|
if (this.#total === undefined) {
|
||||||
const { total } = await memoriesStatistics(this.#filters);
|
const { total } = await memoriesStatistics(this.#filters);
|
||||||
this.#total = total;
|
this.#total = total;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { AssetTypeEnum } from '@immich/sdk';
|
import { AssetTypeEnum, MemoryType, type MemoryResponseDto } from '@immich/sdk';
|
||||||
import { getAssetUrl, semverToName } from '$lib/utils';
|
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 { assetFactory } from '@test-data/factories/asset-factory';
|
||||||
import { sharedLinkFactory } from '@test-data/factories/shared-link-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');
|
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<MemoryResponseDto['data']>, 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -397,7 +397,7 @@ export const memoryLaneTitle = derived(t, ($t) => {
|
|||||||
: memoryDate.toLocaleString(DateTime.DATE_MED, { locale: get(locale) });
|
: 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 } });
|
return $t('birthday_memory_title', { values: { name: memory.data.personName } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { getAssetMediaUrl, getPeopleThumbnailUrl, memoryLaneTitle } from '$lib/utils';
|
import { getAssetMediaUrl, getPeopleThumbnailUrl, memoryLaneTitle } from '$lib/utils';
|
||||||
import { getAssetInfo, AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
|
import { getAssetInfo, AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
|
import MemoryCard from '$lib/components/memories/MemoryCard.svelte';
|
||||||
import { Icon, ImageCarousel } from '@immich/ui';
|
import { Icon, ImageCarousel } from '@immich/ui';
|
||||||
import { mdiHeart } from '@mdi/js';
|
import { mdiHeart } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
href: Route.viewMemory({ id: memory.id, assetId: memory.assets[0].id }),
|
href: Route.viewMemory({ id: memory.id, assetId: memory.assets[0].id }),
|
||||||
alt: $t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } }),
|
alt: $t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } }),
|
||||||
src: getAssetMediaUrl({ id: memory.assets[0].id }),
|
src: getAssetMediaUrl({ id: memory.assets[0].id }),
|
||||||
|
type: memory.type,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -143,7 +145,11 @@
|
|||||||
draggable="false">{$t('view_all')}</a
|
draggable="false">{$t('view_all')}</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<ImageCarousel items={memories} />
|
<ImageCarousel items={memories}>
|
||||||
|
{#snippet child(item)}
|
||||||
|
<MemoryCard {item} />
|
||||||
|
{/snippet}
|
||||||
|
</ImageCarousel>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
|
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
|
||||||
import { memoryManager } from '$lib/managers/memory-manager.svelte';
|
import { memoryManager } from '$lib/managers/memory-manager.svelte';
|
||||||
import { userPreferencesManager } from '$lib/managers/user-preferences-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 MemoriesSettingsModal from '$lib/modals/MemoriesSettingsModal.svelte';
|
||||||
import { Route } from '$lib/route';
|
import { Route } from '$lib/route';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
@@ -10,8 +11,8 @@
|
|||||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import type { MemoryResponseDto } from '@immich/sdk';
|
import type { MemoryResponseDto } from '@immich/sdk';
|
||||||
import { Icon, IconButton, LoadingSpinner, modalManager, type CarouselImageItem } from '@immich/ui';
|
import { IconButton, LoadingSpinner, modalManager } from '@immich/ui';
|
||||||
import { mdiHeart, mdiTune } from '@mdi/js';
|
import { mdiTune } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
@@ -38,26 +39,6 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet card(item: CarouselImageItem & { isSaved?: boolean })}
|
|
||||||
<a
|
|
||||||
class="item-card relative me-2 inline-block aspect-3/4 size-full overflow-hidden rounded-xl last:me-0 max-md:h-37.5 md:me-4 md:aspect-4/3 xl:aspect-video"
|
|
||||||
href={item.href}
|
|
||||||
>
|
|
||||||
<img class="size-full rounded-xl object-cover" src={item.src} alt={item.alt ?? item.title} draggable="false" />
|
|
||||||
{#if item.isSaved}
|
|
||||||
<div class="absolute inset-s-2 top-2 p-1">
|
|
||||||
<Icon data-icon-favorite icon={mdiHeart} size="32" class="text-white" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div
|
|
||||||
class="absolute inset-s-0 top-0 size-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 inset-s-4 bottom-2 text-lg text-white max-md:text-sm">
|
|
||||||
{item.title}
|
|
||||||
</p>
|
|
||||||
</a>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<UserPageLayout
|
<UserPageLayout
|
||||||
title={data.meta.title}
|
title={data.meta.title}
|
||||||
description={memoryManager.total === undefined ? undefined : `(${memoryManager.total.toLocaleString($locale)})`}
|
description={memoryManager.total === undefined ? undefined : `(${memoryManager.total.toLocaleString($locale)})`}
|
||||||
@@ -85,18 +66,22 @@
|
|||||||
{#if hasUpcoming && index === upcomingCount}
|
{#if hasUpcoming && index === upcomingCount}
|
||||||
<div class="col-span-full outline-none">{$t('memories_current')}</div>
|
<div class="col-span-full outline-none">{$t('memories_current')}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{@render card({
|
<MemoryCard
|
||||||
title: $memoryLaneTitle(memory),
|
class="size-full"
|
||||||
href: Route.viewMemory({
|
item={{
|
||||||
id: memory.id,
|
id: memory.id,
|
||||||
assetId: memory.assets[0].id,
|
title: $memoryLaneTitle(memory),
|
||||||
isSaved: userPreferencesManager.memories.onlyFavorites || undefined,
|
href: Route.viewMemory({
|
||||||
}),
|
id: memory.id,
|
||||||
src: getAssetMediaUrl({ id: memory.assets[0].id }),
|
assetId: memory.assets[0].id,
|
||||||
alt: $getAltText(toTimelineAsset(memory.assets[0])),
|
isSaved: userPreferencesManager.memories.onlyFavorites || undefined,
|
||||||
isSaved: memory.isSaved,
|
}),
|
||||||
id: memory.id,
|
src: getAssetMediaUrl({ id: memory.assets[0].id }),
|
||||||
})}
|
alt: $getAltText(toTimelineAsset(memory.assets[0])),
|
||||||
|
isSaved: memory.isSaved,
|
||||||
|
type: memory.type,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else if memoryManager.loading}
|
{:else if memoryManager.loading}
|
||||||
|
|||||||
+44
-6
@@ -2,6 +2,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
|
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
|
||||||
import { shortcuts } from '$lib/actions/shortcut';
|
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 ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
|
||||||
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
|
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
|
||||||
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/GalleryViewer.svelte';
|
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/GalleryViewer.svelte';
|
||||||
@@ -25,9 +26,10 @@
|
|||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
|
import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
|
||||||
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk';
|
import { AssetMediaSize, AssetTypeEnum, getAssetInfo, MemoryType } from '@immich/sdk';
|
||||||
import { ActionButton, IconButton, Text } from '@immich/ui';
|
import { ActionButton, Icon, IconButton, Text } from '@immich/ui';
|
||||||
import {
|
import {
|
||||||
|
mdiCakeVariant,
|
||||||
mdiCardsOutline,
|
mdiCardsOutline,
|
||||||
mdiChevronDown,
|
mdiChevronDown,
|
||||||
mdiChevronLeft,
|
mdiChevronLeft,
|
||||||
@@ -69,6 +71,15 @@
|
|||||||
);
|
);
|
||||||
let currentTimelineAssets = $derived(current?.memory.assets ?? []);
|
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);
|
let viewerHeight = $state(0);
|
||||||
|
|
||||||
const viewport: Viewport = $state({ width: 0, height: 0 });
|
const viewport: Viewport = $state({ width: 0, height: 0 });
|
||||||
@@ -307,6 +318,20 @@
|
|||||||
bind:clientWidth={viewport.width}
|
bind:clientWidth={viewport.width}
|
||||||
>
|
>
|
||||||
{#if current}
|
{#if current}
|
||||||
|
{#key current.memory.id}
|
||||||
|
{#if current.memory.type === MemoryType.Birthday}
|
||||||
|
<div class="pointer-events-none fixed inset-x-0 -top-12 z-1 flex h-screen justify-center overflow-hidden">
|
||||||
|
<BirthdayConfetti
|
||||||
|
x={[-5, 5]}
|
||||||
|
y={[0, 0.1]}
|
||||||
|
delay={[0, 1500]}
|
||||||
|
duration={4000}
|
||||||
|
amount={200}
|
||||||
|
fallDistance="100vh"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/key}
|
||||||
<div class="dark grid grid-cols-[100%] p-2 max-md:h-auto max-md:flex-col md:grid-cols-[25%_50%_25%] md:p-4">
|
<div class="dark grid grid-cols-[100%] p-2 max-md:h-auto max-md:flex-col md:grid-cols-[25%_50%_25%] md:p-4">
|
||||||
{#if current}
|
{#if current}
|
||||||
<div class="flex items-center gap-2 md:gap-6">
|
<div class="flex items-center gap-2 md:gap-6">
|
||||||
@@ -319,7 +344,10 @@
|
|||||||
size="large"
|
size="large"
|
||||||
onclick={() => goto(memoryManager.memoriesHref)}
|
onclick={() => goto(memoryManager.memoriesHref)}
|
||||||
/>
|
/>
|
||||||
<p class="text-lg">
|
<p class="flex items-center gap-2 text-lg">
|
||||||
|
{#if current.memory.type === MemoryType.Birthday}
|
||||||
|
<Icon icon={mdiCakeVariant} size="1.25em" />
|
||||||
|
{/if}
|
||||||
{$memoryLaneTitle(current.memory)}
|
{$memoryLaneTitle(current.memory)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -533,9 +561,12 @@
|
|||||||
|
|
||||||
<div class="absolute inset-s-8 top-4 text-sm font-medium text-white">
|
<div class="absolute inset-s-8 top-4 text-sm font-medium text-white">
|
||||||
<p>
|
<p>
|
||||||
{fromISODateTimeUTC(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, {
|
{fromISODateTimeUTC(current.memory.assets[assetIndex].localDateTime).toLocaleString(
|
||||||
locale: $locale,
|
DateTime.DATE_FULL,
|
||||||
})}
|
{
|
||||||
|
locale: $locale,
|
||||||
|
},
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{#await currentMemoryAssetFull then asset}
|
{#await currentMemoryAssetFull then asset}
|
||||||
@@ -543,6 +574,13 @@
|
|||||||
{asset?.exifInfo?.country || ''}
|
{asset?.exifInfo?.country || ''}
|
||||||
{/await}
|
{/await}
|
||||||
</p>
|
</p>
|
||||||
|
{#if birthdayAge !== undefined}
|
||||||
|
<p class="mt-1 flex items-center gap-2">
|
||||||
|
<span class="rounded-sm bg-logo-yellow px-1.5 py-0.5 whitespace-nowrap text-black">
|
||||||
|
{$t('birthday_memory_age', { values: { age: birthdayAge } })}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import { AssetVisibility } from '@immich/sdk';
|
import { AssetVisibility } from '@immich/sdk';
|
||||||
|
import MemoryCard from '$lib/components/memories/MemoryCard.svelte';
|
||||||
import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel } from '@immich/ui';
|
import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel } from '@immich/ui';
|
||||||
import { mdiDotsVertical } from '@mdi/js';
|
import { mdiDotsVertical } from '@mdi/js';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
@@ -83,6 +84,7 @@
|
|||||||
href: Route.viewMemory({ id: memory.id, assetId: memory.assets[0].id }),
|
href: Route.viewMemory({ id: memory.id, assetId: memory.assets[0].id }),
|
||||||
alt: $t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } }),
|
alt: $t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } }),
|
||||||
src: getAssetMediaUrl({ id: memory.assets[0].id }),
|
src: getAssetMediaUrl({ id: memory.assets[0].id }),
|
||||||
|
type: memory.type,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -100,7 +102,11 @@
|
|||||||
withStacked
|
withStacked
|
||||||
>
|
>
|
||||||
{#if authManager.preferences.memories.enabled}
|
{#if authManager.preferences.memories.enabled}
|
||||||
<ImageCarousel {items} />
|
<ImageCarousel {items}>
|
||||||
|
{#snippet child(item)}
|
||||||
|
<MemoryCard {item} />
|
||||||
|
{/snippet}
|
||||||
|
</ImageCarousel>
|
||||||
{/if}
|
{/if}
|
||||||
{#snippet empty()}
|
{#snippet empty()}
|
||||||
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} class="mx-auto mt-10" />
|
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} class="mx-auto mt-10" />
|
||||||
|
|||||||
Reference in New Issue
Block a user