mirror of
https://github.com/immich-app/immich
synced 2026-08-15 13:03:57 +00:00
Merge 0111e939de into 652ef8a427
This commit is contained in:
commit
808514b13d
32 changed files with 569 additions and 70 deletions
|
|
@ -80,5 +80,7 @@ export function generateMemoriesFromTimeline(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
memories.sort((a, b) => new Date(b.memoryAt).getMilliseconds() - new Date(a.memoryAt).getMilliseconds());
|
||||||
|
|
||||||
return memories;
|
return memories;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,4 +62,14 @@ export const setupMemoryMockApiRoutes = async (
|
||||||
|
|
||||||
await route.fallback();
|
await route.fallback();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await context.route('**/api/memories/statistics*', async (route) => {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
json: {
|
||||||
|
total: memories.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { AssetResponseDto } from '@immich/sdk';
|
||||||
import { expect, Page } from '@playwright/test';
|
import { expect, Page } from '@playwright/test';
|
||||||
|
|
||||||
function getAssetIdFromUrl(url: URL): string | null {
|
function getAssetIdFromUrl(url: URL): string | null {
|
||||||
const pathMatch = url.pathname.match(/\/memory\/photos\/([^/]+)/);
|
const pathMatch = url.pathname.match(/\/memories\/photos\/([^/]+)/);
|
||||||
if (pathMatch) {
|
if (pathMatch) {
|
||||||
return pathMatch[1];
|
return pathMatch[1];
|
||||||
}
|
}
|
||||||
|
|
@ -21,12 +21,12 @@ export const memoryViewerUtils = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async openMemoryPage(page: Page) {
|
async openMemoryPage(page: Page) {
|
||||||
await page.goto('/memory');
|
await page.goto('/memories');
|
||||||
await this.waitForMemoryLoad(page);
|
await this.waitForMemoryLoad(page);
|
||||||
},
|
},
|
||||||
|
|
||||||
async openMemoryPageWithAsset(page: Page, assetId: string) {
|
async openMemoryPageWithAsset(page: Page, assetId: string) {
|
||||||
await page.goto(`/memory?id=${assetId}`);
|
await page.goto(`/memories?id=${assetId}`);
|
||||||
await this.waitForMemoryLoad(page);
|
await this.waitForMemoryLoad(page);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ class DriftMemoryService {
|
||||||
return _repository.getAll(ownerId);
|
return _repository.getAll(ownerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<DriftMemory>> getAllMemories(String ownerId) {
|
||||||
|
return _repository.getAll(ownerId, onlyToday: false);
|
||||||
|
}
|
||||||
|
|
||||||
Future<DriftMemory?> get(String memoryId) {
|
Future<DriftMemory?> get(String memoryId) {
|
||||||
return _repository.get(memoryId);
|
return _repository.get(memoryId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,7 @@ class DriftMemoryRepository extends DriftDatabaseRepository {
|
||||||
final Drift _db;
|
final Drift _db;
|
||||||
const DriftMemoryRepository(this._db) : super(_db);
|
const DriftMemoryRepository(this._db) : super(_db);
|
||||||
|
|
||||||
Future<List<DriftMemory>> getAll(String ownerId) async {
|
Future<List<DriftMemory>> getAll(String ownerId, {bool onlyToday = true}) async {
|
||||||
final now = DateTime.now();
|
|
||||||
final localUtc = DateTime.utc(now.year, now.month, now.day, 0, 0, 0);
|
|
||||||
|
|
||||||
final query =
|
final query =
|
||||||
_db.select(_db.memoryEntity).join([
|
_db.select(_db.memoryEntity).join([
|
||||||
innerJoin(_db.memoryAssetEntity, _db.memoryAssetEntity.memoryId.equalsExp(_db.memoryEntity.id)),
|
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.ownerId.equals(ownerId))
|
||||||
..where(_db.memoryEntity.deletedAt.isNull())
|
..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))
|
if (onlyToday) {
|
||||||
..orderBy([OrderingTerm.desc(_db.memoryEntity.memoryAt), OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]);
|
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();
|
final rows = await query.get();
|
||||||
if (rows.isEmpty) {
|
if (rows.isEmpty) {
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index == kPhotoTabIndex) {
|
if (index == kPhotoTabIndex) {
|
||||||
ref.invalidate(driftMemoryFutureProvider);
|
ref.invalidate(driftMemoryLaneProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (router.activeIndex != kSearchTabIndex && index == kSearchTabIndex) {
|
if (router.activeIndex != kSearchTabIndex && index == kSearchTabIndex) {
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ class _MainTimelinePageState extends ConsumerState<MainTimelinePage> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return Timeline(
|
||||||
topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()),
|
topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()),
|
||||||
topSliverWidgetHeight: hasMemories ? 200 : 0,
|
topSliverWidgetHeight: hasMemories ? 200 : 0,
|
||||||
|
|
|
||||||
|
|
@ -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/generated/translations.g.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/local_album_thumbnail.widget.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/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/presentation/widgets/people/partner_user_avatar.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/album.provider.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/people.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
|
|
@ -133,7 +135,12 @@ class _CollectionCards extends StatelessWidget {
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 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
|
@visibleForTesting
|
||||||
final sharedWithPartnerProvider = StreamProvider.autoDispose<Iterable<Partner>>((ref) {
|
final sharedWithPartnerProvider = StreamProvider.autoDispose<Iterable<Partner>>((ref) {
|
||||||
final currentUser = ref.watch(currentUserProvider);
|
final currentUser = ref.watch(currentUserProvider);
|
||||||
|
|
|
||||||
107
mobile/lib/presentation/pages/drift_memory_list.page.dart
Normal file
107
mobile/lib/presentation/pages/drift_memory_list.page.dart
Normal file
|
|
@ -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<DriftMemoryListPage> createState() => _DriftMemoryListPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DriftMemoryListPageState extends ConsumerState<DriftMemoryListPage> {
|
||||||
|
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()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,7 +16,7 @@ class DriftMemoryLane extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final memoryLaneProvider = ref.watch(driftMemoryFutureProvider);
|
final memoryLaneProvider = ref.watch(driftMemoryLaneProvider);
|
||||||
final memories = memoryLaneProvider.value ?? const [];
|
final memories = memoryLaneProvider.value ?? const [];
|
||||||
if (memories.isEmpty) {
|
if (memories.isEmpty) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||||
syncSuccess = await backgroundManager.syncRemote();
|
syncSuccess = await backgroundManager.syncRemote();
|
||||||
}, "syncRemote"),
|
}, "syncRemote"),
|
||||||
]);
|
]);
|
||||||
_ref.invalidate(driftMemoryFutureProvider);
|
_ref.invalidate(driftAllMemoriesProvider);
|
||||||
if (syncSuccess) {
|
if (syncSuccess) {
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
_safeRun(backgroundManager.hashAssets, "hashAssets").then((_) {
|
_safeRun(backgroundManager.hashAssets, "hashAssets").then((_) {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ final driftMemoryServiceProvider = Provider<DriftMemoryService>(
|
||||||
(ref) => DriftMemoryService(ref.watch(driftMemoryRepositoryProvider)),
|
(ref) => DriftMemoryService(ref.watch(driftMemoryRepositoryProvider)),
|
||||||
);
|
);
|
||||||
|
|
||||||
final driftMemoryFutureProvider = FutureProvider.autoDispose<List<DriftMemory>>((ref) {
|
final driftMemoryLaneProvider = FutureProvider.autoDispose<List<DriftMemory>>((ref) {
|
||||||
final (userId, enabled) = ref.watch(currentUserProvider.select((user) => (user?.id, user?.memoryEnabled ?? true)));
|
final (userId, enabled) = ref.watch(currentUserProvider.select((user) => (user?.id, user?.memoryEnabled ?? true)));
|
||||||
if (userId == null || !enabled) {
|
if (userId == null || !enabled) {
|
||||||
return const [];
|
return const [];
|
||||||
|
|
@ -29,3 +29,13 @@ final driftMemoryFutureProvider = FutureProvider.autoDispose<List<DriftMemory>>(
|
||||||
final service = ref.watch(driftMemoryServiceProvider);
|
final service = ref.watch(driftMemoryServiceProvider);
|
||||||
return service.getMemoryLane(userId);
|
return service.getMemoryLane(userId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final driftAllMemoriesProvider = FutureProvider.autoDispose<List<DriftMemory>>((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);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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_locked_folder.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/drift_map.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.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_partner_detail.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/drift_people_collection.page.dart';
|
import 'package:immich_mobile/presentation/pages/drift_people_collection.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/drift_person.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: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: DriftSlideshowRoute.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
|
// required to handle all deeplinks in deep_link.service.dart
|
||||||
// auto_route_library#1722
|
// auto_route_library#1722
|
||||||
RedirectRoute(path: '*', redirectTo: '/'),
|
RedirectRoute(path: '*', redirectTo: '/'),
|
||||||
|
|
|
||||||
|
|
@ -44,11 +44,11 @@ void main() {
|
||||||
when(() => userService.watchMyUser()).thenAnswer((_) => const Stream.empty());
|
when(() => userService.watchMyUser()).thenAnswer((_) => const Stream.empty());
|
||||||
});
|
});
|
||||||
|
|
||||||
group('driftMemoryFutureProvider', () {
|
group('driftMemoryLaneProvider', () {
|
||||||
test('re-queries after local midnight', () {
|
test('re-queries after local midnight', () {
|
||||||
fakeAsync((async) {
|
fakeAsync((async) {
|
||||||
final container = makeContainer();
|
final container = makeContainer();
|
||||||
container.listen(driftMemoryFutureProvider, (_, __) {});
|
container.listen(driftMemoryLaneProvider, (_, __) {});
|
||||||
async.flushMicrotasks();
|
async.flushMicrotasks();
|
||||||
|
|
||||||
verify(() => memoryService.getMemoryLane('user-1')).called(1);
|
verify(() => memoryService.getMemoryLane('user-1')).called(1);
|
||||||
|
|
@ -66,7 +66,7 @@ void main() {
|
||||||
test('cancels the midnight timer when disposed', () {
|
test('cancels the midnight timer when disposed', () {
|
||||||
fakeAsync((async) {
|
fakeAsync((async) {
|
||||||
final container = makeContainer();
|
final container = makeContainer();
|
||||||
final subscription = container.listen(driftMemoryFutureProvider, (_, __) {});
|
final subscription = container.listen(driftMemoryLaneProvider, (_, __) {});
|
||||||
async.flushMicrotasks();
|
async.flushMicrotasks();
|
||||||
verify(() => memoryService.getMemoryLane('user-1')).called(1);
|
verify(() => memoryService.getMemoryLane('user-1')).called(1);
|
||||||
|
|
||||||
|
|
@ -83,7 +83,7 @@ void main() {
|
||||||
|
|
||||||
fakeAsync((async) {
|
fakeAsync((async) {
|
||||||
final container = makeContainer();
|
final container = makeContainer();
|
||||||
container.listen(driftMemoryFutureProvider, (_, __) {});
|
container.listen(driftMemoryLaneProvider, (_, __) {});
|
||||||
async.flushMicrotasks();
|
async.flushMicrotasks();
|
||||||
|
|
||||||
async.elapse(const Duration(hours: 25));
|
async.elapse(const Duration(hours: 25));
|
||||||
|
|
|
||||||
|
|
@ -7190,6 +7190,17 @@
|
||||||
"$ref": "#/components/schemas/MemorySearchOrder"
|
"$ref": "#/components/schemas/MemorySearchOrder"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "page",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Page number",
|
||||||
|
"schema": {
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 9007199254740991,
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "size",
|
"name": "size",
|
||||||
"required": false,
|
"required": false,
|
||||||
|
|
@ -7359,6 +7370,17 @@
|
||||||
"$ref": "#/components/schemas/MemorySearchOrder"
|
"$ref": "#/components/schemas/MemorySearchOrder"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "page",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Page number",
|
||||||
|
"schema": {
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 9007199254740991,
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "size",
|
"name": "size",
|
||||||
"required": false,
|
"required": false,
|
||||||
|
|
|
||||||
|
|
@ -5006,11 +5006,12 @@ export function reverseGeocode({ lat, lon }: {
|
||||||
/**
|
/**
|
||||||
* Retrieve memories
|
* Retrieve memories
|
||||||
*/
|
*/
|
||||||
export function searchMemories({ $for, isSaved, isTrashed, order, size, $type }: {
|
export function searchMemories({ $for, isSaved, isTrashed, order, page, size, $type }: {
|
||||||
$for?: string;
|
$for?: string;
|
||||||
isSaved?: boolean;
|
isSaved?: boolean;
|
||||||
isTrashed?: boolean;
|
isTrashed?: boolean;
|
||||||
order?: MemorySearchOrder;
|
order?: MemorySearchOrder;
|
||||||
|
page?: number;
|
||||||
size?: number;
|
size?: number;
|
||||||
$type?: MemoryType;
|
$type?: MemoryType;
|
||||||
}, opts?: Oazapfts.RequestOpts) {
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
|
@ -5022,6 +5023,7 @@ export function searchMemories({ $for, isSaved, isTrashed, order, size, $type }:
|
||||||
isSaved,
|
isSaved,
|
||||||
isTrashed,
|
isTrashed,
|
||||||
order,
|
order,
|
||||||
|
page,
|
||||||
size,
|
size,
|
||||||
"type": $type
|
"type": $type
|
||||||
}))}`, {
|
}))}`, {
|
||||||
|
|
@ -5046,11 +5048,12 @@ export function createMemory({ memoryCreateDto }: {
|
||||||
/**
|
/**
|
||||||
* Retrieve memories statistics
|
* Retrieve memories statistics
|
||||||
*/
|
*/
|
||||||
export function memoriesStatistics({ $for, isSaved, isTrashed, order, size, $type }: {
|
export function memoriesStatistics({ $for, isSaved, isTrashed, order, page, size, $type }: {
|
||||||
$for?: string;
|
$for?: string;
|
||||||
isSaved?: boolean;
|
isSaved?: boolean;
|
||||||
isTrashed?: boolean;
|
isTrashed?: boolean;
|
||||||
order?: MemorySearchOrder;
|
order?: MemorySearchOrder;
|
||||||
|
page?: number;
|
||||||
size?: number;
|
size?: number;
|
||||||
$type?: MemoryType;
|
$type?: MemoryType;
|
||||||
}, opts?: Oazapfts.RequestOpts) {
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
|
@ -5062,6 +5065,7 @@ export function memoriesStatistics({ $for, isSaved, isTrashed, order, size, $typ
|
||||||
isSaved,
|
isSaved,
|
||||||
isTrashed,
|
isTrashed,
|
||||||
order,
|
order,
|
||||||
|
page,
|
||||||
size,
|
size,
|
||||||
"type": $type
|
"type": $type
|
||||||
}))}`, {
|
}))}`, {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ const MemorySearchSchema = z
|
||||||
isTrashed: stringToBool.optional().describe('Include trashed memories'),
|
isTrashed: stringToBool.optional().describe('Include trashed memories'),
|
||||||
isSaved: stringToBool.optional().describe('Filter by saved status'),
|
isSaved: stringToBool.optional().describe('Filter by saved status'),
|
||||||
size: z.coerce.number().int().min(1).optional().describe('Number of memories to return'),
|
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(),
|
order: AssetOrderWithRandomSchema.optional(),
|
||||||
})
|
})
|
||||||
.meta({ id: 'MemorySearchDto' });
|
.meta({ id: 'MemorySearchDto' });
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@ export class MemoryRepository implements IBulkAsset {
|
||||||
: qb.orderBy('memoryAt', (dto.order?.toLowerCase() || 'desc') as OrderByDirection),
|
: qb.orderBy('memoryAt', (dto.order?.toLowerCase() || 'desc') as OrderByDirection),
|
||||||
)
|
)
|
||||||
.$if(dto.size !== undefined, (qb) => qb.limit(dto.size!))
|
.$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();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ describe(MemoryService.name, () => {
|
||||||
const memory1 = MemoryFactory.from({ ownerId: userId }).asset(asset).build();
|
const memory1 = MemoryFactory.from({ ownerId: userId }).asset(asset).build();
|
||||||
const memory2 = MemoryFactory.create({ ownerId: userId });
|
const memory2 = MemoryFactory.create({ ownerId: userId });
|
||||||
mocks.memory.search.mockResolvedValue([getForMemory(memory1), getForMemory(memory2)]);
|
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(
|
await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toEqual(
|
||||||
expect.arrayContaining([
|
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 () => {
|
it('should map empty result', async () => {
|
||||||
|
|
|
||||||
|
|
@ -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 { DateTime } from 'luxon';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
|
|
@ -19,8 +26,15 @@ export type MemoryAsset = MemoryIndex & {
|
||||||
nextMemory?: MemoryResponseDto;
|
nextMemory?: MemoryResponseDto;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PAGE_SIZE = 250;
|
||||||
|
|
||||||
class MemoryManager {
|
class MemoryManager {
|
||||||
#loading: Promise<void> | undefined;
|
#loading = $state<Promise<void>>();
|
||||||
|
#filters: Parameters<typeof searchMemories>[0] | undefined;
|
||||||
|
#hasNextPage: boolean = true;
|
||||||
|
#page: number = 1;
|
||||||
|
#total: number | undefined = $state();
|
||||||
|
#queued: boolean = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
eventManager.on({
|
eventManager.on({
|
||||||
|
|
@ -36,7 +50,17 @@ class MemoryManager {
|
||||||
this.scheduleHourlyRefresh();
|
this.scheduleHourlyRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
ready() {
|
get filters() {
|
||||||
|
return this.#filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
set filters(filters) {
|
||||||
|
this.#filters = filters;
|
||||||
|
this.clearCache();
|
||||||
|
void this.loadNextPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh() {
|
||||||
return this.initialize();
|
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() {
|
private clearCache() {
|
||||||
this.#loading = undefined;
|
this.#loading = undefined;
|
||||||
|
this.#hasNextPage = true;
|
||||||
|
this.#page = 1;
|
||||||
|
this.#total = undefined;
|
||||||
this.memories = [];
|
this.memories = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
private initialize() {
|
private initialize() {
|
||||||
if (!this.#loading) {
|
if (!this.#loading) {
|
||||||
this.#loading = this.load();
|
this.#loading = this.load(this.#page++);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.#loading;
|
return this.#loading;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async load() {
|
private async load(page: number) {
|
||||||
const memories = await searchMemories({ $for: DateTime.now().toFormat('yyyy-MM-dd') });
|
if (this.#filters === undefined) {
|
||||||
this.memories = memories.filter((memory) => memory.assets.length > 0);
|
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() {
|
private scheduleHourlyRefresh() {
|
||||||
|
|
@ -145,12 +211,19 @@ class MemoryManager {
|
||||||
const initialDelay = nextEvent.diff(now).as('milliseconds');
|
const initialDelay = nextEvent.diff(now).as('milliseconds');
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.#loading = this.load();
|
if (this.#page <= 2) {
|
||||||
|
this.clearCache();
|
||||||
|
this.loadNextPage();
|
||||||
|
}
|
||||||
|
|
||||||
// Schedule subsequent events hourly
|
// Schedule subsequent events hourly
|
||||||
setInterval(
|
setInterval(
|
||||||
() => {
|
() => {
|
||||||
this.#loading = this.load();
|
if (this.#page > 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.clearCache();
|
||||||
|
this.loadNextPage();
|
||||||
},
|
},
|
||||||
60 * 60 * 1000,
|
60 * 60 * 1000,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ export const Route = {
|
||||||
'/map' + (point ? `#${point.zoom}/${point.lat}/${point.lng}` : ''),
|
'/map' + (point ? `#${point.zoom}/${point.lat}/${point.lng}` : ''),
|
||||||
|
|
||||||
// memories
|
// memories
|
||||||
memories: (params?: { id?: string }) => '/memory' + asQueryString(params),
|
memories: (params?: { id?: string }) => '/memories' + asQueryString(params),
|
||||||
|
|
||||||
// partners
|
// partners
|
||||||
viewPartner: ({ id }: { id: string }) => `/partners/${id}`,
|
viewPartner: ({ id }: { id: string }) => `/partners/${id}`,
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,13 @@ import {
|
||||||
type UserResponseDto,
|
type UserResponseDto,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { toastManager, type ActionItem, type IfLike } from '@immich/ui';
|
import { toastManager, type ActionItem, type IfLike } from '@immich/ui';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
import { init, register, t } from 'svelte-i18n';
|
import { init, register, t } from 'svelte-i18n';
|
||||||
import { derived, get } from 'svelte/store';
|
import { derived, get } from 'svelte/store';
|
||||||
import { defaultLang, locales } from '$lib/constants';
|
import { defaultLang, locales } from '$lib/constants';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { downloadManager } from '$lib/managers/download-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 { isWebCompatibleImage } from '$lib/utils/asset-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { convertBCP47, langs } from '$lib/utils/i18n';
|
import { convertBCP47, langs } from '$lib/utils/i18n';
|
||||||
|
|
@ -365,9 +366,13 @@ export const handlePromiseError = <T>(promise: Promise<T>): void => {
|
||||||
|
|
||||||
export const memoryLaneTitle = derived(t, ($t) => {
|
export const memoryLaneTitle = derived(t, ($t) => {
|
||||||
return (memory: MemoryResponseDto) => {
|
return (memory: MemoryResponseDto) => {
|
||||||
const now = new Date();
|
|
||||||
if (memory.type === MemoryType.OnThisDay) {
|
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');
|
return $t('unknown');
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,10 @@
|
||||||
import SingleGridRow from '$lib/components/shared-components/SingleGridRow.svelte';
|
import SingleGridRow from '$lib/components/shared-components/SingleGridRow.svelte';
|
||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
import { Route } from '$lib/route';
|
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 { getAssetInfo, AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
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 { mdiHeart } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
@ -28,13 +28,22 @@
|
||||||
return targetField?.items || [];
|
return targetField?.items || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
let places = $derived(getFieldItems(data.items, 'exifInfo.city'));
|
let places = $derived(getFieldItems(data.explore, 'exifInfo.city'));
|
||||||
let recents = $derived(
|
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 }) => {
|
const onPersonThumbnailReady = ({ id }: { id: string }) => {
|
||||||
for (const person of people) {
|
for (const person of people) {
|
||||||
|
|
@ -124,6 +133,20 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if memories.length > 0}
|
||||||
|
<div class="mt-2 mb-6">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<p class="mb-4 font-medium dark:text-immich-dark-fg">{$t('memories')}</p>
|
||||||
|
<a
|
||||||
|
href={Route.memories()}
|
||||||
|
class="pe-4 text-sm font-medium hover:text-immich-primary dark:text-immich-dark-fg dark:hover:text-immich-dark-primary"
|
||||||
|
draggable="false">{$t('view_all')}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<ImageCarousel items={memories} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if recents.length > 0}
|
{#if recents.length > 0}
|
||||||
<div class="mt-2 mb-6">
|
<div class="mt-2 mb-6">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
|
|
|
||||||
|
|
@ -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 { authenticate } from '$lib/utils/auth';
|
||||||
import { getFormatter } from '$lib/utils/i18n';
|
import { getFormatter } from '$lib/utils/i18n';
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const load = (async ({ url }) => {
|
export const load = (async ({ url }) => {
|
||||||
await authenticate(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();
|
const $t = await getFormatter();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items,
|
explore,
|
||||||
response,
|
people,
|
||||||
|
memories: memoryManager.memories,
|
||||||
meta: {
|
meta: {
|
||||||
title: $t('explore'),
|
title: $t('explore'),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
import { Route } from '$lib/route';
|
||||||
|
import { getAssetMediaUrl, memoryLaneTitle } from '$lib/utils';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
import { mdiHeartOutline, mdiHeart } from '@mdi/js';
|
||||||
|
import { Button, Icon, LoadingSpinner } from '@immich/ui';
|
||||||
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
|
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||||
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import MemoryViewer from './MemoryViewer.svelte';
|
||||||
|
import { QueryParameter } from '$lib/constants';
|
||||||
|
import { memoryManager } from '$lib/managers/memory-manager.svelte';
|
||||||
|
import { clearQueryParam, setQueryValue } from '$lib/utils/navigation';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
data: PageData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data }: Props = $props();
|
||||||
|
let onlyFavorites = $state(page.url.searchParams.get('favorites') === 'true');
|
||||||
|
let lastElement: HTMLElement | null | undefined = $state();
|
||||||
|
|
||||||
|
const toggleFavorites = async () => {
|
||||||
|
onlyFavorites = !onlyFavorites;
|
||||||
|
memoryManager.filters = onlyFavorites ? { isSaved: true } : {};
|
||||||
|
await memoryManager.refresh();
|
||||||
|
|
||||||
|
if (onlyFavorites) {
|
||||||
|
await setQueryValue('favorites', 'true');
|
||||||
|
} else {
|
||||||
|
await clearQueryParam('favorites', page.url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const intersectionObserver = new IntersectionObserver((entries) => {
|
||||||
|
const entry = entries.find((entry) => entry.target === lastElement);
|
||||||
|
if (entry?.isIntersecting && memoryManager.hasNextPage) {
|
||||||
|
void memoryManager.loadNextPage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!lastElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
intersectionObserver.disconnect();
|
||||||
|
intersectionObserver.observe(lastElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rotationClasses = [
|
||||||
|
'rotate-[-2.5deg]',
|
||||||
|
'-rotate-2',
|
||||||
|
'rotate-[-1.5deg]',
|
||||||
|
'-rotate-1',
|
||||||
|
'rotate-[-0.5deg]',
|
||||||
|
'rotate-0',
|
||||||
|
'rotate-[0.5deg]',
|
||||||
|
'rotate-1',
|
||||||
|
'rotate-[1.5deg]',
|
||||||
|
'rotate-2',
|
||||||
|
'rotate-[2.5deg]',
|
||||||
|
];
|
||||||
|
|
||||||
|
const getRotation = () => rotationClasses[Math.floor(Math.random() * rotationClasses.length)];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if page.url.searchParams.has(QueryParameter.ID)}
|
||||||
|
<MemoryViewer />
|
||||||
|
{:else}
|
||||||
|
<UserPageLayout
|
||||||
|
title={data.meta.title}
|
||||||
|
description={memoryManager.total === undefined ? undefined : `(${memoryManager.total.toLocaleString($locale)})`}
|
||||||
|
>
|
||||||
|
{#snippet buttons()}
|
||||||
|
<div class="flex place-items-center gap-2">
|
||||||
|
<Button
|
||||||
|
leadingIcon={mdiHeartOutline}
|
||||||
|
size="small"
|
||||||
|
variant={onlyFavorites ? 'filled' : 'ghost'}
|
||||||
|
color="secondary"
|
||||||
|
onclick={() => toggleFavorites()}>{$t('only_favorites')}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
{#if memoryManager.memories.length > 0}
|
||||||
|
<div class="grid w-full grid-cols-3 gap-7 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-7">
|
||||||
|
{#each memoryManager.memories as memory, index (memory.id)}
|
||||||
|
<a
|
||||||
|
href={Route.memories({ id: memory.assets[0].id })}
|
||||||
|
class={`relative rounded-md bg-light-100 p-2 pb-0 shadow-md transition-all hover:scale-102 hover:rotate-0 hover:shadow-lg sm:p-5 sm:pb-0 ${getRotation()}`}
|
||||||
|
bind:this={
|
||||||
|
() => (index === memoryManager.memories.length - 1 ? lastElement : null),
|
||||||
|
(e) => {
|
||||||
|
if (index === memoryManager.memories.length - 1) {
|
||||||
|
lastElement = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={getAssetMediaUrl({ id: memory.assets[0].id })}
|
||||||
|
alt={$getAltText(toTimelineAsset(memory.assets[0]))}
|
||||||
|
class="aspect-square object-cover brightness-75"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{#if memory.isSaved}
|
||||||
|
<div class="absolute inset-s-2 top-2">
|
||||||
|
<Icon data-icon-favorite icon={mdiHeart} size="32" class="text-red-400" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<p class="my-2 text-center text-sm font-medium text-ellipsis capitalize hover:cursor-pointer sm:my-5">
|
||||||
|
{$memoryLaneTitle(memory)}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if memoryManager.loading}
|
||||||
|
<div class="flex items-center justify-center py-16">
|
||||||
|
<LoadingSpinner size="giant" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</UserPageLayout>
|
||||||
|
{/if}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -82,6 +82,7 @@
|
||||||
let progressBarController: Tween<number> | undefined = $state(undefined);
|
let progressBarController: Tween<number> | undefined = $state(undefined);
|
||||||
let videoPlayer: HTMLVideoElement | undefined = $state();
|
let videoPlayer: HTMLVideoElement | undefined = $state();
|
||||||
const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
|
const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
|
||||||
|
let previousPage = $state(Route.memories());
|
||||||
|
|
||||||
const handleNavigate = async (asset?: { id: string }) => {
|
const handleNavigate = async (asset?: { id: string }) => {
|
||||||
if (assetViewerManager.isViewing) {
|
if (assetViewerManager.isViewing) {
|
||||||
|
|
@ -106,7 +107,7 @@
|
||||||
const handlePreviousAsset = () => handleNavigate(current?.previous?.asset);
|
const handlePreviousAsset = () => handleNavigate(current?.previous?.asset);
|
||||||
const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]);
|
const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]);
|
||||||
const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]);
|
const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]);
|
||||||
const handleEscape = async () => goto(Route.photos());
|
const handleEscape = async () => goto(previousPage);
|
||||||
const handleSelectAll = () =>
|
const handleSelectAll = () =>
|
||||||
assetMultiSelectManager.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []);
|
assetMultiSelectManager.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []);
|
||||||
|
|
||||||
|
|
@ -249,7 +250,7 @@
|
||||||
|
|
||||||
const init = (target: Page | NavigationTarget | null) => {
|
const init = (target: Page | NavigationTarget | null) => {
|
||||||
if (memoryManager.memories.length === 0) {
|
if (memoryManager.memories.length === 0) {
|
||||||
return handlePromiseError(goto(Route.photos()));
|
return handlePromiseError(goto(previousPage));
|
||||||
}
|
}
|
||||||
|
|
||||||
current = loadFromParams(target);
|
current = loadFromParams(target);
|
||||||
|
|
@ -281,8 +282,12 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
afterNavigate(({ from, to }) => {
|
afterNavigate(({ from, to }) => {
|
||||||
|
if (from?.url !== null && !from?.url.searchParams.has(QueryParameter.ID)) {
|
||||||
|
previousPage = from!.url.href;
|
||||||
|
}
|
||||||
|
|
||||||
memoryManager
|
memoryManager
|
||||||
.ready()
|
.refresh()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
let target;
|
let target;
|
||||||
if (to?.params?.assetId) {
|
if (to?.params?.assetId) {
|
||||||
|
|
@ -381,7 +386,7 @@
|
||||||
icon={mdiClose}
|
icon={mdiClose}
|
||||||
aria-label={$t('close')}
|
aria-label={$t('close')}
|
||||||
size="large"
|
size="large"
|
||||||
onclick={() => goto(Route.photos())}
|
onclick={() => goto(previousPage)}
|
||||||
/>
|
/>
|
||||||
<p class="text-lg">
|
<p class="text-lg">
|
||||||
{$memoryLaneTitle(current.memory)}
|
{$memoryLaneTitle(current.memory)}
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
<script>
|
|
||||||
import MemoryViewer from './MemoryViewer.svelte';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<MemoryViewer />
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -39,6 +39,7 @@
|
||||||
import { AssetVisibility } from '@immich/sdk';
|
import { AssetVisibility } from '@immich/sdk';
|
||||||
import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel } from '@immich/ui';
|
import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel } from '@immich/ui';
|
||||||
import { mdiDotsVertical } from '@mdi/js';
|
import { mdiDotsVertical } from '@mdi/js';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
let timelineManager = $state<TimelineManager>() as TimelineManager;
|
let timelineManager = $state<TimelineManager>() as TimelineManager;
|
||||||
|
|
@ -90,6 +91,10 @@
|
||||||
src: getAssetMediaUrl({ id: memory.assets[0].id }),
|
src: getAssetMediaUrl({ id: memory.assets[0].id }),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (memoryManager.filters === undefined || memoryManager.filters.$for !== DateTime.now().toISODate()) {
|
||||||
|
memoryManager.filters = { $for: DateTime.now().toISODate() };
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<UserPageLayout hideNavbar={assetMultiSelectManager.selectionActive} scrollbar={false}>
|
<UserPageLayout hideNavbar={assetMultiSelectManager.selectionActive} scrollbar={false}>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue