diff --git a/e2e/src/ui/generators/memory/model-objects.ts b/e2e/src/ui/generators/memory/model-objects.ts index f81b2f8896..6b4d8c725a 100644 --- a/e2e/src/ui/generators/memory/model-objects.ts +++ b/e2e/src/ui/generators/memory/model-objects.ts @@ -80,5 +80,7 @@ export function generateMemoriesFromTimeline( ); } + memories.sort((a, b) => new Date(b.memoryAt).getMilliseconds() - new Date(a.memoryAt).getMilliseconds()); + return memories; } diff --git a/e2e/src/ui/mock-network/memory-network.ts b/e2e/src/ui/mock-network/memory-network.ts index 9a3a9e6555..1cb93995b8 100644 --- a/e2e/src/ui/mock-network/memory-network.ts +++ b/e2e/src/ui/mock-network/memory-network.ts @@ -62,4 +62,14 @@ export const setupMemoryMockApiRoutes = async ( await route.fallback(); }); + + await context.route('**/api/memories/statistics*', async (route) => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + json: { + total: memories.length, + }, + }); + }); }; diff --git a/e2e/src/ui/specs/memory/utils.ts b/e2e/src/ui/specs/memory/utils.ts index 378f649cff..bd2ef6b55f 100644 --- a/e2e/src/ui/specs/memory/utils.ts +++ b/e2e/src/ui/specs/memory/utils.ts @@ -3,7 +3,7 @@ import type { AssetResponseDto } from '@immich/sdk'; import { expect, Page } from '@playwright/test'; function getAssetIdFromUrl(url: URL): string | null { - const pathMatch = url.pathname.match(/\/memory\/photos\/([^/]+)/); + const pathMatch = url.pathname.match(/\/memories\/photos\/([^/]+)/); if (pathMatch) { return pathMatch[1]; } @@ -21,12 +21,12 @@ export const memoryViewerUtils = { }, async openMemoryPage(page: Page) { - await page.goto('/memory'); + await page.goto('/memories'); await this.waitForMemoryLoad(page); }, async openMemoryPageWithAsset(page: Page, assetId: string) { - await page.goto(`/memory?id=${assetId}`); + await page.goto(`/memories?id=${assetId}`); await this.waitForMemoryLoad(page); }, }; diff --git a/mobile/lib/domain/services/memory.service.dart b/mobile/lib/domain/services/memory.service.dart index ead613370f..a1fc1b2797 100644 --- a/mobile/lib/domain/services/memory.service.dart +++ b/mobile/lib/domain/services/memory.service.dart @@ -13,6 +13,10 @@ class DriftMemoryService { return _repository.getAll(ownerId); } + Future> getAllMemories(String ownerId) { + return _repository.getAll(ownerId, onlyToday: false); + } + Future get(String memoryId) { return _repository.get(memoryId); } diff --git a/mobile/lib/infrastructure/repositories/memory.repository.dart b/mobile/lib/infrastructure/repositories/memory.repository.dart index 37cccbf2d4..8a61f6a4ab 100644 --- a/mobile/lib/infrastructure/repositories/memory.repository.dart +++ b/mobile/lib/infrastructure/repositories/memory.repository.dart @@ -9,10 +9,7 @@ class DriftMemoryRepository extends DriftDatabaseRepository { final Drift _db; const DriftMemoryRepository(this._db) : super(_db); - Future> getAll(String ownerId) async { - final now = DateTime.now(); - final localUtc = DateTime.utc(now.year, now.month, now.day, 0, 0, 0); - + Future> getAll(String ownerId, {bool onlyToday = true}) async { final query = _db.select(_db.memoryEntity).join([ innerJoin(_db.memoryAssetEntity, _db.memoryAssetEntity.memoryId.equalsExp(_db.memoryEntity.id)), @@ -24,10 +21,17 @@ class DriftMemoryRepository extends DriftDatabaseRepository { ), ]) ..where(_db.memoryEntity.ownerId.equals(ownerId)) - ..where(_db.memoryEntity.deletedAt.isNull()) - ..where(_db.memoryEntity.showAt.isNull() | _db.memoryEntity.showAt.isSmallerOrEqualValue(localUtc)) - ..where(_db.memoryEntity.hideAt.isNull() | _db.memoryEntity.hideAt.isBiggerOrEqualValue(localUtc)) - ..orderBy([OrderingTerm.desc(_db.memoryEntity.memoryAt), OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]); + ..where(_db.memoryEntity.deletedAt.isNull()); + + if (onlyToday) { + final now = DateTime.now(); + final localUtc = DateTime.utc(now.year, now.month, now.day, 0, 0, 0); + + query.where(_db.memoryEntity.showAt.isNull() | _db.memoryEntity.showAt.isSmallerOrEqualValue(localUtc)); + query.where(_db.memoryEntity.hideAt.isNull() | _db.memoryEntity.hideAt.isBiggerOrEqualValue(localUtc)); + } + + query.orderBy([OrderingTerm.desc(_db.memoryEntity.memoryAt), OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]); final rows = await query.get(); if (rows.isEmpty) { diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index 240c4abe7a..1623479adb 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -111,7 +111,7 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) { } if (index == kPhotoTabIndex) { - ref.invalidate(driftMemoryFutureProvider); + ref.invalidate(driftMemoryLaneProvider); } if (router.activeIndex != kSearchTabIndex && index == kSearchTabIndex) { diff --git a/mobile/lib/presentation/pages/dev/main_timeline.page.dart b/mobile/lib/presentation/pages/dev/main_timeline.page.dart index f2215d169c..97925c108f 100644 --- a/mobile/lib/presentation/pages/dev/main_timeline.page.dart +++ b/mobile/lib/presentation/pages/dev/main_timeline.page.dart @@ -39,7 +39,7 @@ class _MainTimelinePageState extends ConsumerState { @override Widget build(BuildContext context) { - final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false)); + final hasMemories = ref.watch(driftMemoryLaneProvider.select((state) => state.value?.isNotEmpty ?? false)); return Timeline( topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()), topSliverWidgetHeight: hasMemories ? 200 : 0, diff --git a/mobile/lib/presentation/pages/drift_library.page.dart b/mobile/lib/presentation/pages/drift_library.page.dart index 137f46659f..c1b7c772b3 100644 --- a/mobile/lib/presentation/pages/drift_library.page.dart +++ b/mobile/lib/presentation/pages/drift_library.page.dart @@ -7,8 +7,10 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/images/local_album_thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; @@ -133,7 +135,12 @@ class _CollectionCards extends StatelessWidget { child: Wrap( spacing: 8, runSpacing: 8, - children: [_PeopleCollectionCard(), _PlacesCollectionCard(), _LocalAlbumsCollectionCard()], + children: [ + _PeopleCollectionCard(), + _PlacesCollectionCard(), + _LocalAlbumsCollectionCard(), + _MemoriesCollectionCard(), + ], ), ), ); @@ -329,6 +336,76 @@ class _LocalAlbumsCollectionCard extends ConsumerWidget { } } +class _MemoriesCollectionCard extends ConsumerWidget { + const _MemoriesCollectionCard(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final memories = ref.watch(driftAllMemoriesProvider); + + return LayoutBuilder( + builder: (context, constraints) { + final isTablet = constraints.maxWidth > 600; + final widthFactor = isTablet ? 0.25 : 0.5; + final size = context.width * widthFactor - 20.0; + + return GestureDetector( + onTap: () => context.pushRoute(const DriftMemoryListRoute()), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: size, + width: size, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(20)), + gradient: LinearGradient( + colors: [context.colorScheme.primary.withAlpha(30), context.colorScheme.primary.withAlpha(25)], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: memories.widgetWhen( + onLoading: () => const Center(child: CircularProgressIndicator()), + onData: (memories) { + return GridView.count( + crossAxisCount: 2, + padding: const EdgeInsets.all(12), + crossAxisSpacing: 8, + mainAxisSpacing: 8, + physics: const NeverScrollableScrollPhysics(), + children: memories.take(4).map((memory) { + return ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(10)), + child: Thumbnail.remote( + remoteId: memory.assets[0].id, + thumbhash: memory.assets[0].thumbHash ?? "", + fit: BoxFit.cover, + ), + ); + }).toList(), + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + context.t.memories, + style: context.textTheme.titleSmall?.copyWith( + color: context.colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + }, + ); + } +} + @visibleForTesting final sharedWithPartnerProvider = StreamProvider.autoDispose>((ref) { final currentUser = ref.watch(currentUserProvider); diff --git a/mobile/lib/presentation/pages/drift_memory_list.page.dart b/mobile/lib/presentation/pages/drift_memory_list.page.dart new file mode 100644 index 0000000000..59c57cf491 --- /dev/null +++ b/mobile/lib/presentation/pages/drift_memory_list.page.dart @@ -0,0 +1,107 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_mobile/presentation/pages/drift_memory.page.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; + +@RoutePage() +class DriftMemoryListPage extends ConsumerStatefulWidget { + const DriftMemoryListPage({super.key}); + + @override + ConsumerState createState() => _DriftMemoryListPageState(); +} + +class _DriftMemoryListPageState extends ConsumerState { + bool _onlyFavorites = false; + + @override + Widget build(BuildContext context) { + final memories = ref.watch(driftAllMemoriesProvider); + + return LayoutBuilder( + builder: (context, constraints) { + return Scaffold( + appBar: AppBar( + title: Text(context.t.memories), + actions: [ + IconButton( + icon: Icon(_onlyFavorites ? Icons.favorite : Icons.favorite_outline), + onPressed: () { + setState(() => _onlyFavorites = !_onlyFavorites); + }, + ), + ], + ), + body: SafeArea( + child: memories.when( + data: (memories) { + if (_onlyFavorites) { + memories = memories.where((memory) => memory.isSaved).toList(); + } + + return GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: constraints.maxWidth > 600 ? 4 : 2, + childAspectRatio: 0.5625, + mainAxisSpacing: 16, + crossAxisSpacing: 16, + ), + padding: const EdgeInsets.all(16), + itemCount: memories.length, + itemBuilder: (context, index) => GestureDetector( + onTap: () { + if (memories[index].assets.isNotEmpty) { + DriftMemoryPage.setMemory(ref, memories[index]); + } + unawaited(context.pushRoute(DriftMemoryRoute(memories: memories, memoryIndex: index))); + }, + child: Stack( + children: [ + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(10)), + child: ColorFiltered( + colorFilter: ColorFilter.mode(Colors.black.withValues(alpha: 0.2), BlendMode.darken), + child: AbsorbPointer( + child: Thumbnail.remote( + remoteId: memories[index].assets[0].id, + thumbhash: memories[index].assets[0].thumbHash ?? "", + fit: BoxFit.cover, + ), + ), + ), + ), + Positioned( + bottom: 16, + left: 16, + child: Text( + DateFormat.yMMMMd().format(memories[index].memoryAt), + style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.white, fontSize: 15), + ), + ), + if (memories[index].isSaved) + const Positioned( + top: 16, + right: 16, + child: Icon(Icons.favorite, color: Colors.white, size: 24), + ), + ], + ), + ), + ); + }, + error: (error, stack) => const Text("Error loading memories"), + loading: () => const Center(child: CircularProgressIndicator()), + ), + ), + ); + }, + ); + } +} diff --git a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart index 1732249523..c8b75cf6b5 100644 --- a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart +++ b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart @@ -16,7 +16,7 @@ class DriftMemoryLane extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final memoryLaneProvider = ref.watch(driftMemoryFutureProvider); + final memoryLaneProvider = ref.watch(driftMemoryLaneProvider); final memories = memoryLaneProvider.value ?? const []; if (memories.isEmpty) { return const SizedBox.shrink(); diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index f369a37142..1c7478850d 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -128,7 +128,7 @@ class AppLifeCycleNotifier extends StateNotifier { syncSuccess = await backgroundManager.syncRemote(); }, "syncRemote"), ]); - _ref.invalidate(driftMemoryFutureProvider); + _ref.invalidate(driftAllMemoriesProvider); if (syncSuccess) { await Future.wait([ _safeRun(backgroundManager.hashAssets, "hashAssets").then((_) { diff --git a/mobile/lib/providers/infrastructure/memory.provider.dart b/mobile/lib/providers/infrastructure/memory.provider.dart index 3dd32454b8..6917f15475 100644 --- a/mobile/lib/providers/infrastructure/memory.provider.dart +++ b/mobile/lib/providers/infrastructure/memory.provider.dart @@ -15,7 +15,7 @@ final driftMemoryServiceProvider = Provider( (ref) => DriftMemoryService(ref.watch(driftMemoryRepositoryProvider)), ); -final driftMemoryFutureProvider = FutureProvider.autoDispose>((ref) { +final driftMemoryLaneProvider = FutureProvider.autoDispose>((ref) { final (userId, enabled) = ref.watch(currentUserProvider.select((user) => (user?.id, user?.memoryEnabled ?? true))); if (userId == null || !enabled) { return const []; @@ -29,3 +29,13 @@ final driftMemoryFutureProvider = FutureProvider.autoDispose>( final service = ref.watch(driftMemoryServiceProvider); return service.getMemoryLane(userId); }); + +final driftAllMemoriesProvider = FutureProvider.autoDispose>((ref) { + final (userId, enabled) = ref.watch(currentUserProvider.select((user) => (user?.id, user?.memoryEnabled ?? true))); + if (userId == null || !enabled) { + return const []; + } + + final service = ref.watch(driftMemoryServiceProvider); + return service.getAllMemories(userId); +}); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index a4c5bab4fb..b9dc1ec4ec 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -52,6 +52,7 @@ import 'package:immich_mobile/presentation/pages/drift_local_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_locked_folder.page.dart'; import 'package:immich_mobile/presentation/pages/drift_map.page.dart'; import 'package:immich_mobile/presentation/pages/drift_memory.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_memory_list.page.dart'; import 'package:immich_mobile/presentation/pages/drift_partner_detail.page.dart'; import 'package:immich_mobile/presentation/pages/drift_people_collection.page.dart'; import 'package:immich_mobile/presentation/pages/drift_person.page.dart'; @@ -194,6 +195,7 @@ class AppRouter extends RootStackRouter { AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftSlideshowRoute.page, guards: [_authGuard, _duplicateGuard]), + AutoRoute(page: DriftMemoryListRoute.page, guards: [_authGuard, _duplicateGuard]), // required to handle all deeplinks in deep_link.service.dart // auto_route_library#1722 RedirectRoute(path: '*', redirectTo: '/'), diff --git a/mobile/test/providers/infrastructure/memory_provider_test.dart b/mobile/test/providers/infrastructure/memory_provider_test.dart index e588153fc9..2bd5ed4267 100644 --- a/mobile/test/providers/infrastructure/memory_provider_test.dart +++ b/mobile/test/providers/infrastructure/memory_provider_test.dart @@ -44,11 +44,11 @@ void main() { when(() => userService.watchMyUser()).thenAnswer((_) => const Stream.empty()); }); - group('driftMemoryFutureProvider', () { + group('driftMemoryLaneProvider', () { test('re-queries after local midnight', () { fakeAsync((async) { final container = makeContainer(); - container.listen(driftMemoryFutureProvider, (_, __) {}); + container.listen(driftMemoryLaneProvider, (_, __) {}); async.flushMicrotasks(); verify(() => memoryService.getMemoryLane('user-1')).called(1); @@ -66,7 +66,7 @@ void main() { test('cancels the midnight timer when disposed', () { fakeAsync((async) { final container = makeContainer(); - final subscription = container.listen(driftMemoryFutureProvider, (_, __) {}); + final subscription = container.listen(driftMemoryLaneProvider, (_, __) {}); async.flushMicrotasks(); verify(() => memoryService.getMemoryLane('user-1')).called(1); @@ -83,7 +83,7 @@ void main() { fakeAsync((async) { final container = makeContainer(); - container.listen(driftMemoryFutureProvider, (_, __) {}); + container.listen(driftMemoryLaneProvider, (_, __) {}); async.flushMicrotasks(); async.elapse(const Duration(hours: 25)); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index d00faab9a0..fc7c4d3513 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7190,6 +7190,17 @@ "$ref": "#/components/schemas/MemorySearchOrder" } }, + { + "name": "page", + "required": false, + "in": "query", + "description": "Page number", + "schema": { + "minimum": 1, + "maximum": 9007199254740991, + "type": "integer" + } + }, { "name": "size", "required": false, @@ -7359,6 +7370,17 @@ "$ref": "#/components/schemas/MemorySearchOrder" } }, + { + "name": "page", + "required": false, + "in": "query", + "description": "Page number", + "schema": { + "minimum": 1, + "maximum": 9007199254740991, + "type": "integer" + } + }, { "name": "size", "required": false, diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index b2f98b58ef..054e3b5e06 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -5006,11 +5006,12 @@ export function reverseGeocode({ lat, lon }: { /** * Retrieve memories */ -export function searchMemories({ $for, isSaved, isTrashed, order, size, $type }: { +export function searchMemories({ $for, isSaved, isTrashed, order, page, size, $type }: { $for?: string; isSaved?: boolean; isTrashed?: boolean; order?: MemorySearchOrder; + page?: number; size?: number; $type?: MemoryType; }, opts?: Oazapfts.RequestOpts) { @@ -5022,6 +5023,7 @@ export function searchMemories({ $for, isSaved, isTrashed, order, size, $type }: isSaved, isTrashed, order, + page, size, "type": $type }))}`, { @@ -5046,11 +5048,12 @@ export function createMemory({ memoryCreateDto }: { /** * Retrieve memories statistics */ -export function memoriesStatistics({ $for, isSaved, isTrashed, order, size, $type }: { +export function memoriesStatistics({ $for, isSaved, isTrashed, order, page, size, $type }: { $for?: string; isSaved?: boolean; isTrashed?: boolean; order?: MemorySearchOrder; + page?: number; size?: number; $type?: MemoryType; }, opts?: Oazapfts.RequestOpts) { @@ -5062,6 +5065,7 @@ export function memoriesStatistics({ $for, isSaved, isTrashed, order, size, $typ isSaved, isTrashed, order, + page, size, "type": $type }))}`, { diff --git a/server/src/dtos/memory.dto.ts b/server/src/dtos/memory.dto.ts index 7e2bdf0df8..47f6b8cf32 100644 --- a/server/src/dtos/memory.dto.ts +++ b/server/src/dtos/memory.dto.ts @@ -14,6 +14,7 @@ const MemorySearchSchema = z isTrashed: stringToBool.optional().describe('Include trashed memories'), isSaved: stringToBool.optional().describe('Filter by saved status'), size: z.coerce.number().int().min(1).optional().describe('Number of memories to return'), + page: z.coerce.number().int().min(1).optional().describe('Page number'), order: AssetOrderWithRandomSchema.optional(), }) .meta({ id: 'MemorySearchDto' }); diff --git a/server/src/repositories/memory.repository.ts b/server/src/repositories/memory.repository.ts index 09aa5ad880..b421ba6ec8 100644 --- a/server/src/repositories/memory.repository.ts +++ b/server/src/repositories/memory.repository.ts @@ -90,6 +90,7 @@ export class MemoryRepository implements IBulkAsset { : qb.orderBy('memoryAt', (dto.order?.toLowerCase() || 'desc') as OrderByDirection), ) .$if(dto.size !== undefined, (qb) => qb.limit(dto.size!)) + .$if(dto.page !== undefined && dto.size !== undefined, (qb) => qb.offset((dto.page! - 1) * dto.size!)) .execute(); } diff --git a/server/src/services/memory.service.spec.ts b/server/src/services/memory.service.spec.ts index 9976189e39..cc35c672f9 100644 --- a/server/src/services/memory.service.spec.ts +++ b/server/src/services/memory.service.spec.ts @@ -35,6 +35,7 @@ describe(MemoryService.name, () => { const memory1 = MemoryFactory.from({ ownerId: userId }).asset(asset).build(); const memory2 = MemoryFactory.create({ ownerId: userId }); mocks.memory.search.mockResolvedValue([getForMemory(memory1), getForMemory(memory2)]); + mocks.memory.statistics.mockResolvedValue({ total: 2 }); await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toEqual( expect.arrayContaining([ @@ -44,6 +45,8 @@ describe(MemoryService.name, () => { }), ]), ); + mocks.memory.search.mockResolvedValue([]); + await expect(sut.search(factory.auth(), {})).resolves.toEqual([]); }); it('should map empty result', async () => { diff --git a/web/src/lib/managers/memory-manager.svelte.ts b/web/src/lib/managers/memory-manager.svelte.ts index dc43510396..fc38a92c6b 100644 --- a/web/src/lib/managers/memory-manager.svelte.ts +++ b/web/src/lib/managers/memory-manager.svelte.ts @@ -1,4 +1,11 @@ -import { deleteMemory, type MemoryResponseDto, removeMemoryAssets, searchMemories, updateMemory } from '@immich/sdk'; +import { + deleteMemory, + type MemoryResponseDto, + removeMemoryAssets, + searchMemories, + updateMemory, + memoriesStatistics, +} from '@immich/sdk'; import { DateTime } from 'luxon'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; @@ -19,8 +26,15 @@ export type MemoryAsset = MemoryIndex & { nextMemory?: MemoryResponseDto; }; +const PAGE_SIZE = 250; + class MemoryManager { - #loading: Promise | undefined; + #loading = $state>(); + #filters: Parameters[0] | undefined; + #hasNextPage: boolean = true; + #page: number = 1; + #total: number | undefined = $state(); + #queued: boolean = false; constructor() { eventManager.on({ @@ -36,7 +50,17 @@ class MemoryManager { this.scheduleHourlyRefresh(); } - ready() { + get filters() { + return this.#filters; + } + + set filters(filters) { + this.#filters = filters; + this.clearCache(); + void this.loadNextPage(); + } + + refresh() { return this.initialize(); } @@ -116,22 +140,64 @@ class MemoryManager { } } + loadNextPage() { + if (this.#hasNextPage) { + if (this.#loading === undefined) { + this.#loading = this.load(this.#page++); + } else { + this.#queued = true; + } + } + } + + get hasNextPage() { + return this.#hasNextPage; + } + + get total() { + return this.#total; + } + + get loading() { + return this.#loading; + } + private clearCache() { this.#loading = undefined; + this.#hasNextPage = true; + this.#page = 1; + this.#total = undefined; this.memories = []; } private initialize() { if (!this.#loading) { - this.#loading = this.load(); + this.#loading = this.load(this.#page++); } return this.#loading; } - private async load() { - const memories = await searchMemories({ $for: DateTime.now().toFormat('yyyy-MM-dd') }); - this.memories = memories.filter((memory) => memory.assets.length > 0); + private async load(page: number) { + if (this.#filters === undefined) { + return; + } + const items = await searchMemories({ size: PAGE_SIZE, ...this.#filters, page }); + this.memories.push(...items); + + if (this.#total === undefined) { + const { total } = await memoriesStatistics(this.#filters); + this.#total = total; + } + + this.#hasNextPage = this.memories.length < this.#total; + this.#loading = undefined; + + if (this.#queued) { + this.#queued = false; + this.#loading = this.load(this.#page++); + await this.#loading; + } } private scheduleHourlyRefresh() { @@ -145,12 +211,19 @@ class MemoryManager { const initialDelay = nextEvent.diff(now).as('milliseconds'); setTimeout(() => { - this.#loading = this.load(); + if (this.#page <= 2) { + this.clearCache(); + this.loadNextPage(); + } // Schedule subsequent events hourly setInterval( () => { - this.#loading = this.load(); + if (this.#page > 2) { + return; + } + this.clearCache(); + this.loadNextPage(); }, 60 * 60 * 1000, ); diff --git a/web/src/lib/route.ts b/web/src/lib/route.ts index d47340a34b..0ab8fe6493 100644 --- a/web/src/lib/route.ts +++ b/web/src/lib/route.ts @@ -83,7 +83,7 @@ export const Route = { '/map' + (point ? `#${point.zoom}/${point.lat}/${point.lng}` : ''), // memories - memories: (params?: { id?: string }) => '/memory' + asQueryString(params), + memories: (params?: { id?: string }) => '/memories' + asQueryString(params), // partners viewPartner: ({ id }: { id: string }) => `/partners/${id}`, diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 3aecb5df59..e99775253e 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -20,12 +20,13 @@ import { type UserResponseDto, } from '@immich/sdk'; import { toastManager, type ActionItem, type IfLike } from '@immich/ui'; +import { DateTime } from 'luxon'; import { init, register, t } from 'svelte-i18n'; import { derived, get } from 'svelte/store'; import { defaultLang, locales } from '$lib/constants'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { downloadManager } from '$lib/managers/download-manager.svelte'; -import { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store'; +import { alwaysLoadOriginalFile, lang, locale } from '$lib/stores/preferences.store'; import { isWebCompatibleImage } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; import { convertBCP47, langs } from '$lib/utils/i18n'; @@ -365,9 +366,13 @@ export const handlePromiseError = (promise: Promise): void => { export const memoryLaneTitle = derived(t, ($t) => { return (memory: MemoryResponseDto) => { - const now = new Date(); if (memory.type === MemoryType.OnThisDay) { - return $t('years_ago', { values: { years: now.getFullYear() - memory.data.year } }); + const now = new Date(); + const memoryDate = new Date(memory.memoryAt); + + return memoryDate.getUTCDate() === now.getDate() && memoryDate.getUTCMonth() === now.getMonth() + ? $t('years_ago', { values: { years: now.getFullYear() - memory.data.year } }) + : DateTime.fromJSDate(memoryDate).toLocaleString(DateTime.DATE_MED, { locale: get(locale) }); } return $t('unknown'); diff --git a/web/src/routes/(user)/explore/+page.svelte b/web/src/routes/(user)/explore/+page.svelte index fa688d7e8b..f7d32affcc 100644 --- a/web/src/routes/(user)/explore/+page.svelte +++ b/web/src/routes/(user)/explore/+page.svelte @@ -6,10 +6,10 @@ import SingleGridRow from '$lib/components/shared-components/SingleGridRow.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { Route } from '$lib/route'; - import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils'; + import { getAssetMediaUrl, getPeopleThumbnailUrl, memoryLaneTitle } from '$lib/utils'; import { getAssetInfo, AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk'; import { authManager } from '$lib/managers/auth-manager.svelte'; - import { Icon } from '@immich/ui'; + import { Icon, ImageCarousel } from '@immich/ui'; import { mdiHeart } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -28,13 +28,22 @@ return targetField?.items || []; }; - let places = $derived(getFieldItems(data.items, 'exifInfo.city')); + let places = $derived(getFieldItems(data.explore, 'exifInfo.city')); let recents = $derived( - getFieldItems(data.items, 'createdAt').sort((a, b) => new Date(b.value).getTime() - new Date(a.value).getTime()), + getFieldItems(data.explore, 'createdAt').sort((a, b) => new Date(b.value).getTime() - new Date(a.value).getTime()), + ); + let people = $state(data.people.people); + let memories = $derived( + data.memories.map((memory) => ({ + id: memory.id, + title: $memoryLaneTitle(memory), + href: Route.memories({ id: memory.assets[0].id }), + alt: $t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } }), + src: getAssetMediaUrl({ id: memory.assets[0].id }), + })), ); - let people = $state(data.response.people); - let hasPeople = $derived(data.response.total > 0); + let hasPeople = $derived(data.people.total > 0); const onPersonThumbnailReady = ({ id }: { id: string }) => { for (const person of people) { @@ -124,6 +133,20 @@ {/if} + {#if memories.length > 0} +
+
+

{$t('memories')}

+ {$t('view_all')} +
+ +
+ {/if} + {#if recents.length > 0}
diff --git a/web/src/routes/(user)/explore/+page.ts b/web/src/routes/(user)/explore/+page.ts index 543c9ce707..3e9e20ca3b 100644 --- a/web/src/routes/(user)/explore/+page.ts +++ b/web/src/routes/(user)/explore/+page.ts @@ -1,16 +1,24 @@ -import { getAllPeople, getExploreData } from '@immich/sdk'; +import { getAllPeople, getExploreData, MemorySearchOrder } from '@immich/sdk'; +import { memoryManager } from '$lib/managers/memory-manager.svelte'; import { authenticate } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; import type { PageLoad } from './$types'; export const load = (async ({ url }) => { await authenticate(url); - const [items, response] = await Promise.all([getExploreData(), getAllPeople({ withHidden: false })]); + memoryManager.filters = { size: 12, order: MemorySearchOrder.Desc }; + + const [explore, people] = await Promise.all([ + getExploreData(), + getAllPeople({ withHidden: false }), + memoryManager.refresh(), + ]); const $t = await getFormatter(); return { - items, - response, + explore, + people, + memories: memoryManager.memories, meta: { title: $t('explore'), }, diff --git a/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/+page.svelte new file mode 100644 index 0000000000..d5cae323b8 --- /dev/null +++ b/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -0,0 +1,126 @@ + + +{#if page.url.searchParams.has(QueryParameter.ID)} + +{:else} + + {#snippet buttons()} +
+ +
+ {/snippet} + {#if memoryManager.memories.length > 0} + + {:else if memoryManager.loading} +
+ +
+ {/if} +
+{/if} diff --git a/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/+page.ts new file mode 100644 index 0000000000..e1cef572c7 --- /dev/null +++ b/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -0,0 +1,27 @@ +import { isEqual } from 'lodash-es'; +import { QueryParameter } from '$lib/constants'; +import { memoryManager } from '$lib/managers/memory-manager.svelte'; +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import type { PageLoad } from './$types'; + +export const load = (async ({ url }) => { + const user = await authenticate(url); + const $t = await getFormatter(); + + const filters = url.searchParams.get('favorites') === 'true' ? { isSaved: true } : {}; + if ( + !(url.searchParams.has(QueryParameter.ID) && memoryManager.memories.length > 0) && + !isEqual(memoryManager.filters, filters) + ) { + memoryManager.filters = filters; + await memoryManager.refresh(); + } + + return { + user, + meta: { + title: $t('memories'), + }, + }; +}) satisfies PageLoad; diff --git a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryPhotoViewer.svelte b/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/MemoryPhotoViewer.svelte similarity index 100% rename from web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryPhotoViewer.svelte rename to web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/MemoryPhotoViewer.svelte diff --git a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryVideoViewer.svelte b/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/MemoryVideoViewer.svelte similarity index 100% rename from web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryVideoViewer.svelte rename to web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/MemoryVideoViewer.svelte diff --git a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte b/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte similarity index 98% rename from web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte rename to web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte index df484fba81..07304963f1 100644 --- a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte +++ b/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte @@ -82,6 +82,7 @@ let progressBarController: Tween | undefined = $state(undefined); let videoPlayer: HTMLVideoElement | undefined = $state(); const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`; + let previousPage = $state(Route.memories()); const handleNavigate = async (asset?: { id: string }) => { if (assetViewerManager.isViewing) { @@ -106,7 +107,7 @@ const handlePreviousAsset = () => handleNavigate(current?.previous?.asset); const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]); const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]); - const handleEscape = async () => goto(Route.photos()); + const handleEscape = async () => goto(previousPage); const handleSelectAll = () => assetMultiSelectManager.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []); @@ -249,7 +250,7 @@ const init = (target: Page | NavigationTarget | null) => { if (memoryManager.memories.length === 0) { - return handlePromiseError(goto(Route.photos())); + return handlePromiseError(goto(previousPage)); } current = loadFromParams(target); @@ -281,8 +282,12 @@ }; afterNavigate(({ from, to }) => { + if (from?.url !== null && !from?.url.searchParams.has(QueryParameter.ID)) { + previousPage = from!.url.href; + } + memoryManager - .ready() + .refresh() .then(() => { let target; if (to?.params?.assetId) { @@ -381,7 +386,7 @@ icon={mdiClose} aria-label={$t('close')} size="large" - onclick={() => goto(Route.photos())} + onclick={() => goto(previousPage)} />

{$memoryLaneTitle(current.memory)} diff --git a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte deleted file mode 100644 index a4d8f34c48..0000000000 --- a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.ts deleted file mode 100644 index 784eeeb6bf..0000000000 --- a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { authenticate } from '$lib/utils/auth'; -import { getFormatter } from '$lib/utils/i18n'; -import type { PageLoad } from './$types'; - -export const load = (async ({ url }) => { - const user = await authenticate(url); - const $t = await getFormatter(); - - return { - user, - meta: { - title: $t('memory'), - }, - }; -}) satisfies PageLoad; diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index 44c1ac4e65..7f008fcf18 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -39,6 +39,7 @@ import { AssetVisibility } from '@immich/sdk'; import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel } from '@immich/ui'; import { mdiDotsVertical } from '@mdi/js'; + import { DateTime } from 'luxon'; import { t } from 'svelte-i18n'; let timelineManager = $state() as TimelineManager; @@ -90,6 +91,10 @@ src: getAssetMediaUrl({ id: memory.assets[0].id }), })), ); + + if (memoryManager.filters === undefined || memoryManager.filters.$for !== DateTime.now().toISODate()) { + memoryManager.filters = { $for: DateTime.now().toISODate() }; + }