Show on-this-day memories from the last 20 years with automatic playback

This commit is contained in:
Codex
2026-10-18 19:00:06 +03:00
parent 04a7b4104f
commit ca7766fcd6
7 changed files with 526 additions and 48 deletions
+5 -16
View File
@@ -109,18 +109,7 @@ with
select select
make_date(year::int, $1::int, $2::int) as "date" make_date(year::int, $1::int, $2::int) as "date"
from from
generate_series( generate_series($3::int, $4::int) as "year"
(
select
date_part(
'year',
min(("localDateTime" at time zone 'UTC')::date)
)::int
from
asset
),
$3
) as "year"
) )
select select
"a".* "a".*
@@ -135,21 +124,21 @@ with
inner join "asset_job_status" on "asset"."id" = "asset_job_status"."assetId" inner join "asset_job_status" on "asset"."id" = "asset_job_status"."assetId"
where where
(asset."localDateTime" at time zone 'UTC')::date = today.date (asset."localDateTime" at time zone 'UTC')::date = today.date
and "asset"."ownerId" = any ($4::uuid[]) and "asset"."ownerId" = any ($5::uuid[])
and "asset"."visibility" = $5 and "asset"."visibility" = $6
and exists ( and exists (
select select
from from
"asset_file" "asset_file"
where where
"assetId" = "asset"."id" "assetId" = "asset"."id"
and "asset_file"."type" = $6 and "asset_file"."type" = $7
) )
and "asset"."deletedAt" is null and "asset"."deletedAt" is null
order by order by
(asset."localDateTime" at time zone 'UTC')::date desc (asset."localDateTime" at time zone 'UTC')::date desc
limit limit
$7 $8
) as "a" on true ) as "a" on true
) )
select select
+13 -9
View File
@@ -464,15 +464,19 @@ export class AssetRepository {
qb qb
.with('today', (qb) => .with('today', (qb) =>
qb qb
.selectFrom((eb) => .selectFrom((eb) => eb.fn('generate_series', [sql`${year - 20}::int`, sql`${year - 1}::int`]).as('year'))
eb .select((eb) => {
.fn('generate_series', [ const date = eb.fn('make_date', [sql`year::int`, sql`${month}::int`, sql`${day}::int`]);
sql`(select date_part('year', min(("localDateTime" at time zone 'UTC')::date))::int from asset)`, return (
sql`${year - 1}`, month === 2 && day === 29
]) ? eb
.as('year'), .case()
) .when(sql<boolean>`year::int % 4 = 0 and (year::int % 100 != 0 or year::int % 400 = 0)`)
.select((eb) => eb.fn('make_date', [sql`year::int`, sql`${month}::int`, sql`${day}::int`]).as('date')), .then(date)
.end()
: date
).as('date');
}),
) )
.selectFrom('today') .selectFrom('today')
.innerJoinLateral( .innerJoinLateral(
@@ -1,5 +1,5 @@
import { Kysely } from 'kysely'; import { Kysely } from 'kysely';
import { AssetOrder, AssetOrderBy, AssetVisibility } from 'src/enum.js'; import { AssetFileType, AssetOrder, AssetOrderBy, AssetVisibility } from 'src/enum.js';
import { AssetRepository } from 'src/repositories/asset.repository.js'; import { AssetRepository } from 'src/repositories/asset.repository.js';
import { LoggingRepository } from 'src/repositories/logging.repository.js'; import { LoggingRepository } from 'src/repositories/logging.repository.js';
import { DB } from 'src/schema/index.js'; import { DB } from 'src/schema/index.js';
@@ -64,6 +64,67 @@ const keyframeRow = (assetId: string, n: number) => ({
}); });
describe(AssetRepository.name, () => { describe(AssetRepository.name, () => {
describe('getByDayOfYear', () => {
it('should include every year from one through twenty years ago on the same local calendar day', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
const target = { year: 2026, month: 10, day: 18 };
const expectedAssetIds = [];
for (let yearsAgo = 0; yearsAgo <= 21; yearsAgo++) {
const { asset } = await ctx.newAsset({
ownerId: user.id,
localDateTime: `${target.year - yearsAgo}-10-18T12:00:00.000Z`,
});
await ctx.newJobStatus({ assetId: asset.id });
await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: '/preview.jpg' });
if (yearsAgo >= 1 && yearsAgo <= 20) {
expectedAssetIds.push(asset.id);
}
}
for (const dto of [
{ ownerId: user.id, localDateTime: '2025-10-17T23:59:59.999Z' },
{ ownerId: user.id, localDateTime: '2025-10-19T00:00:00.000Z' },
{ ownerId: otherUser.id, localDateTime: '2025-10-18T12:00:00.000Z' },
]) {
const { asset } = await ctx.newAsset(dto);
await ctx.newJobStatus({ assetId: asset.id });
await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: '/preview.jpg' });
}
const memories = await sut.getByDayOfYear([user.id], target);
expect(memories.map(({ year }) => year)).toEqual(Array.from({ length: 20 }, (_, index) => 2025 - index));
expect(memories.flatMap(({ assets }) => assets.map(({ id }) => id))).toEqual(expectedAssetIds);
});
it.each([2028, 2104])('should skip non-leap years on February 29th in %i', async (year) => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const expectedYears = [];
for (let yearsAgo = 1; yearsAgo <= 20; yearsAgo++) {
const assetYear = year - yearsAgo;
const isLeapYear = assetYear % 4 === 0 && (assetYear % 100 !== 0 || assetYear % 400 === 0);
const { asset } = await ctx.newAsset({
ownerId: user.id,
localDateTime: `${assetYear}-${isLeapYear ? '02-29' : '03-01'}T12:00:00.000Z`,
});
await ctx.newJobStatus({ assetId: asset.id });
await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: '/preview.jpg' });
if (isLeapYear) {
expectedYears.push(assetYear);
}
}
const memories = await sut.getByDayOfYear([user.id], { year, month: 2, day: 29 });
expect(memories.map(({ year }) => year)).toEqual(expectedYears);
});
});
describe('getTimeBucket', () => { describe('getTimeBucket', () => {
it('should order assets by local day first and fileCreatedAt within each day', async () => { it('should order assets by local day first and fileCreatedAt within each day', async () => {
const { ctx, sut } = setup(); const { ctx, sut } = setup();
@@ -0,0 +1,178 @@
import { MemoryType, type MemoryResponseDto } from '@immich/sdk';
import { cleanup, fireEvent, render } from '@testing-library/svelte';
import { tick } from 'svelte';
import { assetFactory } from '@test-data/factories/asset-factory';
import MemoriesLane from './__test__/MemoriesLaneHarness.svelte';
const date = '2026-10-18';
const makeMemory = (year: number, assetCount = 2): MemoryResponseDto => ({
id: `memory-${year}`,
type: MemoryType.OnThisDay,
data: { year },
assets: Array.from({ length: assetCount }, (_, index) => assetFactory.build({ id: `asset-${year}-${index}` })),
createdAt: date,
updatedAt: date,
memoryAt: `${year}-10-18T00:00:00.000Z`,
showAt: `${date}T00:00:00.000Z`,
ownerId: 'owner',
isSaved: false,
});
describe('MemoriesLane', () => {
let intersectionCallback: IntersectionObserverCallback;
const disconnect = vi.fn();
const intersect = async (isIntersecting: boolean) => {
intersectionCallback([{ isIntersecting } as IntersectionObserverEntry], {} as IntersectionObserver);
await tick();
};
const advance = async () => {
await vi.advanceTimersByTimeAsync(5000);
await tick();
};
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(`${date}T12:00:00Z`));
vi.stubGlobal(
'IntersectionObserver',
vi.fn(function (callback: IntersectionObserverCallback) {
intersectionCallback = callback;
return { observe: vi.fn(), disconnect, unobserve: vi.fn(), takeRecords: vi.fn() };
}),
);
Object.defineProperty(document, 'hidden', { configurable: true, value: false });
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
disconnect.mockClear();
});
it('shows today from 1 through 20 years ago, recent years first, and keeps birthdays', () => {
const yesterday = { ...makeMemory(2024), memoryAt: '2024-10-17T00:00:00.000Z' };
const stale = { ...makeMemory(2023), showAt: '2025-10-18T00:00:00.000Z' };
const birthday = { ...makeMemory(2022), type: MemoryType.Birthday, data: { year: 2022, personName: 'Alex' } };
const { getAllByRole } = render(MemoriesLane, {
date,
memories: [makeMemory(2005), makeMemory(2006), makeMemory(2026), yesterday, stale, makeMemory(2025), birthday],
});
expect(getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual([
'/memories/memory-2022?assetId=asset-2022-0',
'/memories/memory-2025?assetId=asset-2025-0',
'/memories/memory-2006?assetId=asset-2006-0',
]);
});
it('rotates each card every five seconds, opens its displayed asset, and wraps its photos', async () => {
const { getByRole, getAllByRole } = render(MemoriesLane, {
date,
memories: [makeMemory(2025), makeMemory(2024, 3)],
});
await intersect(true);
await advance();
const link = getAllByRole('link')[0];
expect(link.querySelector('img')?.src).toContain('asset-2025-1');
let clickedHref: string | null = null;
link.addEventListener('click', (event) => {
event.preventDefault();
clickedHref = (event.currentTarget as HTMLAnchorElement).getAttribute('href');
});
await fireEvent.click(link);
expect(clickedHref).toBe('/memories/memory-2025?assetId=asset-2025-1');
await advance();
expect(link.querySelector('img')?.src).toContain('asset-2025-0');
expect(getAllByRole('link')[1].querySelector('img')?.src).toContain('asset-2024-2');
expect(getByRole('button', { name: 'pause_memories' })).toHaveAttribute('aria-pressed', 'false');
});
it('pauses for hover, focus, hidden page, offscreen lane, and a disabled viewer state', async () => {
const component = render(MemoriesLane, { date, memories: [makeMemory(2025)] });
const region = component.getByRole('region');
const image = component.getByRole('img') as HTMLImageElement;
await intersect(true);
await fireEvent.mouseEnter(region);
await advance();
expect(image.src).toContain('asset-2025-0');
await fireEvent.mouseLeave(region);
await fireEvent.focusIn(component.getByRole('link'));
await advance();
expect(image.src).toContain('asset-2025-0');
await fireEvent.focusOut(component.getByRole('link'), { relatedTarget: null });
Object.defineProperty(document, 'hidden', { configurable: true, value: true });
await fireEvent(document, new Event('visibilitychange'));
await advance();
expect(image.src).toContain('asset-2025-0');
Object.defineProperty(document, 'hidden', { configurable: true, value: false });
await fireEvent(document, new Event('visibilitychange'));
await intersect(false);
await advance();
expect(image.src).toContain('asset-2025-0');
await intersect(true);
await component.rerender({ enabled: false });
await advance();
expect(image.src).toContain('asset-2025-0');
await component.rerender({ enabled: true });
await advance();
expect(image.src).toContain('asset-2025-1');
});
it('supports an explicit pause and resume and starts paused for reduced motion', async () => {
vi.mocked(globalThis.matchMedia).mockReturnValueOnce({
matches: true,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as unknown as MediaQueryList);
const component = render(MemoriesLane, { date, memories: [makeMemory(2025)] });
await intersect(true);
await advance();
expect(component.getByRole('img').getAttribute('src')).toContain('asset-2025-0');
const region = component.getByRole('region');
await fireEvent.mouseEnter(region);
const resumeButton = component.getByRole('button', { name: 'resume' });
resumeButton.focus();
await tick();
await fireEvent.click(resumeButton);
await fireEvent.mouseLeave(region);
await advance();
expect(component.getByRole('img').getAttribute('src')).toContain('asset-2025-1');
await fireEvent.click(component.getByRole('button', { name: 'pause_memories' }));
await advance();
expect(component.getByRole('img').getAttribute('src')).toContain('asset-2025-1');
});
it('advances the horizontal lane and loops at the end, then cleans up its timer and observer', async () => {
const component = render(MemoriesLane, { date, memories: [makeMemory(2025), makeMemory(2024), makeMemory(2023)] });
const scroller = component.getByRole('region').firstElementChild as HTMLElement;
Object.defineProperties(scroller, {
clientWidth: { configurable: true, value: 200 },
scrollWidth: { configurable: true, value: 500 },
});
vi.spyOn(component.getAllByRole('link')[0], 'getBoundingClientRect').mockReturnValue({ width: 150 } as DOMRect);
const scrollTo = vi.fn((options: ScrollToOptions | number | undefined) => {
scroller.scrollLeft = typeof options === 'object' ? (options.left ?? 0) : (options ?? 0);
});
scroller.scrollTo = scrollTo;
await intersect(true);
await advance();
expect(scrollTo).toHaveBeenLastCalledWith({ left: 150, behavior: 'smooth' });
await advance();
expect(scrollTo).toHaveBeenLastCalledWith({ left: 300, behavior: 'smooth' });
await advance();
expect(scrollTo).toHaveBeenLastCalledWith({ left: 0, behavior: 'smooth' });
component.unmount();
expect(disconnect).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
await advance();
expect(scrollTo).toHaveBeenCalledTimes(3);
});
});
@@ -0,0 +1,226 @@
<script lang="ts">
import MemoryCard from '$lib/components/memories/MemoryCard.svelte';
import { Route } from '$lib/route';
import { getAssetMediaUrl, memoryLaneTitle } from '$lib/utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { MemoryType, type MemoryResponseDto } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiChevronLeft, mdiChevronRight, mdiPause, mdiPlay } from '@mdi/js';
import { DateTime } from 'luxon';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
type Props = {
memories: MemoryResponseDto[];
enabled?: boolean;
date?: string;
};
const { memories, enabled = true, date = DateTime.now().toISODate() }: Props = $props();
const interval = 5000;
let lane = $state<HTMLElement>();
let scroller = $state<HTMLElement>();
let mounted = $state(false);
let visible = $state(false);
let documentVisible = $state(true);
let hovered = $state(false);
let focused = $state(false);
let paused = $state(false);
let reducedMotion = $state(false);
let frame = $state(0);
let viewportWidth = $state(0);
let contentWidth = $state(0);
let scrollPosition = $state(0);
const currentMemories = $derived(
memories
.filter((memory) => {
if (memory.type !== MemoryType.OnThisDay) {
return true;
}
const age = Number(date.slice(0, 4)) - memory.data.year;
return (
age >= 1 &&
age <= 20 &&
memory.memoryAt.slice(5, 10) === date.slice(5, 10) &&
(!memory.showAt || memory.showAt.slice(0, 10) === date)
);
})
.sort((a, b) => {
if (a.type !== b.type) {
return Number(b.type === MemoryType.Birthday) - Number(a.type === MemoryType.Birthday);
}
return b.data.year - a.data.year;
}),
);
const items = $derived(
currentMemories.flatMap((memory) => {
const asset = memory.assets[frame % memory.assets.length];
return asset
? [
{
id: memory.id,
title: $memoryLaneTitle(memory),
href: Route.viewMemory({ id: memory.id, assetId: asset.id }),
alt: $t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(asset)) } }),
src: getAssetMediaUrl({ id: asset.id }),
type: memory.type,
},
]
: [];
}),
);
const canScrollPrevious = $derived(scrollPosition > 0);
const canScrollNext = $derived(Math.ceil(scrollPosition) < Math.floor(contentWidth - viewportWidth));
const canPlay = $derived(
mounted && enabled && visible && documentVisible && !hovered && !focused && !paused && items.length > 0,
);
const hasContentFocus = (target: EventTarget | null) =>
target instanceof Element && !!lane?.contains(target) && !target.closest('[data-memory-playback-control]');
const scroll = (forward: boolean, loop = false) => {
if (!scroller) {
return;
}
const end = scroller.scrollWidth - scroller.clientWidth;
if (end <= 0) {
return;
}
const position = Math.abs(scroller.scrollLeft);
const firstCard = scroller.querySelector('a');
const step = firstCard
? firstCard.getBoundingClientRect().width +
(Number(getComputedStyle(firstCard).marginInlineEnd.replace('px', '')) || 0)
: scroller.clientWidth;
const next =
loop && Math.ceil(position) >= Math.floor(end)
? 0
: Math.max(0, Math.min(end, position + (forward ? step : -step)));
const direction = getComputedStyle(scroller).direction === 'rtl' ? -1 : 1;
scroller.scrollTo({ left: next * direction, behavior: reducedMotion ? 'instant' : 'smooth' });
};
onMount(() => {
const motionQuery = globalThis.matchMedia('(prefers-reduced-motion: reduce)');
reducedMotion = motionQuery.matches;
paused = motionQuery.matches;
const onMotionChange = () => {
reducedMotion = motionQuery.matches;
if (reducedMotion) {
paused = true;
}
};
const onVisibilityChange = () => (documentVisible = !document.hidden);
onVisibilityChange();
motionQuery.addEventListener('change', onMotionChange);
document.addEventListener('visibilitychange', onVisibilityChange);
mounted = true;
return () => {
motionQuery.removeEventListener('change', onMotionChange);
document.removeEventListener('visibilitychange', onVisibilityChange);
};
});
$effect(() => {
if (!mounted || !lane) {
return;
}
const observer = new IntersectionObserver(([entry]) => (visible = entry.isIntersecting), { threshold: 0.1 });
observer.observe(lane);
return () => observer.disconnect();
});
$effect(() => {
if (!canPlay) {
return;
}
const timer = globalThis.setInterval(() => {
frame++;
scroll(true, true);
}, interval);
return () => globalThis.clearInterval(timer);
});
</script>
{#if items.length > 0}
<section
bind:this={lane}
aria-label={$t('memories')}
class="relative mt-3"
onmouseenter={() => (hovered = true)}
onmouseleave={() => (hovered = false)}
onfocusin={(event) => (focused = hasContentFocus(event.target))}
onfocusout={(event) => (focused = hasContentFocus(event.relatedTarget))}
>
<div
bind:this={scroller}
bind:clientWidth={viewportWidth}
class="overflow-x-scroll overflow-y-hidden whitespace-nowrap"
style="scrollbar-width:none"
onscroll={() => (scrollPosition = Math.abs(scroller?.scrollLeft ?? 0))}
>
{#if canScrollPrevious || canScrollNext}
<div class="light sticky inset-s-0 z-10">
{#if canScrollPrevious}
<div class="absolute inset-s-4 top-27 -translate-y-1/2 max-md:top-19">
<IconButton
icon={mdiChevronLeft}
directional
shape="round"
variant="outline"
color="secondary"
class="opacity-50 hover:opacity-100"
size="giant"
aria-label={$t('previous_memory')}
onclick={() => scroll(false)}
/>
</div>
{/if}
{#if canScrollNext}
<div class="absolute inset-e-4 top-27 -translate-y-1/2 max-md:top-19">
<IconButton
icon={mdiChevronRight}
directional
shape="round"
variant="outline"
color="secondary"
class="opacity-50 hover:opacity-100"
size="giant"
aria-label={$t('next_memory')}
onclick={() => scroll(true)}
/>
</div>
{/if}
</div>
{/if}
<div class="inline-block" bind:clientWidth={contentWidth}>
{#each items as item (item.id)}
<MemoryCard {item} />
{/each}
</div>
</div>
<div class="light absolute inset-s-2 top-2" data-memory-playback-control>
<IconButton
icon={paused ? mdiPlay : mdiPause}
shape="round"
variant="outline"
color="secondary"
size="small"
aria-label={$t(paused ? 'resume' : 'pause_memories')}
aria-pressed={paused}
onclick={() => (paused = !paused)}
/>
</div>
</section>
{/if}
@@ -0,0 +1,11 @@
<script lang="ts">
import MemoriesLane from '$lib/components/memories/MemoriesLane.svelte';
import { TooltipProvider } from '@immich/ui';
import type { ComponentProps } from 'svelte';
const props: ComponentProps<typeof MemoriesLane> = $props();
</script>
<TooltipProvider>
<MemoriesLane {...props} />
</TooltipProvider>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte'; import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte'; import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import MemoriesLane from '$lib/components/memories/MemoriesLane.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte'; import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
@@ -21,22 +22,19 @@
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { memoryManager } from '$lib/managers/memory-manager.svelte'; import { memoryManager } from '$lib/managers/memory-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { Route } from '$lib/route';
import { getAssetBulkActions } from '$lib/services/asset.service'; import { getAssetBulkActions } from '$lib/services/asset.service';
import { getStackBulkActions } from '$lib/services/stack.service'; import { getStackBulkActions } from '$lib/services/stack.service';
import { getAssetMediaUrl, memoryLaneTitle } from '$lib/utils';
import { type OnLink, type OnUnlink } from '$lib/utils/actions'; import { type OnLink, type OnUnlink } from '$lib/utils/actions';
import { openFileUploadDialog } from '$lib/utils/file-uploader'; 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 { AssetVisibility } from '@immich/sdk';
import MemoryCard from '$lib/components/memories/MemoryCard.svelte'; import { ActionButton, CommandPaletteDefaultProvider } 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';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
let timelineManager = $state<TimelineManager>() as TimelineManager; let timelineManager = $state<TimelineManager>() as TimelineManager;
let memoryDate = $state(DateTime.now().toISODate());
const options = { visibility: AssetVisibility.Timeline, withStacked: true, withPartners: true }; const options = { visibility: AssetVisibility.Timeline, withStacked: true, withPartners: true };
let selectedAssets = $derived(assetMultiSelectManager.assets); let selectedAssets = $derived(assetMultiSelectManager.assets);
@@ -75,18 +73,29 @@
assetMultiSelectManager.clear(); assetMultiSelectManager.clear();
}; };
const items = $derived(
memoryManager.memories.map((memory) => ({
id: memory.id,
title: $memoryLaneTitle(memory),
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,
})),
);
memoryManager.setFilters({ $for: DateTime.now().toISODate() }); memoryManager.setFilters({ $for: DateTime.now().toISODate() });
onMount(() => {
let midnightTimer: ReturnType<typeof setTimeout>;
const refreshMemoriesDate = () => {
const now = DateTime.now();
memoryDate = now.toISODate();
memoryManager.setFilters({ $for: memoryDate });
clearTimeout(midnightTimer);
midnightTimer = setTimeout(refreshMemoriesDate, now.plus({ days: 1 }).startOf('day').diff(now).toMillis() + 1000);
};
const onVisibilityChange = () => {
if (!document.hidden) {
refreshMemoriesDate();
}
};
refreshMemoriesDate();
document.addEventListener('visibilitychange', onVisibilityChange);
return () => {
clearTimeout(midnightTimer);
document.removeEventListener('visibilitychange', onVisibilityChange);
};
});
</script> </script>
<UserPageLayout hideNavbar={assetMultiSelectManager.selectionActive} scrollbar={false}> <UserPageLayout hideNavbar={assetMultiSelectManager.selectionActive} scrollbar={false}>
@@ -100,11 +109,11 @@
withStacked withStacked
> >
{#if authManager.preferences.memories.enabled} {#if authManager.preferences.memories.enabled}
<ImageCarousel {items}> <MemoriesLane
{#snippet child(item)} memories={memoryManager.memories}
<MemoryCard {item} /> date={memoryDate}
{/snippet} enabled={!assetViewerManager.isViewing && !assetMultiSelectManager.selectionActive}
</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" />