Show on-this-day memories from the last 20 years with automatic playback
This commit is contained in:
@@ -109,18 +109,7 @@ with
|
||||
select
|
||||
make_date(year::int, $1::int, $2::int) as "date"
|
||||
from
|
||||
generate_series(
|
||||
(
|
||||
select
|
||||
date_part(
|
||||
'year',
|
||||
min(("localDateTime" at time zone 'UTC')::date)
|
||||
)::int
|
||||
from
|
||||
asset
|
||||
),
|
||||
$3
|
||||
) as "year"
|
||||
generate_series($3::int, $4::int) as "year"
|
||||
)
|
||||
select
|
||||
"a".*
|
||||
@@ -135,21 +124,21 @@ with
|
||||
inner join "asset_job_status" on "asset"."id" = "asset_job_status"."assetId"
|
||||
where
|
||||
(asset."localDateTime" at time zone 'UTC')::date = today.date
|
||||
and "asset"."ownerId" = any ($4::uuid[])
|
||||
and "asset"."visibility" = $5
|
||||
and "asset"."ownerId" = any ($5::uuid[])
|
||||
and "asset"."visibility" = $6
|
||||
and exists (
|
||||
select
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
"assetId" = "asset"."id"
|
||||
and "asset_file"."type" = $6
|
||||
and "asset_file"."type" = $7
|
||||
)
|
||||
and "asset"."deletedAt" is null
|
||||
order by
|
||||
(asset."localDateTime" at time zone 'UTC')::date desc
|
||||
limit
|
||||
$7
|
||||
$8
|
||||
) as "a" on true
|
||||
)
|
||||
select
|
||||
|
||||
@@ -464,15 +464,19 @@ export class AssetRepository {
|
||||
qb
|
||||
.with('today', (qb) =>
|
||||
qb
|
||||
.selectFrom((eb) =>
|
||||
eb
|
||||
.fn('generate_series', [
|
||||
sql`(select date_part('year', min(("localDateTime" at time zone 'UTC')::date))::int from asset)`,
|
||||
sql`${year - 1}`,
|
||||
])
|
||||
.as('year'),
|
||||
)
|
||||
.select((eb) => eb.fn('make_date', [sql`year::int`, sql`${month}::int`, sql`${day}::int`]).as('date')),
|
||||
.selectFrom((eb) => eb.fn('generate_series', [sql`${year - 20}::int`, sql`${year - 1}::int`]).as('year'))
|
||||
.select((eb) => {
|
||||
const date = eb.fn('make_date', [sql`year::int`, sql`${month}::int`, sql`${day}::int`]);
|
||||
return (
|
||||
month === 2 && day === 29
|
||||
? eb
|
||||
.case()
|
||||
.when(sql<boolean>`year::int % 4 = 0 and (year::int % 100 != 0 or year::int % 400 = 0)`)
|
||||
.then(date)
|
||||
.end()
|
||||
: date
|
||||
).as('date');
|
||||
}),
|
||||
)
|
||||
.selectFrom('today')
|
||||
.innerJoinLateral(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { LoggingRepository } from 'src/repositories/logging.repository.js';
|
||||
import { DB } from 'src/schema/index.js';
|
||||
@@ -64,6 +64,67 @@ const keyframeRow = (assetId: string, n: number) => ({
|
||||
});
|
||||
|
||||
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', () => {
|
||||
it('should order assets by local day first and fileCreatedAt within each day', async () => {
|
||||
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">
|
||||
import ActionMenuItem from '$lib/components/ActionMenuItem.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 EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
|
||||
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
|
||||
@@ -21,22 +22,19 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { memoryManager } from '$lib/managers/memory-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 { getStackBulkActions } from '$lib/services/stack.service';
|
||||
import { getAssetMediaUrl, memoryLaneTitle } from '$lib/utils';
|
||||
import { type OnLink, 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 MemoryCard from '$lib/components/memories/MemoryCard.svelte';
|
||||
import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel } from '@immich/ui';
|
||||
import { ActionButton, CommandPaletteDefaultProvider } from '@immich/ui';
|
||||
import { mdiDotsVertical } from '@mdi/js';
|
||||
import { DateTime } from 'luxon';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
let timelineManager = $state<TimelineManager>() as TimelineManager;
|
||||
let memoryDate = $state(DateTime.now().toISODate());
|
||||
const options = { visibility: AssetVisibility.Timeline, withStacked: true, withPartners: true };
|
||||
|
||||
let selectedAssets = $derived(assetMultiSelectManager.assets);
|
||||
@@ -75,18 +73,29 @@
|
||||
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() });
|
||||
|
||||
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>
|
||||
|
||||
<UserPageLayout hideNavbar={assetMultiSelectManager.selectionActive} scrollbar={false}>
|
||||
@@ -100,11 +109,11 @@
|
||||
withStacked
|
||||
>
|
||||
{#if authManager.preferences.memories.enabled}
|
||||
<ImageCarousel {items}>
|
||||
{#snippet child(item)}
|
||||
<MemoryCard {item} />
|
||||
{/snippet}
|
||||
</ImageCarousel>
|
||||
<MemoriesLane
|
||||
memories={memoryManager.memories}
|
||||
date={memoryDate}
|
||||
enabled={!assetViewerManager.isViewing && !assetMultiSelectManager.selectionActive}
|
||||
/>
|
||||
{/if}
|
||||
{#snippet empty()}
|
||||
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} class="mx-auto mt-10" />
|
||||
|
||||
Reference in New Issue
Block a user