feat: memory UI for birthday (#31798)

* feat: birthday memory UI

* chore: untrack .scratch

* make sure to render unknown

* confetti
This commit is contained in:
Alex
2026-09-26 09:02:46 -05:00
committed by GitHub
parent f8f4051a24
commit 9c06df378c
14 changed files with 210 additions and 44 deletions
+1
View File
@@ -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
+1
View File
@@ -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",
+12
View File
@@ -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
+1
View File
@@ -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",
+6
View File
@@ -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;
+32 -2
View File
@@ -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');
});
});
}); });
+1 -1
View File
@@ -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 } });
} }
+7 -1
View File
@@ -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}
+18 -33
View File
@@ -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}
@@ -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" />