diff --git a/e2e/src/ui/generators/memory/model-objects.ts b/e2e/src/ui/generators/memory/model-objects.ts index f81b2f889..6b4d8c725 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 9a3a9e655..1cb93995b 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/memory-viewer.e2e-spec.ts b/e2e/src/ui/specs/memory/memory-viewer.e2e-spec.ts index dbc21ad1c..8b088a382 100644 --- a/e2e/src/ui/specs/memory/memory-viewer.e2e-spec.ts +++ b/e2e/src/ui/specs/memory/memory-viewer.e2e-spec.ts @@ -79,7 +79,7 @@ test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => { const firstMemory = memories[0]; const middleAsset = firstMemory.assets[1]; - await memoryViewerUtils.openMemoryPageWithAsset(page, middleAsset.id); + await memoryViewerUtils.openMemoryPageWithAsset(page, firstMemory.id, middleAsset.id); await memoryGalleryUtils.clickThumbnail(page, middleAsset.id); await memoryAssetViewerUtils.waitForViewerOpen(page); @@ -93,7 +93,7 @@ test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => { const firstMemory = memories[0]; const lastAssetOfFirstMemory = firstMemory.assets.at(-1)!; - await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirstMemory.id); + await memoryViewerUtils.openMemoryPageWithAsset(page, firstMemory.id, lastAssetOfFirstMemory.id); await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirstMemory.id); await memoryAssetViewerUtils.waitForViewerOpen(page); @@ -107,7 +107,7 @@ test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => { const lastMemory = memories.at(-1)!; const firstAssetOfLastMemory = lastMemory.assets[0]; - await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfLastMemory.id); + await memoryViewerUtils.openMemoryPageWithAsset(page, lastMemory.id, firstAssetOfLastMemory.id); await memoryGalleryUtils.clickThumbnail(page, firstAssetOfLastMemory.id); await memoryAssetViewerUtils.waitForViewerOpen(page); @@ -123,7 +123,7 @@ test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => { const lastAssetOfFirst = firstMemory.assets.at(-1)!; const firstAssetOfSecond = secondMemory.assets[0]; - await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirst.id); + await memoryViewerUtils.openMemoryPageWithAsset(page, firstMemory.id, lastAssetOfFirst.id); await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id); await memoryAssetViewerUtils.waitForViewerOpen(page); @@ -141,7 +141,7 @@ test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => { const lastAssetOfFirst = firstMemory.assets.at(-1)!; const firstAssetOfSecond = secondMemory.assets[0]; - await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfSecond.id); + await memoryViewerUtils.openMemoryPageWithAsset(page, secondMemory.id, firstAssetOfSecond.id); await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id); await memoryAssetViewerUtils.waitForViewerOpen(page); @@ -155,7 +155,7 @@ test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => { const firstMemory = memories[0]; const veryFirstAsset = firstMemory.assets[0]; - await memoryViewerUtils.openMemoryPageWithAsset(page, veryFirstAsset.id); + await memoryViewerUtils.openMemoryPageWithAsset(page, firstMemory.id, veryFirstAsset.id); await memoryGalleryUtils.clickThumbnail(page, veryFirstAsset.id); await memoryAssetViewerUtils.waitForViewerOpen(page); @@ -169,7 +169,7 @@ test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => { const lastMemory = memories.at(-1)!; const veryLastAsset = lastMemory.assets.at(-1)!; - await memoryViewerUtils.openMemoryPageWithAsset(page, veryLastAsset.id); + await memoryViewerUtils.openMemoryPageWithAsset(page, lastMemory.id, veryLastAsset.id); await memoryGalleryUtils.clickThumbnail(page, veryLastAsset.id); await memoryAssetViewerUtils.waitForViewerOpen(page); @@ -187,7 +187,7 @@ test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => { const lastAssetOfFirst = firstMemory.assets.at(-1)!; const firstAssetOfSecond = secondMemory.assets[0]; - await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfSecond.id); + await memoryViewerUtils.openMemoryPageWithAsset(page, secondMemory.id, firstAssetOfSecond.id); await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id); await memoryAssetViewerUtils.waitForViewerOpen(page); @@ -203,7 +203,7 @@ test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => { const lastAssetOfFirst = firstMemory.assets.at(-1)!; const firstAssetOfSecond = secondMemory.assets[0]; - await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirst.id); + await memoryViewerUtils.openMemoryPageWithAsset(page, firstMemory.id, lastAssetOfFirst.id); await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id); await memoryAssetViewerUtils.waitForViewerOpen(page); @@ -277,7 +277,7 @@ test.describe('Memory Viewer - Single Asset Memory Edge Cases', () => { const singleAssetMemory = memories[1]; const singleAsset = singleAssetMemory.assets[0]; - await memoryViewerUtils.openMemoryPageWithAsset(page, singleAsset.id); + await memoryViewerUtils.openMemoryPageWithAsset(page, singleAssetMemory.id, singleAsset.id); await memoryGalleryUtils.clickThumbnail(page, singleAsset.id); await memoryAssetViewerUtils.waitForViewerOpen(page); diff --git a/e2e/src/ui/specs/memory/utils.ts b/e2e/src/ui/specs/memory/utils.ts index 378f649cf..1f691d6fc 100644 --- a/e2e/src/ui/specs/memory/utils.ts +++ b/e2e/src/ui/specs/memory/utils.ts @@ -3,11 +3,11 @@ 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]; } - return url.searchParams.get('id'); + return url.searchParams.get('assetId'); } export const memoryViewerUtils = { @@ -20,13 +20,8 @@ export const memoryViewerUtils = { await expect(page.locator('#memory-viewer img').first()).toBeVisible(); }, - async openMemoryPage(page: Page) { - await page.goto('/memory'); - await this.waitForMemoryLoad(page); - }, - - async openMemoryPageWithAsset(page: Page, assetId: string) { - await page.goto(`/memory?id=${assetId}`); + async openMemoryPageWithAsset(page: Page, memoryId: string, assetId: string) { + await page.goto(`/memories/${memoryId}?assetId=${assetId}`); await this.waitForMemoryLoad(page); }, }; diff --git a/i18n/en.json b/i18n/en.json index 1a9d2fa3c..833c5ccde 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1453,9 +1453,12 @@ "memories": "Memories", "memories_all_caught_up": "All caught up", "memories_check_back_tomorrow": "Check back tomorrow for more memories", + "memories_current": "Current", "memories_setting_description": "Manage what you see in your memories", + "memories_show_upcoming": "Show upcoming memories", "memories_start_over": "Start Over", "memories_swipe_to_close": "Swipe up to close", + "memories_upcoming": "Upcoming", "memory": "Memory", "memory_lane_title": "Memory Lane {title}", "menu": "Menu", diff --git a/mise.lock b/mise.lock index 137b1f1c0..592a7c3fb 100644 --- a/mise.lock +++ b/mise.lock @@ -91,18 +91,6 @@ url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets version = "7.1.3-6" backend = "github:jellyfin/jellyfin-ffmpeg" -[tools."github:jellyfin/jellyfin-ffmpeg".options] -asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz" - -[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-arm64"] -checksum = "sha256:e024d5e78d5414e75f0181036cd21373fafb9270c72894dfd7dbda2572439820" -url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_macarm64-gpl.tar.xz" -url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995838" - -[[tools."github:jellyfin/jellyfin-ffmpeg"]] -version = "7.1.3-6" -backend = "github:jellyfin/jellyfin-ffmpeg" - [tools."github:jellyfin/jellyfin-ffmpeg".options] asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz" @@ -145,6 +133,18 @@ checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952 url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz" url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876" +[[tools."github:jellyfin/jellyfin-ffmpeg"]] +version = "7.1.3-6" +backend = "github:jellyfin/jellyfin-ffmpeg" + +[tools."github:jellyfin/jellyfin-ffmpeg".options] +asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz" + +[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-arm64"] +checksum = "sha256:e024d5e78d5414e75f0181036cd21373fafb9270c72894dfd7dbda2572439820" +url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_macarm64-gpl.tar.xz" +url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995838" + [[tools."github:webassembly/binaryen"]] version = "version_124" backend = "github:webassembly/binaryen" diff --git a/mobile/lib/domain/services/memory.service.dart b/mobile/lib/domain/services/memory.service.dart index ccb865fb6..8cc474a1a 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> getAll(String ownerId, {bool onlyFavorites = false}) { + return _repository.getAll(ownerId, onlyToday: false, onlyFavorites: onlyFavorites); + } + 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 fa92c2350..51ed141d2 100644 --- a/mobile/lib/infrastructure/repositories/memory.repository.dart +++ b/mobile/lib/infrastructure/repositories/memory.repository.dart @@ -12,10 +12,7 @@ class MemoryRepository extends DatabaseAccessor with $MemoryRepositoryMix Drift get _db => attachedDatabase; - 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, bool onlyFavorites = false}) async { final query = _db.select(_db.memoryEntity).join([ innerJoin(_db.memoryAssetEntity, _db.memoryAssetEntity.memoryId.equalsExp(_db.memoryEntity.id)), @@ -27,10 +24,21 @@ class MemoryRepository extends DatabaseAccessor with $MemoryRepositoryMix ), ]) ..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 (onlyFavorites) { + query.where(_db.memoryEntity.isSaved.equals(true)); + } + + 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 240c4abe7..1623479ad 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 f2215d169..97925c108 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 137f46659..8a4946823 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(false)); + + 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 000000000..ddf9fd8ae --- /dev/null +++ b/mobile/lib/presentation/pages/drift_memory_list.page.dart @@ -0,0 +1,100 @@ +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/extensions/asyncvalue_extensions.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(_onlyFavorites)); + + 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.widgetWhen( + onData: (memories) { + 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: () { + 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), + ), + ], + ), + ), + ); + }, + ), + ), + ); + }, + ); + } +} diff --git a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart index 173224952..c8b75cf6b 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 f369a3714..1c7478850 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 a2817f9c0..1581ea928 100644 --- a/mobile/lib/providers/infrastructure/memory.provider.dart +++ b/mobile/lib/providers/infrastructure/memory.provider.dart @@ -6,7 +6,7 @@ import 'package:immich_mobile/domain/services/memory.service.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; -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 []; @@ -20,3 +20,13 @@ final driftMemoryFutureProvider = FutureProvider.autoDispose>( final service = DriftMemoryService(ref.watch(driftProvider).memoryRepository); return service.getMemoryLane(userId); }); + +final driftAllMemoriesProvider = FutureProvider.autoDispose.family, bool>((ref, onlyFavorites) { + final (userId, enabled) = ref.watch(currentUserProvider.select((user) => (user?.id, user?.memoryEnabled ?? true))); + if (userId == null || !enabled) { + return const []; + } + + final service = DriftMemoryService(ref.watch(driftProvider).memoryRepository); + return service.getAll(userId, onlyFavorites: onlyFavorites); +}); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 1d6455782..baa260249 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'; @@ -191,6 +192,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/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index 7afd3c827..a196e2ad9 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -19,7 +19,7 @@ final Map> openApiPatches = { 'UserPreferencesResponseDto': { 'download.includeEmbeddedVideos': false, 'folders': FoldersResponse(enabled: false, sidebarWeb: false).toJson(), - 'memories': MemoriesResponse(enabled: true, duration: 5).toJson(), + 'memories': MemoriesResponse(enabled: true, duration: 5, sidebarWeb: false).toJson(), 'ratings': RatingsResponse(enabled: false).toJson(), 'people': PeopleResponse(enabled: true, sidebarWeb: false).toJson(), 'tags': TagsResponse(enabled: false, sidebarWeb: false).toJson(), @@ -40,7 +40,7 @@ final Map> openApiPatches = { 'SyncUserV1': {'profileChangedAt': _now, 'hasProfileImage': false}, 'SyncAssetV1': {'isEdited': false}, 'ServerFeaturesDto': {'ocr': false, 'realtimeTranscoding': false}, - 'MemoriesResponse': {'duration': 5}, + 'MemoriesResponse': {'duration': 5, 'sidebarWeb': false}, 'WorkflowResponseDto': {'logging': false}, }; diff --git a/mobile/test/providers/infrastructure/memory_provider_test.dart b/mobile/test/providers/infrastructure/memory_provider_test.dart index 333a3ad66..f6e519432 100644 --- a/mobile/test/providers/infrastructure/memory_provider_test.dart +++ b/mobile/test/providers/infrastructure/memory_provider_test.dart @@ -52,11 +52,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(() => memoryRepository.getAll('user-1')).called(1); @@ -74,7 +74,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(() => memoryRepository.getAll('user-1')).called(1); @@ -91,7 +91,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 8d128914f..b2bd309f6 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8064,6 +8064,17 @@ "type": "string" } }, + { + "name": "id", + "required": false, + "in": "query", + "description": "Memory ID", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + }, { "name": "isSaved", "required": false, @@ -8082,6 +8093,15 @@ "type": "boolean" } }, + { + "name": "isUpcoming", + "required": false, + "in": "query", + "description": "Filter by memories that have not been shown yet", + "schema": { + "type": "boolean" + } + }, { "name": "order", "required": false, @@ -8090,6 +8110,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, @@ -8233,6 +8264,17 @@ "type": "string" } }, + { + "name": "id", + "required": false, + "in": "query", + "description": "Memory ID", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + }, { "name": "isSaved", "required": false, @@ -8251,6 +8293,15 @@ "type": "boolean" } }, + { + "name": "isUpcoming", + "required": false, + "in": "query", + "description": "Filter by memories that have not been shown yet", + "schema": { + "type": "boolean" + } + }, { "name": "order", "required": false, @@ -8259,6 +8310,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, @@ -22179,11 +22241,16 @@ "enabled": { "description": "Whether memories are enabled", "type": "boolean" + }, + "sidebarWeb": { + "description": "Whether memories appear in web sidebar", + "type": "boolean" } }, "required": [ "duration", - "enabled" + "enabled", + "sidebarWeb" ], "type": "object" }, @@ -22198,6 +22265,10 @@ "enabled": { "description": "Whether memories are enabled", "type": "boolean" + }, + "sidebarWeb": { + "description": "Whether memories appear in web sidebar", + "type": "boolean" } }, "type": "object" diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 98444f4c4..722ed1a25 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -678,6 +678,8 @@ export type MemoriesResponse = { duration: number; /** Whether memories are enabled */ enabled: boolean; + /** Whether memories appear in web sidebar */ + sidebarWeb: boolean; }; export type PeopleResponse = { /** Whether people are enabled */ @@ -762,6 +764,8 @@ export type MemoriesUpdate = { duration?: number; /** Whether memories are enabled */ enabled?: boolean; + /** Whether memories appear in web sidebar */ + sidebarWeb?: boolean; }; export type PeopleUpdate = { /** Whether people are enabled */ @@ -5411,11 +5415,14 @@ export function reverseGeocode({ lat, lon }: { /** * Retrieve memories */ -export function searchMemories({ $for, isSaved, isTrashed, order, size, $type }: { +export function searchMemories({ $for, id, isSaved, isTrashed, isUpcoming, order, page, size, $type }: { $for?: string; + id?: string; isSaved?: boolean; isTrashed?: boolean; + isUpcoming?: boolean; order?: MemorySearchOrder; + page?: number; size?: number; $type?: MemoryType; }, opts?: Oazapfts.RequestOpts) { @@ -5424,9 +5431,12 @@ export function searchMemories({ $for, isSaved, isTrashed, order, size, $type }: data: MemoryResponseDto[]; }>(`/memories${QS.query(QS.explode({ "for": $for, + id, isSaved, isTrashed, + isUpcoming, order, + page, size, "type": $type }))}`, { @@ -5451,11 +5461,14 @@ export function createMemory({ memoryCreateDto }: { /** * Retrieve memories statistics */ -export function memoriesStatistics({ $for, isSaved, isTrashed, order, size, $type }: { +export function memoriesStatistics({ $for, id, isSaved, isTrashed, isUpcoming, order, page, size, $type }: { $for?: string; + id?: string; isSaved?: boolean; isTrashed?: boolean; + isUpcoming?: boolean; order?: MemorySearchOrder; + page?: number; size?: number; $type?: MemoryType; }, opts?: Oazapfts.RequestOpts) { @@ -5464,9 +5477,12 @@ export function memoriesStatistics({ $for, isSaved, isTrashed, order, size, $typ data: MemoryStatisticsResponseDto; }>(`/memories/statistics${QS.query(QS.explode({ "for": $for, + id, isSaved, isTrashed, + isUpcoming, order, + page, size, "type": $type }))}`, { diff --git a/server/src/dtos/memory.dto.ts b/server/src/dtos/memory.dto.ts index 7e2bdf0df..a401d0efa 100644 --- a/server/src/dtos/memory.dto.ts +++ b/server/src/dtos/memory.dto.ts @@ -9,11 +9,14 @@ import z from 'zod'; const MemorySearchSchema = z .object({ + id: z.uuidv4().optional().describe('Memory ID'), type: MemoryTypeSchema.optional(), for: isoDateToDate.optional().describe('Filter by date'), isTrashed: stringToBool.optional().describe('Include trashed memories'), isSaved: stringToBool.optional().describe('Filter by saved status'), + isUpcoming: stringToBool.optional().describe('Filter by memories that have not been shown yet'), 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/dtos/user-preferences.dto.ts b/server/src/dtos/user-preferences.dto.ts index adaa37d1a..5dcb72ae5 100644 --- a/server/src/dtos/user-preferences.dto.ts +++ b/server/src/dtos/user-preferences.dto.ts @@ -22,6 +22,7 @@ const MemoriesUpdateSchema = z .object({ enabled: z.boolean().optional().describe('Whether memories are enabled'), duration: z.int().min(1).optional().describe('Memory duration in seconds'), + sidebarWeb: z.boolean().optional().describe('Whether memories appear in web sidebar'), }) .optional() .meta({ id: 'MemoriesUpdate' }); @@ -140,6 +141,7 @@ const MemoriesResponseSchema = z .object({ enabled: z.boolean().describe('Whether memories are enabled'), duration: z.int().describe('Memory duration in seconds'), + sidebarWeb: z.boolean().describe('Whether memories appear in web sidebar'), }) .meta({ id: 'MemoriesResponse' }); diff --git a/server/src/queries/memory.repository.sql b/server/src/queries/memory.repository.sql index 7730813a5..5386c2fed 100644 --- a/server/src/queries/memory.repository.sql +++ b/server/src/queries/memory.repository.sql @@ -64,6 +64,7 @@ where "deletedAt" is null and "ownerId" = $3 order by + "showAt" desc nulls last, "memoryAt" desc -- MemoryRepository.search (date filter) @@ -112,6 +113,94 @@ where and "deletedAt" is null and "ownerId" = $5 order by + "showAt" desc nulls last, + "memoryAt" desc + +-- MemoryRepository.search (upcoming filter) +select + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset".* + from + "asset" + inner join "memory_asset" on "asset"."id" = "memory_asset"."assetId" + where + "memory_asset"."memoriesId" = "memory"."id" + and "asset"."visibility" = 'timeline' + and "asset"."deletedAt" is null + and not exists ( + select + $1 as "one" + from + "asset_face" + inner join "person" on "person"."personGroupId" = "asset_face"."personGroupId" + and "person"."ownerId" = "asset"."ownerId" + where + "asset_face"."assetId" = "asset"."id" + and "person"."isHidden" = $2 + ) + order by + "asset"."fileCreatedAt" asc + ) as agg + ) as "assets", + "memory".* +from + "memory" +where + "showAt" > $3 + and "deletedAt" is null + and "ownerId" = $4 +order by + "showAt" desc nulls last, + "memoryAt" desc + +-- MemoryRepository.search (not upcoming filter) +select + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset".* + from + "asset" + inner join "memory_asset" on "asset"."id" = "memory_asset"."assetId" + where + "memory_asset"."memoriesId" = "memory"."id" + and "asset"."visibility" = 'timeline' + and "asset"."deletedAt" is null + and not exists ( + select + $1 as "one" + from + "asset_face" + inner join "person" on "person"."personGroupId" = "asset_face"."personGroupId" + and "person"."ownerId" = "asset"."ownerId" + where + "asset_face"."assetId" = "asset"."id" + and "person"."isHidden" = $2 + ) + order by + "asset"."fileCreatedAt" asc + ) as agg + ) as "assets", + "memory".* +from + "memory" +where + ( + "showAt" is null + or "showAt" <= $3 + ) + and "deletedAt" is null + and "ownerId" = $4 +order by + "showAt" desc nulls last, "memoryAt" desc -- MemoryRepository.get diff --git a/server/src/repositories/memory.repository.ts b/server/src/repositories/memory.repository.ts index 9e34efdf3..b2c84afed 100644 --- a/server/src/repositories/memory.repository.ts +++ b/server/src/repositories/memory.repository.ts @@ -39,6 +39,12 @@ export class MemoryRepository implements IBulkAsset { .where((where) => where.or([where('showAt', 'is', null), where('showAt', '<=', dto.for!)])) .where((where) => where.or([where('hideAt', 'is', null), where('hideAt', '>=', dto.for!)])), ) + .$if(dto.isUpcoming !== undefined, (qb) => { + const now = DateTime.now().toJSDate(); + return dto.isUpcoming + ? qb.where('showAt', '>', now) + : qb.where((where) => where.or([where('showAt', 'is', null), where('showAt', '<=', now)])); + }) .where('deletedAt', dto.isTrashed ? 'is not' : 'is', null) .where('ownerId', '=', ownerId); } @@ -56,6 +62,8 @@ export class MemoryRepository implements IBulkAsset { @GenerateSql( { params: [DummyValue.UUID, {}] }, { name: 'date filter', params: [DummyValue.UUID, { for: DummyValue.DATE }] }, + { name: 'upcoming filter', params: [DummyValue.UUID, { isUpcoming: true }] }, + { name: 'not upcoming filter', params: [DummyValue.UUID, { isUpcoming: false }] }, ) search(ownerId: string, dto: MemorySearchDto) { return this.searchBuilder(ownerId, dto) @@ -88,12 +96,19 @@ export class MemoryRepository implements IBulkAsset { ).as('assets'), ) .selectAll('memory') - .$call((qb) => - dto.order === AssetOrderWithRandom.Random - ? qb.orderBy(sql`RANDOM()`) - : qb.orderBy('memoryAt', (dto.order?.toLowerCase() || 'desc') as OrderByDirection), - ) + .$call((qb) => { + if (dto.order === AssetOrderWithRandom.Random) { + return qb.orderBy(sql`RANDOM()`); + } + + const direction = (dto.order?.toLowerCase() || 'desc') as OrderByDirection; + return qb + .orderBy('showAt', (ob) => (direction === 'asc' ? ob.asc() : ob.desc()).nullsLast()) + .orderBy('memoryAt', direction); + }) + .$if(dto.id !== undefined, (qb) => qb.where('id', '=', dto.id!)) .$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 9976189e3..cc35c672f 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/server/src/types.ts b/server/src/types.ts index 2eb996d14..a7c0dbd1b 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -557,6 +557,7 @@ export type UserPreferences = { memories: { enabled: boolean; duration: number; + sidebarWeb: boolean; }; people: { enabled: boolean; diff --git a/server/src/utils/preferences.ts b/server/src/utils/preferences.ts index 9cb294727..807952ca6 100644 --- a/server/src/utils/preferences.ts +++ b/server/src/utils/preferences.ts @@ -17,6 +17,7 @@ const getDefaultPreferences = (): UserPreferences => { memories: { enabled: true, duration: 5, + sidebarWeb: false, }, people: { enabled: true, diff --git a/web/src/lib/components/shared-components/side-bar/UserSidebar.svelte b/web/src/lib/components/shared-components/side-bar/UserSidebar.svelte index a01b4533f..49a06ef1c 100644 --- a/web/src/lib/components/shared-components/side-bar/UserSidebar.svelte +++ b/web/src/lib/components/shared-components/side-bar/UserSidebar.svelte @@ -14,6 +14,8 @@ mdiAccountOutline, mdiArchiveArrowDown, mdiArchiveArrowDownOutline, + mdiCards, + mdiCardsOutline, mdiFolderOutline, mdiHeart, mdiHeartOutline, @@ -48,6 +50,10 @@ {/if} + {#if authManager.preferences.memories.enabled && authManager.preferences.memories.sidebarWeb} + + {/if} + {#if authManager.preferences.people.enabled && authManager.preferences.people.sidebarWeb} {/if} diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index af63ea5ae..6f598b092 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -44,8 +44,10 @@ export const dateFormats = { export enum QueryParameter { ACTION = 'action', + ASSET_ID = 'assetId', ID = 'id', IS_OPEN = 'isOpen', + IS_SAVED = 'isSaved', OPEN_SETTING = 'openSetting', PREVIOUS_ROUTE = 'previousRoute', QUERY = 'query', diff --git a/web/src/lib/managers/asset-viewer-manager.svelte.ts b/web/src/lib/managers/asset-viewer-manager.svelte.ts index 94a6d0070..345776f02 100644 --- a/web/src/lib/managers/asset-viewer-manager.svelte.ts +++ b/web/src/lib/managers/asset-viewer-manager.svelte.ts @@ -2,11 +2,11 @@ import { getAssetInfo, type AssetResponseDto } from '@immich/sdk'; import type { ZoomImageWheelState } from '@zoom-image/core'; import { cubicOut } from 'svelte/easing'; import { authManager } from '$lib/managers/auth-manager.svelte'; +import { userPreferencesManager } from '$lib/managers/user-preferences-manager.svelte'; import type { ImageLoaderStatus } from '$lib/utils/adaptive-image-loader.svelte'; import { canCopyImageToClipboard } from '$lib/utils/asset-utils'; import { BaseEventManager } from '$lib/utils/base-event-manager.svelte'; import type { AssetGridRouteSearchParams } from '$lib/utils/navigation'; -import { PersistedLocalStorage } from '$lib/utils/persisted'; export interface Faces { id: string; @@ -18,9 +18,6 @@ export interface Faces { boundingBoxY2: number; } -const isShowDetailPanel = new PersistedLocalStorage('asset-viewer-state', false); -const isShowAssetPath = new PersistedLocalStorage('asset-viewer-show-path', false); - const createDefaultZoomState = (): ZoomImageWheelState => ({ currentRotation: 0, currentZoom: 1, @@ -75,11 +72,11 @@ class AssetViewerManager extends BaseEventManager { } get isShowDetailPanel() { - return isShowDetailPanel.current; + return userPreferencesManager.showDetailPanel; } get isShowAssetPath() { - return isShowAssetPath.current; + return userPreferencesManager.showAssetPath; } get isFaceEditMode() { @@ -121,11 +118,11 @@ class AssetViewerManager extends BaseEventManager { } private set isShowDetailPanel(value: boolean) { - isShowDetailPanel.current = value; + userPreferencesManager.showDetailPanel = value; } private set isShowAssetPath(value: boolean) { - isShowAssetPath.current = value; + userPreferencesManager.showAssetPath = value; } onZoomChange(state: ZoomImageWheelState) { diff --git a/web/src/lib/managers/memory-manager.svelte.ts b/web/src/lib/managers/memory-manager.svelte.ts index dc4351039..99ad83956 100644 --- a/web/src/lib/managers/memory-manager.svelte.ts +++ b/web/src/lib/managers/memory-manager.svelte.ts @@ -1,26 +1,58 @@ -import { deleteMemory, type MemoryResponseDto, removeMemoryAssets, searchMemories, updateMemory } from '@immich/sdk'; +import { + type AssetResponseDto, + MemorySearchOrder, + deleteMemory, + type MemoryResponseDto, + removeMemoryAssets, + searchMemories, + updateMemory, + memoriesStatistics, +} from '@immich/sdk'; +import { toastManager } from '@immich/ui'; +import { isEqual, omitBy } from 'lodash-es'; import { DateTime } from 'luxon'; +import { t } from 'svelte-i18n'; +import { SvelteMap } from 'svelte/reactivity'; +import { fromStore, get } from 'svelte/store'; +import { goto } from '$app/navigation'; +import { page } from '$app/state'; +import { QueryParameter } from '$lib/constants'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; +import { userPreferencesManager } from '$lib/managers/user-preferences-manager.svelte'; +import { Route } from '$lib/route'; +import { memoryLaneTitle } from '$lib/utils'; import { toTimelineAsset } from '$lib/utils/timeline-util'; -type MemoryIndex = { - memoryIndex: number; - assetIndex: number; +type MemoriesSearchDto = Parameters[0]; + +type AdjacentMemory = { + href: string; + assetId: string; + title: string; }; -export type MemoryAsset = MemoryIndex & { +/** Everything the memory viewer renders, for the position currently in the url. */ +export type MemoryState = { memory: MemoryResponseDto; asset: TimelineAsset; - previousMemory?: MemoryResponseDto; - previous?: MemoryAsset; - next?: MemoryAsset; - nextMemory?: MemoryResponseDto; + /** The assets the asset viewer can navigate through, spanning the adjacent memories. */ + viewerAssets: AssetResponseDto[]; + previousMemory?: AdjacentMemory; + nextMemory?: AdjacentMemory; + previousHref?: string; + nextHref?: string; + getAssetHref: (assetId: string) => string | undefined; }; class MemoryManager { - #loading: Promise | undefined; + #loading = $state>(); + #filters = $state({ size: 250, order: MemorySearchOrder.Desc }); + #hasNextPage: boolean = true; + #page: number = 1; + #total: number | undefined = $state(); + #queued: boolean = false; constructor() { eventManager.on({ @@ -36,44 +68,158 @@ class MemoryManager { this.scheduleHourlyRefresh(); } - ready() { + get filters() { + return this.#filters; + } + + setFilters(filters: MemoriesSearchDto) { + filters = omitBy(filters, (item) => item === undefined); + if (!isEqual(this.#filters, filters)) { + this.#filters = filters; + this.clearCache(); + void this.loadNextPage(); + } + } + + refresh() { return this.initialize(); } - memories = $state([]); - private memoryAssets = $derived.by(() => { - const memoryAssets: MemoryAsset[] = []; - let previous: MemoryAsset | undefined; - for (const [memoryIndex, memory] of this.memories.entries()) { - for (const [assetIndex, asset] of memory.assets.entries()) { - const current = { - memory, - memoryIndex, - previousMemory: this.memories[memoryIndex - 1], - nextMemory: this.memories[memoryIndex + 1], - asset: toTimelineAsset(asset), - assetIndex, - previous, - }; + applyPreferences() { + const { showUpcoming, onlyFavorites } = userPreferencesManager.memories; + this.setFilters({ + order: MemorySearchOrder.Desc, + isSaved: onlyFavorites ? true : undefined, + isUpcoming: showUpcoming ? undefined : false, + }); - memoryAssets.push(current); - - if (previous) { - previous.next = current; - } - - previous = current; - } - } - - return memoryAssets; - }); - - getMemoryAsset(assetId: string | undefined) { - return this.memoryAssets.find((memoryAsset) => memoryAsset.asset.id === assetId) ?? this.memoryAssets[0]; + return this.refresh(); } - hideAssetsFromMemory(ids: string[]) { + memories = $state([]); + + #memoryLaneTitle = fromStore(memoryLaneTitle); + + #url = $derived({ + memoryId: page.params.id, + assetId: page.url.searchParams.get(QueryParameter.ASSET_ID) ?? undefined, + isSaved: page.url.searchParams.get(QueryParameter.IS_SAVED) === 'true' || undefined, + }); + + memoriesHref = $derived(Route.memories({ isSaved: this.#url.isSaved })); + + #lookup = $derived.by(() => { + const lookup = new SvelteMap }>(); + + for (const [memoryIndex, memory] of this.memories.entries()) { + const assetIndexes = new Map(memory.assets.map((asset, assetIndex) => [asset.id, assetIndex] as const)); + lookup.set(memory.id, { memoryIndex, assetIndexes }); + } + + return lookup; + }); + + current = $derived.by((): MemoryState | undefined => { + const { memoryId, assetId } = this.#url; + if (memoryId === undefined || assetId === undefined) { + return; + } + + const location = this.#lookup.get(memoryId); + if (!location) { + return; + } + + const { memoryIndex, assetIndexes } = location; + const assetIndex = assetIndexes.get(assetId); + if (assetIndex === undefined) { + return; + } + + const memory = this.memories[memoryIndex]; + const previousMemory = this.memories[memoryIndex - 1]; + const nextMemory = this.memories[memoryIndex + 1]; + + return { + memory, + asset: toTimelineAsset(memory.assets[assetIndex]), + viewerAssets: [...(previousMemory?.assets ?? []), ...memory.assets, ...(nextMemory?.assets ?? [])], + previousMemory: this.#toAdjacentMemory(previousMemory), + nextMemory: this.#toAdjacentMemory(nextMemory), + previousHref: + assetIndex > 0 + ? this.#toHref(memory, memory.assets[assetIndex - 1]) + : this.#toHref(previousMemory, previousMemory?.assets.at(-1)), + nextHref: + assetIndex < memory.assets.length - 1 + ? this.#toHref(memory, memory.assets[assetIndex + 1]) + : this.#toHref(nextMemory, nextMemory?.assets[0]), + getAssetHref: (assetId: string) => this.#toHref(memory, { id: assetId }), + }; + }); + + #toHref(memory: { id: string } | undefined, asset: { id: string } | undefined) { + return memory && asset + ? Route.viewMemory({ id: memory.id, assetId: asset.id, isSaved: this.#url.isSaved }) + : undefined; + } + + #toAdjacentMemory(memory: MemoryResponseDto | undefined): AdjacentMemory | undefined { + if (!memory) { + return; + } + + const asset = memory.assets[0]; + const href = this.#toHref(memory, asset); + if (!href) { + return; + } + + return { href, assetId: asset.id, title: this.#memoryLaneTitle.current(memory) }; + } + + // the memory holding an asset, checking the given memory first, then the ones next to it + getMemoryWithAsset(assetId: string, memoryId: string) { + const location = this.#lookup.get(memoryId); + if (!location) { + return; + } + + const { memoryIndex } = location; + return [memoryIndex, memoryIndex - 1, memoryIndex + 1] + .map((index) => this.memories[index]) + .find((memory) => memory && this.#lookup.get(memory.id)?.assetIndexes.has(assetId)); + } + + #getMemory(id: string) { + const location = this.#lookup.get(id); + return location ? this.memories[location.memoryIndex] : undefined; + } + + /** Fetch a memory that is not part of the loaded pages, i.e. when opening a link to an older one. */ + async loadMemory(id: string) { + const memory = this.#getMemory(id); + if (memory) { + return memory; + } + + const [loaded] = await searchMemories({ id }).catch(() => []); + if (!loaded) { + return; + } + + // memories are ordered by date, descending + const index = this.memories.findIndex(({ memoryAt }) => memoryAt < loaded.memoryAt); + this.memories.splice(index === -1 ? this.memories.length : index, 0, loaded); + + return loaded; + } + + async hideAssets(ids: string[]) { + if (this.current && ids.includes(this.current.asset.id)) { + await this.#leaveCurrentAsset(); + } + const idSet = new Set(ids); for (const memory of this.memories) { memory.assets = memory.assets.filter((asset) => !idSet.has(asset.id)); @@ -82,56 +228,135 @@ class MemoryManager { this.memories = this.memories.filter((memory) => memory.assets.length > 0); } - async deleteMemory(id: string) { - const memory = this.memories.find((memory) => memory.id === id); - if (memory) { - await deleteMemory({ id: memory.id }); - this.memories = this.memories.filter((memory) => memory.id !== id); + async deleteCurrentAsset() { + const current = this.current; + if (!current) { + return; } + + const memoryId = current.memory.id; + const assetId = current.asset.id; + await this.#leaveCurrentAsset(); + await this.#deleteAsset(memoryId, assetId); } - async deleteAssetFromMemory(assetId: string) { - const memoryWithAsset = this.memories.find((memory) => memory.assets.some((asset) => asset.id === assetId)); + async deleteCurrentMemory() { + const current = this.current; + if (!current) { + return; + } - if (memoryWithAsset) { - if (memoryWithAsset.assets.length === 1) { - await this.deleteMemory(memoryWithAsset.id); + const { id } = current.memory; + await this.#goto(current.nextMemory?.href ?? current.previousMemory?.href ?? this.memoriesHref); + await this.#deleteMemory(id); + toastManager.primary(get(t)('removed_memory')); + } + + async toggleCurrentMemorySaved() { + const memory = this.current?.memory; + if (!memory) { + return; + } + + const isSaved = !memory.isSaved; + await updateMemory({ id: memory.id, memoryUpdateDto: { isSaved } }); + memory.isSaved = isSaved; + toastManager.primary(get(t)(isSaved ? 'added_to_favorites' : 'removed_from_favorites')); + } + + // navigate away before removing something, so the url never points at a deleted position + #leaveCurrentAsset() { + const { nextHref, previousHref } = this.current ?? {}; + return this.#goto(nextHref ?? previousHref ?? this.memoriesHref); + } + + #goto(href: string) { + return goto(href, { replaceState: true, noScroll: true, keepFocus: true }); + } + + async #deleteMemory(id: string) { + if (!this.#getMemory(id)) { + return; + } + + await deleteMemory({ id }); + this.memories = this.memories.filter((memory) => memory.id !== id); + } + + async #deleteAsset(memoryId: string, assetId: string) { + const memory = this.#getMemory(memoryId); + if (!memory?.assets.some((asset) => asset.id === assetId)) { + return; + } + + if (memory.assets.length === 1) { + await this.#deleteMemory(memoryId); + return; + } + + await removeMemoryAssets({ id: memoryId, bulkIdsDto: { ids: [assetId] } }); + memory.assets = memory.assets.filter((asset) => asset.id !== assetId); + } + + loadNextPage() { + if (this.#hasNextPage) { + if (this.#loading === undefined) { + this.#loading = this.load(this.#page++); } else { - await removeMemoryAssets({ id: memoryWithAsset.id, bulkIdsDto: { ids: [assetId] } }); - memoryWithAsset.assets = memoryWithAsset.assets.filter((asset) => asset.id !== assetId); + this.#queued = true; } } } - async updateMemorySaved(id: string, isSaved: boolean) { - const memory = this.memories.find((memory) => memory.id === id); - if (memory) { - await updateMemory({ - id, - memoryUpdateDto: { - isSaved, - }, - }); - memory.isSaved = isSaved; - } + 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) { + const items = await searchMemories({ ...this.#filters, page }); + for (const item of items) { + if (!this.#lookup.has(item.id)) { + this.memories.push(item); + } + } + + 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 +370,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/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts index 3f8277fbc..7016371f8 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -17,8 +17,8 @@ import { retrieveRange as retrieveRangeUtil, } from '$lib/managers/timeline-manager/internal/search-support.svelte'; import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte'; +import { userPreferencesManager } from '$lib/managers/user-preferences-manager.svelte'; import { CancellableTask } from '$lib/utils/cancellable-task'; -import { PersistedLocalStorage } from '$lib/utils/persisted'; import { getOrderingDate, isAssetResponseDto, @@ -94,19 +94,18 @@ export class TimelineManager extends VirtualScrollManager { #options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS; #updatingViewportProximities = false; #scrollableElement: HTMLElement | undefined = $state(); - #showAssetOwners = new PersistedLocalStorage('album-show-asset-owners', false); #unsubscribes: Array<() => void> = []; get showAssetOwners() { - return this.#showAssetOwners.current; + return userPreferencesManager.showAssetOwners; } setShowAssetOwners(value: boolean) { - this.#showAssetOwners.current = value; + userPreferencesManager.showAssetOwners = value; } toggleShowAssetOwners() { - this.#showAssetOwners.current = !this.#showAssetOwners.current; + userPreferencesManager.showAssetOwners = !userPreferencesManager.showAssetOwners; } constructor() { diff --git a/web/src/lib/managers/user-preferences-manager.svelte.ts b/web/src/lib/managers/user-preferences-manager.svelte.ts new file mode 100644 index 000000000..520043934 --- /dev/null +++ b/web/src/lib/managers/user-preferences-manager.svelte.ts @@ -0,0 +1,59 @@ +import { isEqual } from 'lodash-es'; +import { PersistedLocalStorage } from '$lib/utils/persisted'; + +export type MemoriesPreferences = { + showUpcoming: boolean; + onlyFavorites: boolean; +}; + +class UserPreferencesManager { + #showDetailPanel = new PersistedLocalStorage('asset-viewer-state', false); + #showAssetPath = new PersistedLocalStorage('asset-viewer-show-path', false); + #showAssetOwners = new PersistedLocalStorage('album-show-asset-owners', false); + #defaultMemories: MemoriesPreferences = { showUpcoming: false, onlyFavorites: false }; + #memories = new PersistedLocalStorage( + 'memories-settings', + { ...this.#defaultMemories }, + { + upgrade: 'merge', + }, + ); + + get showDetailPanel() { + return this.#showDetailPanel.current; + } + + set showDetailPanel(value: boolean) { + this.#showDetailPanel.current = value; + } + + get showAssetPath() { + return this.#showAssetPath.current; + } + + set showAssetPath(value: boolean) { + this.#showAssetPath.current = value; + } + + get showAssetOwners() { + return this.#showAssetOwners.current; + } + + set showAssetOwners(value: boolean) { + this.#showAssetOwners.current = value; + } + + get memories() { + return this.#memories.current; + } + + set memories(value: MemoriesPreferences) { + this.#memories.current = value; + } + + hasMemoryPreferences() { + return !isEqual(this.memories, this.#defaultMemories); + } +} + +export const userPreferencesManager = new UserPreferencesManager(); diff --git a/web/src/lib/modals/MemoriesSettingsModal.svelte b/web/src/lib/modals/MemoriesSettingsModal.svelte new file mode 100644 index 000000000..55bb607e9 --- /dev/null +++ b/web/src/lib/modals/MemoriesSettingsModal.svelte @@ -0,0 +1,27 @@ + + + + + + + + + + + + diff --git a/web/src/lib/route.ts b/web/src/lib/route.ts index d0a1a3593..c1f3580d2 100644 --- a/web/src/lib/route.ts +++ b/web/src/lib/route.ts @@ -13,7 +13,7 @@ export const fromQueueSlug = (slug: string): QueueName | undefined => { } }; -type QueryValue = number | string; +type QueryValue = number | string | boolean; const asQueryString = ( params?: Record, options?: { skipEmptyStrings?: boolean; skipNullValues?: boolean }, @@ -77,7 +77,11 @@ export const Route = { '/map' + (point ? `#${point.zoom}/${point.lat}/${point.lng}` : ''), // memories - memories: (params?: { id?: string }) => '/memory' + asQueryString(params), + memories: (params?: { isSaved?: boolean }) => '/memories' + asQueryString(params), + viewMemory: ({ id, ...params }: { id: string; assetId?: string; isSaved?: boolean }) => + `/memories/${id}` + asQueryString(params), + viewMemoryAsset: ({ id, assetId, ...params }: { id: string; assetId: string; isSaved?: boolean }) => + `/memories/${id}/photos/${assetId}` + asQueryString({ assetId, ...params }), // partners viewPartner: ({ id }: { id: string }) => `/partners/${id}`, diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 43f99df30..89c312196 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -20,11 +20,12 @@ 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 { 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'; @@ -387,9 +388,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 fa688d7e8..c30f88be2 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.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 }), + })), ); - 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 543c9ce70..e206e14dc 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.setFilters({ 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/+page.svelte b/web/src/routes/(user)/memories/+page.svelte new file mode 100644 index 000000000..c2b4f3d93 --- /dev/null +++ b/web/src/routes/(user)/memories/+page.svelte @@ -0,0 +1,105 @@ + + +{#snippet card(item: CarouselImageItem & { isSaved?: boolean })} + + {item.alt + {#if item.isSaved} +
+ +
+ {/if} +
+

+ {item.title} +

+
+{/snippet} + + + {#snippet buttons()} +
+ +
+ {/snippet} + {#if memoryManager.memories.length > 0} +
+ {#if hasUpcoming} +
{$t('memories_upcoming')}
+ {/if} + {#each memoryManager.memories as memory, index (memory.id)} + {#if hasUpcoming && index === upcomingCount} +
{$t('memories_current')}
+ {/if} + {@render card({ + title: $memoryLaneTitle(memory), + href: Route.viewMemory({ + id: memory.id, + assetId: memory.assets[0].id, + isSaved: userPreferencesManager.memories.onlyFavorites || undefined, + }), + src: getAssetMediaUrl({ id: memory.assets[0].id }), + alt: $getAltText(toTimelineAsset(memory.assets[0])), + isSaved: memory.isSaved, + id: memory.id, + })} + {/each} +
+ {:else if memoryManager.loading} +
+ +
+ {/if} +
diff --git a/web/src/routes/(user)/memories/+page.ts b/web/src/routes/(user)/memories/+page.ts new file mode 100644 index 000000000..3e61386cf --- /dev/null +++ b/web/src/routes/(user)/memories/+page.ts @@ -0,0 +1,18 @@ +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(); + + await memoryManager.applyPreferences(); + + return { + user, + meta: { + title: $t('memories'), + }, + }; +}) satisfies PageLoad; diff --git a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/memories/[id]/[[photos=photos]]/[[assetId=id]]/+page.svelte similarity index 100% rename from web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte rename to web/src/routes/(user)/memories/[id]/[[photos=photos]]/[[assetId=id]]/+page.svelte diff --git a/web/src/routes/(user)/memories/[id]/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/memories/[id]/[[photos=photos]]/[[assetId=id]]/+page.ts new file mode 100644 index 000000000..5bac28d31 --- /dev/null +++ b/web/src/routes/(user)/memories/[id]/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -0,0 +1,51 @@ +import { redirect } from '@sveltejs/kit'; +import { QueryParameter, UUID_REGEX } from '$lib/constants'; +import { memoryManager } from '$lib/managers/memory-manager.svelte'; +import { Route } from '$lib/route'; +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import type { PageLoad } from './$types'; + +export const load = (async ({ url, params }) => { + await authenticate(url); + const $t = await getFormatter(); + + if (!UUID_REGEX.test(params.id)) { + redirect(307, Route.memories()); + } + + await memoryManager.loading; + + const memory = await memoryManager.loadMemory(params.id); + if (!memory) { + redirect(307, memoryManager.memoriesHref); + } + + if (params.assetId) { + const targetMemory = memoryManager.getMemoryWithAsset(params.assetId, memory.id); + if (targetMemory && targetMemory.id !== memory.id) { + redirect(307, Route.viewMemoryAsset({ id: targetMemory.id, assetId: params.assetId })); + } + } + + const assetIds = new Set(memory.assets.map((asset) => asset.id)); + const assetId = + [params.assetId, url.searchParams.get(QueryParameter.ASSET_ID)].find((id) => id && assetIds.has(id)) ?? + memory.assets[0]?.id; + + if (!assetId) { + redirect(307, memoryManager.memoriesHref); + } + + if (url.searchParams.get(QueryParameter.ASSET_ID) !== assetId) { + const target = new URL(url); + target.searchParams.set(QueryParameter.ASSET_ID, assetId); + redirect(307, target.pathname + target.search); + } + + return { + meta: { + title: $t('memory'), + }, + }; +}) satisfies PageLoad; diff --git a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryPhotoViewer.svelte b/web/src/routes/(user)/memories/[id]/[[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/[id]/[[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/[id]/[[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/[id]/[[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/[id]/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte similarity index 75% rename from web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte rename to web/src/routes/(user)/memories/[id]/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte index df484fba8..bee691c4f 100644 --- a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte +++ b/web/src/routes/(user)/memories/[id]/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte @@ -1,6 +1,5 @@ diff --git a/web/src/routes/(user)/user-settings/FeatureSettings.svelte b/web/src/routes/(user)/user-settings/FeatureSettings.svelte index 5761488fb..afdc7e2ad 100644 --- a/web/src/routes/(user)/user-settings/FeatureSettings.svelte +++ b/web/src/routes/(user)/user-settings/FeatureSettings.svelte @@ -18,6 +18,7 @@ // Memories let memoriesEnabled = $state(authManager.preferences.memories?.enabled ?? true); let memoriesDuration = $state(authManager.preferences.memories?.duration ?? 5); + let memoriesSidebar = $state(authManager.preferences.memories?.sidebarWeb ?? false); // People let peopleEnabled = $state(authManager.preferences.people?.enabled ?? false); @@ -47,7 +48,7 @@ userPreferencesUpdateDto: { albums: { defaultAssetOrder }, folders: { enabled: foldersEnabled, sidebarWeb: foldersSidebar }, - memories: { enabled: memoriesEnabled, duration: memoriesDuration }, + memories: { enabled: memoriesEnabled, duration: memoriesDuration, sidebarWeb: memoriesSidebar }, people: { enabled: peopleEnabled, sidebarWeb: peopleSidebar, minimumFaces: peopleMinFaces }, ratings: { enabled: ratingsEnabled }, sharedLinks: { enabled: sharedLinksEnabled, sidebarWeb: sharedLinkSidebar }, @@ -107,6 +108,12 @@ + {#if memoriesEnabled} + + + + {/if} + diff --git a/web/src/test-data/factories/preferences-factory.ts b/web/src/test-data/factories/preferences-factory.ts index 43ce044b1..2103810ff 100644 --- a/web/src/test-data/factories/preferences-factory.ts +++ b/web/src/test-data/factories/preferences-factory.ts @@ -24,6 +24,7 @@ export const preferencesFactory = Sync.makeFactory({ memories: { enabled: false, duration: 5, + sidebarWeb: false, }, people: { enabled: false,