mirror of
https://github.com/immich-app/immich.git
synced 2026-06-29 17:54:35 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29797dba9e |
@@ -30,7 +30,7 @@ export const setupMemoryMockApiRoutes = async (
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: { total: activeMemories.length, items: activeMemories, hasNextPage: false },
|
||||
json: activeMemories,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ class RemoteAsset extends BaseAsset {
|
||||
|
||||
bool get isTrashed => deletedAt != null;
|
||||
|
||||
bool get isStacked => stackId != null;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''Asset {
|
||||
|
||||
@@ -13,10 +13,6 @@ class DriftMemoryService {
|
||||
return _repository.getAll(ownerId);
|
||||
}
|
||||
|
||||
Future<List<DriftMemory>> getAllMemories(String ownerId) {
|
||||
return _repository.getAll(ownerId, onlyToday: false);
|
||||
}
|
||||
|
||||
Future<DriftMemory?> get(String memoryId) {
|
||||
return _repository.get(memoryId);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ class DriftMemoryRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
const DriftMemoryRepository(this._db) : super(_db);
|
||||
|
||||
Future<List<DriftMemory>> getAll(String ownerId, {bool onlyToday = true}) async {
|
||||
Future<List<DriftMemory>> getAll(String ownerId) async {
|
||||
final now = DateTime.now();
|
||||
final localUtc = DateTime.utc(now.year, now.month, now.day, 0, 0, 0);
|
||||
|
||||
final query =
|
||||
_db.select(_db.memoryEntity).join([
|
||||
innerJoin(_db.memoryAssetEntity, _db.memoryAssetEntity.memoryId.equalsExp(_db.memoryEntity.id)),
|
||||
@@ -21,17 +24,10 @@ class DriftMemoryRepository extends DriftDatabaseRepository {
|
||||
),
|
||||
])
|
||||
..where(_db.memoryEntity.ownerId.equals(ownerId))
|
||||
..where(_db.memoryEntity.deletedAt.isNull());
|
||||
|
||||
if (onlyToday) {
|
||||
final now = DateTime.now();
|
||||
final localUtc = DateTime.utc(now.year, now.month, now.day, 0, 0, 0);
|
||||
|
||||
query.where(_db.memoryEntity.showAt.isNull() | _db.memoryEntity.showAt.isSmallerOrEqualValue(localUtc));
|
||||
query.where(_db.memoryEntity.hideAt.isNull() | _db.memoryEntity.hideAt.isBiggerOrEqualValue(localUtc));
|
||||
}
|
||||
|
||||
query.orderBy([OrderingTerm.desc(_db.memoryEntity.memoryAt), OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]);
|
||||
..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)]);
|
||||
|
||||
final rows = await query.get();
|
||||
if (rows.isEmpty) {
|
||||
|
||||
@@ -112,7 +112,7 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) {
|
||||
}
|
||||
|
||||
if (index == kPhotoTabIndex) {
|
||||
ref.invalidate(driftMemoryLaneProvider);
|
||||
ref.invalidate(driftMemoryFutureProvider);
|
||||
}
|
||||
|
||||
if (router.activeIndex != kSearchTabIndex && index == kSearchTabIndex) {
|
||||
|
||||
@@ -3,25 +3,29 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class FavoriteAction extends AssetAction<RemoteAsset> {
|
||||
final bool shouldFavorite;
|
||||
final bool favorite;
|
||||
|
||||
FavoriteAction({required super.assets}) : shouldFavorite = assets.any((asset) => !asset.isFavorite);
|
||||
FavoriteAction({required super.assets}) : favorite = assets.any((asset) => !asset.isFavorite);
|
||||
|
||||
@override
|
||||
IconData get icon => shouldFavorite ? Icons.favorite_border_rounded : Icons.favorite_rounded;
|
||||
IconData get icon => favorite ? Icons.favorite_border_rounded : Icons.favorite_rounded;
|
||||
|
||||
@override
|
||||
String label(ActionScope scope) => shouldFavorite ? scope.context.t.favorite : scope.context.t.unfavorite;
|
||||
String label(ActionScope scope) => favorite ? scope.context.t.favorite : scope.context.t.unfavorite;
|
||||
|
||||
@override
|
||||
Iterable<RemoteAsset> filter(ActionScope scope) => assets
|
||||
.where(
|
||||
(asset) => asset is RemoteAsset && asset.ownerId == scope.authUser.id && asset.isFavorite == !shouldFavorite,
|
||||
)
|
||||
.cast<RemoteAsset>();
|
||||
Iterable<RemoteAsset> filter(ActionScope scope) {
|
||||
final owned = AssetFilter(assets).owned(scope.authUser.id);
|
||||
if (favorite) {
|
||||
return owned.notFavorites();
|
||||
} else {
|
||||
return owned.favorites();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
|
||||
@@ -31,8 +35,8 @@ class FavoriteAction extends AssetAction<RemoteAsset> {
|
||||
final ActionScope(:ref) = scope;
|
||||
final assets = filter(scope).map((asset) => asset.id).toList(growable: false);
|
||||
|
||||
await ref.read(assetServiceProvider).updateFavorite(assets, shouldFavorite);
|
||||
final message = shouldFavorite
|
||||
await ref.read(assetServiceProvider).updateFavorite(assets, favorite);
|
||||
final message = favorite
|
||||
? StaticTranslations.instance.favorite_action_prompt(count: assets.length)
|
||||
: StaticTranslations.instance.unfavorite_action_prompt(count: assets.length);
|
||||
snackbar.success(message);
|
||||
|
||||
@@ -11,7 +11,7 @@ class MainTimelinePage extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final hasMemories = ref.watch(driftMemoryLaneProvider.select((state) => state.value?.isNotEmpty ?? false));
|
||||
final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false));
|
||||
return Timeline(
|
||||
topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()),
|
||||
topSliverWidgetHeight: hasMemories ? 200 : 0,
|
||||
|
||||
@@ -7,11 +7,9 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/local_album_thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.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';
|
||||
@@ -136,12 +134,7 @@ class _CollectionCards extends StatelessWidget {
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_PeopleCollectionCard(),
|
||||
_PlacesCollectionCard(),
|
||||
_LocalAlbumsCollectionCard(),
|
||||
_MemoriesCollectionCard(),
|
||||
],
|
||||
children: [_PeopleCollectionCard(), _PlacesCollectionCard(), _LocalAlbumsCollectionCard()],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -335,76 +328,6 @@ 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(
|
||||
'memories'.t(context: context),
|
||||
style: context.textTheme.titleSmall?.copyWith(
|
||||
color: context.colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
final sharedWithPartnerProvider = StreamProvider.autoDispose<Iterable<Partner>>((ref) {
|
||||
final currentUser = ref.watch(currentUserProvider);
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
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/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('memories'.tr()),
|
||||
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]);
|
||||
}
|
||||
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()),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ class DriftMemoryLane extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final memoryLaneProvider = ref.watch(driftMemoryLaneProvider);
|
||||
final memoryLaneProvider = ref.watch(driftMemoryFutureProvider);
|
||||
final memories = memoryLaneProvider.value ?? const [];
|
||||
if (memories.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
|
||||
@@ -116,7 +116,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
_safeRun(backgroundManager.syncLocal(full: CurrentPlatform.isAndroid ? true : false), "syncLocal"),
|
||||
_safeRun(backgroundManager.syncRemote().then((success) => syncSuccess = success), "syncRemote"),
|
||||
]);
|
||||
_ref.invalidate(driftAllMemoriesProvider);
|
||||
_ref.invalidate(driftMemoryFutureProvider);
|
||||
if (syncSuccess) {
|
||||
await Future.wait([
|
||||
_safeRun(backgroundManager.hashAssets(), "hashAssets").then((_) {
|
||||
|
||||
@@ -15,7 +15,7 @@ final driftMemoryServiceProvider = Provider<DriftMemoryService>(
|
||||
(ref) => DriftMemoryService(ref.watch(driftMemoryRepositoryProvider)),
|
||||
);
|
||||
|
||||
final driftMemoryLaneProvider = FutureProvider.autoDispose<List<DriftMemory>>((ref) {
|
||||
final driftMemoryFutureProvider = FutureProvider.autoDispose<List<DriftMemory>>((ref) {
|
||||
final (userId, enabled) = ref.watch(currentUserProvider.select((user) => (user?.id, user?.memoryEnabled ?? true)));
|
||||
if (userId == null || !enabled) {
|
||||
return const [];
|
||||
@@ -29,13 +29,3 @@ final driftMemoryLaneProvider = FutureProvider.autoDispose<List<DriftMemory>>((r
|
||||
final service = ref.watch(driftMemoryServiceProvider);
|
||||
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,7 +52,6 @@ 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';
|
||||
@@ -192,7 +191,6 @@ 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: '/'),
|
||||
|
||||
@@ -754,22 +754,6 @@ class DriftMapRouteArgs {
|
||||
int get hashCode => key.hashCode ^ initialLocation.hashCode;
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [DriftMemoryListPage]
|
||||
class DriftMemoryListRoute extends PageRouteInfo<void> {
|
||||
const DriftMemoryListRoute({List<PageRouteInfo>? children})
|
||||
: super(DriftMemoryListRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'DriftMemoryListRoute';
|
||||
|
||||
static PageInfo page = PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const DriftMemoryListPage();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [DriftMemoryPage]
|
||||
class DriftMemoryRoute extends PageRouteInfo<DriftMemoryRouteArgs> {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
|
||||
extension type const AssetFilter<T extends BaseAsset>(Iterable<T> assets) implements Iterable<T> {
|
||||
AssetFilter<T> where(bool Function(T asset) test) => AssetFilter(assets.where(test));
|
||||
AssetFilter<T> whereNot(bool Function(T asset) test) => AssetFilter(assets.where((asset) => !test(asset)));
|
||||
|
||||
AssetFilter<T> type(AssetType type) => where((asset) => asset.type == type);
|
||||
AssetFilter<T> favorites() => where(_isFavorite);
|
||||
AssetFilter<T> notFavorites() => whereNot(_isFavorite);
|
||||
|
||||
AssetFilter<RemoteAsset> remote() => AssetFilter(assets.whereType<RemoteAsset>());
|
||||
AssetFilter<RemoteAsset> owned(String ownerId) => remote().where((asset) => asset.ownerId == ownerId);
|
||||
AssetFilter<RemoteAsset> visibility(AssetVisibility visibility) => remote().where(_hasVisibility(visibility));
|
||||
AssetFilter<RemoteAsset> notVisibility(AssetVisibility visibility) => remote().whereNot(_hasVisibility(visibility));
|
||||
AssetFilter<RemoteAsset> archived() => visibility(.archive);
|
||||
AssetFilter<RemoteAsset> notArchived() => notVisibility(.archive);
|
||||
AssetFilter<RemoteAsset> stacked() => remote().where(_isStacked);
|
||||
AssetFilter<RemoteAsset> notStacked() => remote().whereNot(_isStacked);
|
||||
|
||||
AssetFilter<LocalAsset> local() => AssetFilter(assets.whereType<LocalAsset>());
|
||||
AssetFilter<LocalAsset> backedUp() => local().where(_isBackedUp);
|
||||
}
|
||||
|
||||
bool _isFavorite(BaseAsset asset) => asset.isFavorite;
|
||||
bool _isStacked(RemoteAsset asset) => asset.isStacked;
|
||||
bool _isBackedUp(LocalAsset asset) => asset.remoteAssetId != null;
|
||||
bool Function(RemoteAsset asset) _hasVisibility(AssetVisibility visibility) =>
|
||||
(asset) => asset.visibility == visibility;
|
||||
Generated
-1
@@ -482,7 +482,6 @@ Class | Method | HTTP request | Description
|
||||
- [MemoryCreateDto](doc//MemoryCreateDto.md)
|
||||
- [MemoryResponseDto](doc//MemoryResponseDto.md)
|
||||
- [MemorySearchOrder](doc//MemorySearchOrder.md)
|
||||
- [MemorySearchResponseDto](doc//MemorySearchResponseDto.md)
|
||||
- [MemoryStatisticsResponseDto](doc//MemoryStatisticsResponseDto.md)
|
||||
- [MemoryType](doc//MemoryType.md)
|
||||
- [MemoryUpdateDto](doc//MemoryUpdateDto.md)
|
||||
|
||||
Generated
-1
@@ -203,7 +203,6 @@ part 'model/memories_update.dart';
|
||||
part 'model/memory_create_dto.dart';
|
||||
part 'model/memory_response_dto.dart';
|
||||
part 'model/memory_search_order.dart';
|
||||
part 'model/memory_search_response_dto.dart';
|
||||
part 'model/memory_statistics_response_dto.dart';
|
||||
part 'model/memory_type.dart';
|
||||
part 'model/memory_update_dto.dart';
|
||||
|
||||
Generated
+11
-26
@@ -265,14 +265,11 @@ class MemoriesApi {
|
||||
///
|
||||
/// * [MemorySearchOrder] order:
|
||||
///
|
||||
/// * [int] page:
|
||||
/// Page number
|
||||
///
|
||||
/// * [int] size:
|
||||
/// Number of memories to return
|
||||
///
|
||||
/// * [MemoryType] type:
|
||||
Future<Response> memoriesStatisticsWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, Future<void>? abortTrigger, }) async {
|
||||
Future<Response> memoriesStatisticsWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/memories/statistics';
|
||||
|
||||
@@ -295,9 +292,6 @@ class MemoriesApi {
|
||||
if (order != null) {
|
||||
queryParams.addAll(_queryParams('', 'order', order));
|
||||
}
|
||||
if (page != null) {
|
||||
queryParams.addAll(_queryParams('', 'page', page));
|
||||
}
|
||||
if (size != null) {
|
||||
queryParams.addAll(_queryParams('', 'size', size));
|
||||
}
|
||||
@@ -337,15 +331,12 @@ class MemoriesApi {
|
||||
///
|
||||
/// * [MemorySearchOrder] order:
|
||||
///
|
||||
/// * [int] page:
|
||||
/// Page number
|
||||
///
|
||||
/// * [int] size:
|
||||
/// Number of memories to return
|
||||
///
|
||||
/// * [MemoryType] type:
|
||||
Future<MemoryStatisticsResponseDto?> memoriesStatistics({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, Future<void>? abortTrigger, }) async {
|
||||
final response = await memoriesStatisticsWithHttpInfo(for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, page: page, size: size, type: type, abortTrigger: abortTrigger,);
|
||||
Future<MemoryStatisticsResponseDto?> memoriesStatistics({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, Future<void>? abortTrigger, }) async {
|
||||
final response = await memoriesStatisticsWithHttpInfo(for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, size: size, type: type, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
@@ -443,14 +434,11 @@ class MemoriesApi {
|
||||
///
|
||||
/// * [MemorySearchOrder] order:
|
||||
///
|
||||
/// * [int] page:
|
||||
/// Page number
|
||||
///
|
||||
/// * [int] size:
|
||||
/// Number of memories to return
|
||||
///
|
||||
/// * [MemoryType] type:
|
||||
Future<Response> searchMemoriesWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, Future<void>? abortTrigger, }) async {
|
||||
Future<Response> searchMemoriesWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/memories';
|
||||
|
||||
@@ -473,9 +461,6 @@ class MemoriesApi {
|
||||
if (order != null) {
|
||||
queryParams.addAll(_queryParams('', 'order', order));
|
||||
}
|
||||
if (page != null) {
|
||||
queryParams.addAll(_queryParams('', 'page', page));
|
||||
}
|
||||
if (size != null) {
|
||||
queryParams.addAll(_queryParams('', 'size', size));
|
||||
}
|
||||
@@ -515,15 +500,12 @@ class MemoriesApi {
|
||||
///
|
||||
/// * [MemorySearchOrder] order:
|
||||
///
|
||||
/// * [int] page:
|
||||
/// Page number
|
||||
///
|
||||
/// * [int] size:
|
||||
/// Number of memories to return
|
||||
///
|
||||
/// * [MemoryType] type:
|
||||
Future<MemorySearchResponseDto?> searchMemories({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, Future<void>? abortTrigger, }) async {
|
||||
final response = await searchMemoriesWithHttpInfo(for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, page: page, size: size, type: type, abortTrigger: abortTrigger,);
|
||||
Future<List<MemoryResponseDto>?> searchMemories({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, Future<void>? abortTrigger, }) async {
|
||||
final response = await searchMemoriesWithHttpInfo(for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, size: size, type: type, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
@@ -531,8 +513,11 @@ class MemoriesApi {
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MemorySearchResponseDto',) as MemorySearchResponseDto;
|
||||
|
||||
final responseBody = await _decodeBodyBytes(response);
|
||||
return (await apiClient.deserializeAsync(responseBody, 'List<MemoryResponseDto>') as List)
|
||||
.cast<MemoryResponseDto>()
|
||||
.toList(growable: false);
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Generated
-2
@@ -451,8 +451,6 @@ class ApiClient {
|
||||
return MemoryResponseDto.fromJson(value);
|
||||
case 'MemorySearchOrder':
|
||||
return MemorySearchOrderTypeTransformer().decode(value);
|
||||
case 'MemorySearchResponseDto':
|
||||
return MemorySearchResponseDto.fromJson(value);
|
||||
case 'MemoryStatisticsResponseDto':
|
||||
return MemoryStatisticsResponseDto.fromJson(value);
|
||||
case 'MemoryType':
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class MemorySearchResponseDto {
|
||||
/// Returns a new [MemorySearchResponseDto] instance.
|
||||
MemorySearchResponseDto({
|
||||
required this.hasNextPage,
|
||||
this.items = const [],
|
||||
required this.total,
|
||||
});
|
||||
|
||||
/// Whether there are more pages
|
||||
bool hasNextPage;
|
||||
|
||||
List<MemoryResponseDto> items;
|
||||
|
||||
/// Total number of matching memories
|
||||
///
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 9007199254740991
|
||||
int total;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is MemorySearchResponseDto &&
|
||||
other.hasNextPage == hasNextPage &&
|
||||
_deepEquality.equals(other.items, items) &&
|
||||
other.total == total;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(hasNextPage.hashCode) +
|
||||
(items.hashCode) +
|
||||
(total.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'MemorySearchResponseDto[hasNextPage=$hasNextPage, items=$items, total=$total]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'hasNextPage'] = this.hasNextPage;
|
||||
json[r'items'] = this.items;
|
||||
json[r'total'] = this.total;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [MemorySearchResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static MemorySearchResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "MemorySearchResponseDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return MemorySearchResponseDto(
|
||||
hasNextPage: mapValueOfType<bool>(json, r'hasNextPage')!,
|
||||
items: MemoryResponseDto.listFromJson(json[r'items']),
|
||||
total: mapValueOfType<int>(json, r'total')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<MemorySearchResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <MemorySearchResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = MemorySearchResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, MemorySearchResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, MemorySearchResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = MemorySearchResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of MemorySearchResponseDto-objects as value to a dart map
|
||||
static Map<String, List<MemorySearchResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<MemorySearchResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = MemorySearchResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'hasNextPage',
|
||||
'items',
|
||||
'total',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,11 +44,11 @@ void main() {
|
||||
when(() => userService.watchMyUser()).thenAnswer((_) => const Stream.empty());
|
||||
});
|
||||
|
||||
group('driftMemoryLaneProvider', () {
|
||||
group('driftMemoryFutureProvider', () {
|
||||
test('re-queries after local midnight', () {
|
||||
fakeAsync((async) {
|
||||
final container = makeContainer();
|
||||
container.listen(driftMemoryLaneProvider, (_, __) {});
|
||||
container.listen(driftMemoryFutureProvider, (_, __) {});
|
||||
async.flushMicrotasks();
|
||||
|
||||
verify(() => memoryService.getMemoryLane('user-1')).called(1);
|
||||
@@ -66,7 +66,7 @@ void main() {
|
||||
test('cancels the midnight timer when disposed', () {
|
||||
fakeAsync((async) {
|
||||
final container = makeContainer();
|
||||
final subscription = container.listen(driftMemoryLaneProvider, (_, __) {});
|
||||
final subscription = container.listen(driftMemoryFutureProvider, (_, __) {});
|
||||
async.flushMicrotasks();
|
||||
verify(() => memoryService.getMemoryLane('user-1')).called(1);
|
||||
|
||||
@@ -83,7 +83,7 @@ void main() {
|
||||
|
||||
fakeAsync((async) {
|
||||
final container = makeContainer();
|
||||
container.listen(driftMemoryLaneProvider, (_, __) {});
|
||||
container.listen(driftMemoryFutureProvider, (_, __) {});
|
||||
async.flushMicrotasks();
|
||||
|
||||
async.elapse(const Duration(hours: 25));
|
||||
|
||||
@@ -5,7 +5,14 @@ import '../../utils.dart';
|
||||
class RemoteAssetFactory {
|
||||
const RemoteAssetFactory();
|
||||
|
||||
static RemoteAsset create({String? id, String? name, String? ownerId, bool isFavorite = false}) {
|
||||
static RemoteAsset create({
|
||||
String? id,
|
||||
String? name,
|
||||
String? ownerId,
|
||||
bool isFavorite = false,
|
||||
AssetVisibility visibility = AssetVisibility.timeline,
|
||||
String? stackId,
|
||||
}) {
|
||||
id = TestUtils.uuid(id);
|
||||
|
||||
return RemoteAsset(
|
||||
@@ -17,6 +24,8 @@ class RemoteAssetFactory {
|
||||
createdAt: TestUtils.yesterday(),
|
||||
updatedAt: TestUtils.now(),
|
||||
isFavorite: isFavorite,
|
||||
visibility: visibility,
|
||||
stackId: stackId,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||
|
||||
import '../factories/local_asset_factory.dart';
|
||||
import '../factories/remote_asset_factory.dart';
|
||||
|
||||
void main() {
|
||||
group('AssetFilter', () {
|
||||
group('type promotion', () {
|
||||
test('a bare filter retains every BaseAsset', () {
|
||||
final remoteAsset = RemoteAssetFactory.create();
|
||||
final localAsset = LocalAssetFactory.create();
|
||||
|
||||
final AssetFilter<BaseAsset> filter = AssetFilter(<BaseAsset>[remoteAsset, localAsset]);
|
||||
|
||||
expect(filter.toList(), [remoteAsset, localAsset]);
|
||||
});
|
||||
|
||||
test('remote keeps only remote assets', () {
|
||||
final remoteAsset = RemoteAssetFactory.create();
|
||||
final localAsset = LocalAssetFactory.create();
|
||||
|
||||
final AssetFilter<RemoteAsset> remoteOnly = AssetFilter(<BaseAsset>[remoteAsset, localAsset]).remote();
|
||||
|
||||
expect(remoteOnly.toList(), [remoteAsset]);
|
||||
});
|
||||
|
||||
test('local keeps only local assets', () {
|
||||
final remoteAsset = RemoteAssetFactory.create();
|
||||
final localAsset = LocalAssetFactory.create();
|
||||
|
||||
final AssetFilter<LocalAsset> localOnly = AssetFilter(<BaseAsset>[remoteAsset, localAsset]).local();
|
||||
|
||||
expect(localOnly.toList(), [localAsset]);
|
||||
});
|
||||
|
||||
test('owned promotes to RemoteAsset and drops local assets', () {
|
||||
final remoteAsset = RemoteAssetFactory.create();
|
||||
final localAsset = LocalAssetFactory.create();
|
||||
|
||||
final AssetFilter<RemoteAsset> remoteOnly = AssetFilter(<BaseAsset>[
|
||||
remoteAsset,
|
||||
localAsset,
|
||||
]).owned(remoteAsset.ownerId);
|
||||
|
||||
expect(remoteOnly.toList(), [remoteAsset]);
|
||||
});
|
||||
|
||||
test('backedUp promotes to LocalAsset and drops remote assets', () {
|
||||
final syncedPhoto = LocalAssetFactory.create().copyWith(remoteId: 'remote');
|
||||
final offlinePhoto = LocalAssetFactory.create();
|
||||
final remotePhoto = RemoteAssetFactory.create();
|
||||
|
||||
final AssetFilter<LocalAsset> syncedPhotos = AssetFilter(<BaseAsset>[
|
||||
syncedPhoto,
|
||||
offlinePhoto,
|
||||
remotePhoto,
|
||||
]).backedUp();
|
||||
|
||||
expect(syncedPhotos.toList(), [syncedPhoto]);
|
||||
});
|
||||
});
|
||||
|
||||
group('named filters', () {
|
||||
test('owned keeps only assets of the given owner', () {
|
||||
final asset1 = RemoteAssetFactory.create();
|
||||
final asset2 = RemoteAssetFactory.create();
|
||||
|
||||
final alexPhotos = AssetFilter([asset1, asset2]).owned(asset1.ownerId);
|
||||
|
||||
expect(alexPhotos.toList(), [asset1]);
|
||||
});
|
||||
|
||||
test('favorites keeps only favorite assets', () {
|
||||
final asset1 = RemoteAssetFactory.create(isFavorite: true);
|
||||
final asset2 = RemoteAssetFactory.create(ownerId: asset1.ownerId);
|
||||
|
||||
final favorites = AssetFilter([asset1, asset2]).favorites();
|
||||
|
||||
expect(favorites.toList(), [asset1]);
|
||||
});
|
||||
|
||||
test('type keeps only assets of the given type', () {
|
||||
final image = RemoteAssetFactory.create();
|
||||
final video = RemoteAssetFactory.create(ownerId: image.ownerId).copyWith(type: .video);
|
||||
|
||||
final videos = AssetFilter([image, video]).type(.video);
|
||||
|
||||
expect(videos.toList(), [video]);
|
||||
});
|
||||
|
||||
test('visibility keeps only assets with the given visibility', () {
|
||||
final locked = RemoteAssetFactory.create(visibility: AssetVisibility.locked);
|
||||
final onTimeline = RemoteAssetFactory.create(ownerId: locked.ownerId);
|
||||
|
||||
final lockedPhotos = AssetFilter([locked, onTimeline]).visibility(.locked);
|
||||
|
||||
expect(lockedPhotos.toList(), [locked]);
|
||||
});
|
||||
|
||||
test('archived keeps only archived assets', () {
|
||||
final archived = RemoteAssetFactory.create(visibility: AssetVisibility.archive);
|
||||
final onTimeline = RemoteAssetFactory.create(ownerId: archived.ownerId);
|
||||
|
||||
final archivedPhotos = AssetFilter([archived, onTimeline]).archived();
|
||||
|
||||
expect(archivedPhotos.toList(), [archived]);
|
||||
});
|
||||
|
||||
test('stacked keeps only assets belonging to a stack', () {
|
||||
final stacked = RemoteAssetFactory.create(stackId: 'stack');
|
||||
final loose = RemoteAssetFactory.create(ownerId: stacked.ownerId);
|
||||
|
||||
final stackedPhotos = AssetFilter([stacked, loose]).stacked();
|
||||
|
||||
expect(stackedPhotos.toList(), [stacked]);
|
||||
});
|
||||
});
|
||||
|
||||
group('inversion', () {
|
||||
test('notArchived keeps every non-archived visibility', () {
|
||||
final archived = RemoteAssetFactory.create(visibility: AssetVisibility.archive);
|
||||
final onTimeline = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.timeline);
|
||||
final hidden = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.hidden);
|
||||
final locked = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.locked);
|
||||
|
||||
final visiblePhotos = AssetFilter([archived, onTimeline, hidden, locked]).notArchived();
|
||||
|
||||
expect(visiblePhotos.toSet(), {onTimeline, hidden, locked});
|
||||
});
|
||||
|
||||
test('notVisibility keeps every asset not at the target visibility', () {
|
||||
final archived = RemoteAssetFactory.create(visibility: AssetVisibility.archive);
|
||||
final onTimeline = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.timeline);
|
||||
final locked = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.locked);
|
||||
|
||||
final toArchive = AssetFilter([archived, onTimeline, locked]).notVisibility(.archive);
|
||||
|
||||
expect(toArchive.toSet(), {onTimeline, locked});
|
||||
});
|
||||
|
||||
test('notStacked keeps only assets without a stack', () {
|
||||
final stacked = RemoteAssetFactory.create(stackId: 'stack');
|
||||
final loose = RemoteAssetFactory.create(ownerId: stacked.ownerId);
|
||||
|
||||
final loosePhotos = AssetFilter([stacked, loose]).notStacked();
|
||||
|
||||
expect(loosePhotos.toList(), [loose]);
|
||||
});
|
||||
|
||||
test('whereNot inverts an arbitrary predicate', () {
|
||||
final favorite = RemoteAssetFactory.create(isFavorite: true);
|
||||
final regular = RemoteAssetFactory.create(ownerId: favorite.ownerId);
|
||||
|
||||
final nonFavorites = AssetFilter([favorite, regular]).whereNot((asset) => asset.isFavorite);
|
||||
|
||||
expect(nonFavorites.toList(), [regular]);
|
||||
});
|
||||
|
||||
test('notFavorites keeps only non-favorite assets', () {
|
||||
final favorite = RemoteAssetFactory.create(isFavorite: true);
|
||||
final regular = RemoteAssetFactory.create(ownerId: favorite.ownerId);
|
||||
|
||||
final nonFavorites = AssetFilter([favorite, regular]).notFavorites();
|
||||
|
||||
expect(nonFavorites.toList(), [regular]);
|
||||
});
|
||||
});
|
||||
|
||||
group('chaining', () {
|
||||
test('combines predicates across owner, visibility and stack', () {
|
||||
final asset = RemoteAssetFactory.create();
|
||||
final wrongOwner = RemoteAssetFactory.create();
|
||||
final archived = RemoteAssetFactory.create(ownerId: asset.ownerId, visibility: AssetVisibility.archive);
|
||||
final stacked = RemoteAssetFactory.create(ownerId: asset.ownerId, stackId: 'stack-1');
|
||||
final localPhoto = LocalAssetFactory.create();
|
||||
|
||||
final result = AssetFilter(<BaseAsset>[
|
||||
asset,
|
||||
wrongOwner,
|
||||
archived,
|
||||
stacked,
|
||||
localPhoto,
|
||||
]).owned(asset.ownerId).notArchived().notStacked();
|
||||
|
||||
expect(result.toList(), [asset]);
|
||||
});
|
||||
|
||||
test('a base filter after a promotion retains the promoted type', () {
|
||||
final favorite = RemoteAssetFactory.create(isFavorite: true);
|
||||
final regular = RemoteAssetFactory.create(ownerId: favorite.ownerId);
|
||||
|
||||
final AssetFilter<RemoteAsset> result = AssetFilter([favorite, regular]).owned(favorite.ownerId).favorites();
|
||||
|
||||
expect(result.toList(), [favorite]);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -7171,17 +7171,6 @@
|
||||
"$ref": "#/components/schemas/MemorySearchOrder"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Page number",
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
"required": false,
|
||||
@@ -7207,7 +7196,10 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MemorySearchResponseDto"
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MemoryResponseDto"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -7348,17 +7340,6 @@
|
||||
"$ref": "#/components/schemas/MemorySearchOrder"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Page number",
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
"required": false,
|
||||
@@ -19974,32 +19955,6 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"MemorySearchResponseDto": {
|
||||
"properties": {
|
||||
"hasNextPage": {
|
||||
"description": "Whether there are more pages",
|
||||
"type": "boolean"
|
||||
},
|
||||
"items": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MemoryResponseDto"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"total": {
|
||||
"description": "Total number of matching memories",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"hasNextPage",
|
||||
"items",
|
||||
"total"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MemoryStatisticsResponseDto": {
|
||||
"properties": {
|
||||
"total": {
|
||||
|
||||
@@ -1350,13 +1350,6 @@ export type MemoryResponseDto = {
|
||||
/** Last update date */
|
||||
updatedAt: string;
|
||||
};
|
||||
export type MemorySearchResponseDto = {
|
||||
/** Whether there are more pages */
|
||||
hasNextPage: boolean;
|
||||
items: MemoryResponseDto[];
|
||||
/** Total number of matching memories */
|
||||
total: number;
|
||||
};
|
||||
export type MemoryCreateDto = {
|
||||
/** Asset IDs to associate with memory */
|
||||
assetIds?: string[];
|
||||
@@ -4977,24 +4970,22 @@ export function reverseGeocode({ lat, lon }: {
|
||||
/**
|
||||
* Retrieve memories
|
||||
*/
|
||||
export function searchMemories({ $for, isSaved, isTrashed, order, page, size, $type }: {
|
||||
export function searchMemories({ $for, isSaved, isTrashed, order, size, $type }: {
|
||||
$for?: string;
|
||||
isSaved?: boolean;
|
||||
isTrashed?: boolean;
|
||||
order?: MemorySearchOrder;
|
||||
page?: number;
|
||||
size?: number;
|
||||
$type?: MemoryType;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: MemorySearchResponseDto;
|
||||
data: MemoryResponseDto[];
|
||||
}>(`/memories${QS.query(QS.explode({
|
||||
"for": $for,
|
||||
isSaved,
|
||||
isTrashed,
|
||||
order,
|
||||
page,
|
||||
size,
|
||||
"type": $type
|
||||
}))}`, {
|
||||
@@ -5019,12 +5010,11 @@ export function createMemory({ memoryCreateDto }: {
|
||||
/**
|
||||
* Retrieve memories statistics
|
||||
*/
|
||||
export function memoriesStatistics({ $for, isSaved, isTrashed, order, page, size, $type }: {
|
||||
export function memoriesStatistics({ $for, isSaved, isTrashed, order, size, $type }: {
|
||||
$for?: string;
|
||||
isSaved?: boolean;
|
||||
isTrashed?: boolean;
|
||||
order?: MemorySearchOrder;
|
||||
page?: number;
|
||||
size?: number;
|
||||
$type?: MemoryType;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
@@ -5036,7 +5026,6 @@ export function memoriesStatistics({ $for, isSaved, isTrashed, order, page, size
|
||||
isSaved,
|
||||
isTrashed,
|
||||
order,
|
||||
page,
|
||||
size,
|
||||
"type": $type
|
||||
}))}`, {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
MemoryCreateDto,
|
||||
MemoryResponseDto,
|
||||
MemorySearchDto,
|
||||
MemorySearchResponseDto,
|
||||
MemoryStatisticsResponseDto,
|
||||
MemoryUpdateDto,
|
||||
} from 'src/dtos/memory.dto';
|
||||
@@ -29,7 +28,7 @@ export class MemoryController {
|
||||
'Retrieve a list of memories. Memories are sorted descending by creation date by default, although they can also be sorted in ascending order, or randomly.',
|
||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
||||
})
|
||||
searchMemories(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise<MemorySearchResponseDto> {
|
||||
searchMemories(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise<MemoryResponseDto[]> {
|
||||
return this.service.search(auth, dto);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ const MemorySearchSchema = z
|
||||
isTrashed: stringToBool.optional().describe('Include trashed memories'),
|
||||
isSaved: stringToBool.optional().describe('Filter by saved status'),
|
||||
size: z.coerce.number().int().min(1).optional().describe('Number of memories to return'),
|
||||
page: z.coerce.number().int().min(1).optional().describe('Page number'),
|
||||
order: AssetOrderWithRandomSchema.optional(),
|
||||
})
|
||||
.meta({ id: 'MemorySearchDto' });
|
||||
@@ -76,20 +75,11 @@ const MemoryResponseSchema = z
|
||||
})
|
||||
.meta({ id: 'MemoryResponseDto' });
|
||||
|
||||
const MemorySearchResponseSchema = z
|
||||
.object({
|
||||
total: z.int().min(0).describe('Total number of matching memories'),
|
||||
items: z.array(MemoryResponseSchema),
|
||||
hasNextPage: z.boolean().describe('Whether there are more pages'),
|
||||
})
|
||||
.meta({ id: 'MemorySearchResponseDto' });
|
||||
|
||||
export class MemorySearchDto extends createZodDto(MemorySearchSchema) {}
|
||||
export class MemoryUpdateDto extends createZodDto(MemoryUpdateSchema) {}
|
||||
export class MemoryCreateDto extends createZodDto(MemoryCreateSchema) {}
|
||||
export class MemoryStatisticsResponseDto extends createZodDto(MemoryStatisticsResponseSchema) {}
|
||||
export class MemoryResponseDto extends createZodDto(MemoryResponseSchema) {}
|
||||
export class MemorySearchResponseDto extends createZodDto(MemorySearchResponseSchema) {}
|
||||
|
||||
export const mapMemory = (entity: Memory, auth: AuthDto): MemoryResponseDto => {
|
||||
return {
|
||||
|
||||
@@ -9,7 +9,6 @@ import { AssetOrderWithRandom, AssetVisibility } from 'src/enum';
|
||||
import { DB } from 'src/schema';
|
||||
import { MemoryTable } from 'src/schema/tables/memory.table';
|
||||
import { IBulkAsset } from 'src/types';
|
||||
import { paginationHelper } from 'src/utils/pagination';
|
||||
|
||||
@Injectable()
|
||||
export class MemoryRepository implements IBulkAsset {
|
||||
@@ -58,8 +57,8 @@ export class MemoryRepository implements IBulkAsset {
|
||||
{ params: [DummyValue.UUID, {}] },
|
||||
{ name: 'date filter', params: [DummyValue.UUID, { for: DummyValue.DATE }] },
|
||||
)
|
||||
async search(ownerId: string, dto: MemorySearchDto) {
|
||||
const items = await this.searchBuilder(ownerId, dto)
|
||||
search(ownerId: string, dto: MemorySearchDto) {
|
||||
return this.searchBuilder(ownerId, dto)
|
||||
.select((eb) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
@@ -90,11 +89,8 @@ export class MemoryRepository implements IBulkAsset {
|
||||
? qb.orderBy(sql`RANDOM()`)
|
||||
: qb.orderBy('memoryAt', (dto.order?.toLowerCase() || 'desc') as OrderByDirection),
|
||||
)
|
||||
.$if(dto.size !== undefined, (qb) => qb.limit(dto.size! + 1))
|
||||
.$if(dto.page !== undefined && dto.size !== undefined, (qb) => qb.offset((dto.page! - 1) * dto.size!))
|
||||
.$if(dto.size !== undefined, (qb) => qb.limit(dto.size!))
|
||||
.execute();
|
||||
|
||||
return paginationHelper(items, dto.size ?? items.length);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
|
||||
@@ -34,28 +34,21 @@ describe(MemoryService.name, () => {
|
||||
const asset = AssetFactory.create();
|
||||
const memory1 = MemoryFactory.from({ ownerId: userId }).asset(asset).build();
|
||||
const memory2 = MemoryFactory.create({ ownerId: userId });
|
||||
mocks.memory.search.mockResolvedValue({
|
||||
items: [getForMemory(memory1), getForMemory(memory2)],
|
||||
hasNextPage: false,
|
||||
});
|
||||
mocks.memory.statistics.mockResolvedValue({ total: 2 });
|
||||
mocks.memory.search.mockResolvedValue([getForMemory(memory1), getForMemory(memory2)]);
|
||||
|
||||
await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toMatchObject({
|
||||
items: expect.arrayContaining([
|
||||
await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: memory1.id,
|
||||
assets: expect.arrayContaining([expect.objectContaining({ id: asset.id })]),
|
||||
}),
|
||||
]),
|
||||
hasNextPage: false,
|
||||
total: 2,
|
||||
});
|
||||
);
|
||||
});
|
||||
|
||||
it('should map empty result', async () => {
|
||||
mocks.memory.search.mockResolvedValue({ items: [], hasNextPage: false });
|
||||
mocks.memory.statistics.mockResolvedValue({ total: 0 });
|
||||
await expect(sut.search(factory.auth(), {})).resolves.toMatchObject({ items: [], hasNextPage: false, total: 0 });
|
||||
mocks.memory.search.mockResolvedValue([]);
|
||||
await expect(sut.search(factory.auth(), {})).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -71,14 +71,10 @@ export class MemoryService extends BaseService {
|
||||
}
|
||||
|
||||
async search(auth: AuthDto, dto: MemorySearchDto) {
|
||||
const { items, hasNextPage } = await this.memoryRepository.search(auth.user.id, dto);
|
||||
const { total } = await this.memoryRepository.statistics(auth.user.id, dto);
|
||||
|
||||
return {
|
||||
total,
|
||||
items: items.map((memory: Memory) => mapMemory(memory, auth)),
|
||||
hasNextPage,
|
||||
};
|
||||
const memories = await this.memoryRepository.search(auth.user.id, dto);
|
||||
return memories
|
||||
.filter((memory: Memory) => memory.assets && memory.assets.length > 0)
|
||||
.map((memory: Memory) => mapMemory(memory, auth));
|
||||
}
|
||||
|
||||
statistics(auth: AuthDto, dto: MemorySearchDto) {
|
||||
|
||||
@@ -133,8 +133,8 @@ describe(MemoryService.name, () => {
|
||||
await sut.onMemoriesCreate();
|
||||
|
||||
const memories = await memoryRepo.search(user.id, {});
|
||||
expect(memories.items.length).toBe(1);
|
||||
expect(memories.items[0]).toEqual(
|
||||
expect(memories.length).toBe(1);
|
||||
expect(memories[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
createdAt: expect.any(Date),
|
||||
@@ -173,8 +173,8 @@ describe(MemoryService.name, () => {
|
||||
await sut.onMemoriesCreate();
|
||||
|
||||
const memories = await memoryRepo.search(user.id, {});
|
||||
expect(memories.items.length).toBe(1);
|
||||
expect(memories.items[0]).toEqual(
|
||||
expect(memories.length).toBe(1);
|
||||
expect(memories[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
createdAt: expect.any(Date),
|
||||
@@ -228,12 +228,12 @@ describe(MemoryService.name, () => {
|
||||
await sut.onMemoriesCreate();
|
||||
|
||||
const memories = await memoryRepo.search(user.id, {});
|
||||
expect(memories.items.length).toBe(1);
|
||||
expect(memories.length).toBe(1);
|
||||
|
||||
await sut.onMemoriesCreate();
|
||||
|
||||
const memoriesAfter = await memoryRepo.search(user.id, {});
|
||||
expect(memoriesAfter.items.length).toBe(1);
|
||||
expect(memoriesAfter.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import {
|
||||
deleteMemory,
|
||||
type MemoryResponseDto,
|
||||
removeMemoryAssets,
|
||||
searchMemories,
|
||||
updateMemory,
|
||||
MemorySearchOrder,
|
||||
MemoryType,
|
||||
} from '@immich/sdk';
|
||||
import { deleteMemory, type MemoryResponseDto, removeMemoryAssets, searchMemories, updateMemory } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
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 { asLocalTimeISO } from '$lib/utils/date-time';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
|
||||
type MemoryIndex = {
|
||||
@@ -27,31 +20,10 @@ export type MemoryAsset = MemoryIndex & {
|
||||
nextMemory?: MemoryResponseDto;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 250;
|
||||
|
||||
class MemoryManager {
|
||||
#loading: Promise<void> | undefined;
|
||||
#filters:
|
||||
| {
|
||||
$for?: string;
|
||||
isSaved?: boolean;
|
||||
isTrashed?: boolean;
|
||||
order?: MemorySearchOrder;
|
||||
page?: number;
|
||||
size?: number;
|
||||
$type?: MemoryType;
|
||||
}
|
||||
| undefined;
|
||||
#hasNextPage: boolean;
|
||||
#page: number;
|
||||
#total: number | undefined;
|
||||
|
||||
constructor() {
|
||||
this.#filters = undefined;
|
||||
this.#hasNextPage = true;
|
||||
this.#page = 1;
|
||||
this.#total = $state(undefined);
|
||||
|
||||
eventManager.on({
|
||||
AuthLogout: () => this.clearCache(),
|
||||
AuthUserLoaded: () => this.initialize(),
|
||||
@@ -65,16 +37,6 @@ class MemoryManager {
|
||||
this.scheduleHourlyRefresh();
|
||||
}
|
||||
|
||||
get filters() {
|
||||
return this.#filters;
|
||||
}
|
||||
|
||||
set filters(filters) {
|
||||
this.#filters = filters;
|
||||
this.clearCache();
|
||||
void this.loadNextPage();
|
||||
}
|
||||
|
||||
ready() {
|
||||
return this.initialize();
|
||||
}
|
||||
@@ -155,47 +117,22 @@ class MemoryManager {
|
||||
}
|
||||
}
|
||||
|
||||
loadNextPage() {
|
||||
if (this.#hasNextPage) {
|
||||
if (this.#loading === undefined) {
|
||||
this.#loading = this.load(this.#page++);
|
||||
} else {
|
||||
void this.#loading.then(() => (this.#loading = this.load(this.#page++)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get hasNextPage() {
|
||||
return this.#hasNextPage;
|
||||
}
|
||||
|
||||
get total() {
|
||||
return this.#total;
|
||||
}
|
||||
|
||||
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.#page++);
|
||||
this.#loading = this.load();
|
||||
}
|
||||
|
||||
return this.#loading;
|
||||
}
|
||||
|
||||
private async load(page: number) {
|
||||
if (this.#filters !== undefined) {
|
||||
const { items, hasNextPage, total } = await searchMemories({ size: PAGE_SIZE, ...this.#filters, page });
|
||||
this.memories.push(...items);
|
||||
this.#hasNextPage = hasNextPage;
|
||||
this.#total = total;
|
||||
}
|
||||
private async load() {
|
||||
const memories = await searchMemories({ $for: asLocalTimeISO(DateTime.now()) });
|
||||
this.memories = memories.filter((memory) => memory.assets.length > 0);
|
||||
}
|
||||
|
||||
private scheduleHourlyRefresh() {
|
||||
@@ -209,18 +146,12 @@ class MemoryManager {
|
||||
const initialDelay = nextEvent.diff(now).as('milliseconds');
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.#page <= 2) {
|
||||
this.clearCache();
|
||||
this.loadNextPage();
|
||||
}
|
||||
this.#loading = this.load();
|
||||
|
||||
// Schedule subsequent events hourly
|
||||
setInterval(
|
||||
() => {
|
||||
if (this.#page <= 2) {
|
||||
this.clearCache();
|
||||
this.loadNextPage();
|
||||
}
|
||||
this.#loading = this.load();
|
||||
},
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
|
||||
@@ -20,13 +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 { downloadManager } from '$lib/managers/download-manager.svelte';
|
||||
import { alwaysLoadOriginalFile, lang, locale } from '$lib/stores/preferences.store';
|
||||
import { alwaysLoadOriginalFile, lang } 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';
|
||||
@@ -367,13 +366,9 @@ export const handlePromiseError = <T>(promise: Promise<T>): void => {
|
||||
|
||||
export const memoryLaneTitle = derived(t, ($t) => {
|
||||
return (memory: MemoryResponseDto) => {
|
||||
const now = new Date();
|
||||
if (memory.type === MemoryType.OnThisDay) {
|
||||
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('years_ago', { values: { years: now.getFullYear() - memory.data.year } });
|
||||
}
|
||||
|
||||
return $t('unknown');
|
||||
|
||||
@@ -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, memoryLaneTitle } from '$lib/utils';
|
||||
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { getAssetInfo, AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { Icon, ImageCarousel } from '@immich/ui';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiHeart } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
@@ -28,22 +28,13 @@
|
||||
return targetField?.items || [];
|
||||
};
|
||||
|
||||
let places = $derived(getFieldItems(data.explore, 'exifInfo.city'));
|
||||
let places = $derived(getFieldItems(data.items, 'exifInfo.city'));
|
||||
let recents = $derived(
|
||||
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 }),
|
||||
})),
|
||||
getFieldItems(data.items, 'createdAt').sort((a, b) => new Date(b.value).getTime() - new Date(a.value).getTime()),
|
||||
);
|
||||
let people = $state(data.response.people);
|
||||
|
||||
let hasPeople = $derived(data.people.total > 0);
|
||||
let hasPeople = $derived(data.response.total > 0);
|
||||
|
||||
const onPersonThumbnailReady = ({ id }: { id: string }) => {
|
||||
for (const person of people) {
|
||||
@@ -133,20 +124,6 @@
|
||||
</div>
|
||||
{/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}
|
||||
<div class="mt-2 mb-6">
|
||||
<div class="flex justify-between">
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
import { getAllPeople, getExploreData, MemorySearchOrder } from '@immich/sdk';
|
||||
import { memoryManager } from '$lib/managers/memory-manager.svelte';
|
||||
import { getAllPeople, getExploreData } from '@immich/sdk';
|
||||
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);
|
||||
memoryManager.filters = { size: 12, order: MemorySearchOrder.Desc };
|
||||
|
||||
const [explore, people] = await Promise.all([
|
||||
getExploreData(),
|
||||
getAllPeople({ withHidden: false }),
|
||||
memoryManager.ready(),
|
||||
]);
|
||||
const [items, response] = await Promise.all([getExploreData(), getAllPeople({ withHidden: false })]);
|
||||
const $t = await getFormatter();
|
||||
|
||||
return {
|
||||
explore,
|
||||
people,
|
||||
memories: memoryManager.memories,
|
||||
items,
|
||||
response,
|
||||
meta: {
|
||||
title: $t('explore'),
|
||||
},
|
||||
|
||||
@@ -1,127 +1,5 @@
|
||||
<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';
|
||||
<script>
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
let onlyFavorites = $state(page.url.searchParams.get('favorites') === 'true');
|
||||
let lastElement: HTMLElement | undefined = $state();
|
||||
|
||||
const toggleFavorites = async () => {
|
||||
onlyFavorites = !onlyFavorites;
|
||||
memoryManager.filters = onlyFavorites ? { isSaved: true } : {};
|
||||
await memoryManager.ready();
|
||||
|
||||
if (onlyFavorites) {
|
||||
void setQueryValue('favorites', 'true');
|
||||
} else {
|
||||
void 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) {
|
||||
intersectionObserver.disconnect();
|
||||
intersectionObserver.observe(lastElement);
|
||||
}
|
||||
});
|
||||
|
||||
const rotation = () => {
|
||||
const classes = [
|
||||
'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]',
|
||||
];
|
||||
|
||||
return classes[Math.round(Math.random() * classes.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-gray-50 p-2 pb-0 shadow-md transition-all hover:scale-102 hover:rotate-0 hover:shadow-lg sm:p-5 sm:pb-0 dark:bg-gray-800 ${rotation()}`}
|
||||
bind:this={
|
||||
() => (index === memoryManager.memories.length - 1 ? lastElement : null),
|
||||
(e) => {
|
||||
if (index === memoryManager.memories.length - 1) {
|
||||
lastElement = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
>
|
||||
{#if memory.isSaved}
|
||||
<div class="absolute inset-s-2 top-2 z-2">
|
||||
<Icon data-icon-favorite icon={mdiHeart} size="32" class="text-red-400" />
|
||||
</div>
|
||||
{/if}
|
||||
<img
|
||||
src={getAssetMediaUrl({ id: memory.assets[0].id })}
|
||||
alt={$getAltText(toTimelineAsset(memory.assets[0]))}
|
||||
class="aspect-square object-cover brightness-75"
|
||||
loading="lazy"
|
||||
/>
|
||||
<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.total === undefined}
|
||||
<div class="flex items-center justify-center py-16">
|
||||
<LoadingSpinner size="giant" />
|
||||
</div>
|
||||
{/if}
|
||||
</UserPageLayout>
|
||||
{/if}
|
||||
<MemoryViewer />
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
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';
|
||||
@@ -9,19 +6,10 @@ 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.ready();
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
meta: {
|
||||
title: $t('memories'),
|
||||
title: $t('memory'),
|
||||
},
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
|
||||
@@ -82,7 +82,6 @@
|
||||
let progressBarController: Tween<number> | undefined = $state(undefined);
|
||||
let videoPlayer: HTMLVideoElement | undefined = $state();
|
||||
const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
|
||||
let previousPage = $state(Route.memories());
|
||||
|
||||
const handleNavigate = async (asset?: { id: string }) => {
|
||||
if (assetViewerManager.isViewing) {
|
||||
@@ -107,7 +106,7 @@
|
||||
const handlePreviousAsset = () => handleNavigate(current?.previous?.asset);
|
||||
const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]);
|
||||
const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]);
|
||||
const handleEscape = async () => goto(previousPage);
|
||||
const handleEscape = async () => goto(Route.photos());
|
||||
const handleSelectAll = () =>
|
||||
assetMultiSelectManager.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []);
|
||||
|
||||
@@ -250,7 +249,7 @@
|
||||
|
||||
const init = (target: Page | NavigationTarget | null) => {
|
||||
if (memoryManager.memories.length === 0) {
|
||||
return handlePromiseError(goto(previousPage));
|
||||
return handlePromiseError(goto(Route.photos()));
|
||||
}
|
||||
|
||||
current = loadFromParams(target);
|
||||
@@ -282,10 +281,6 @@
|
||||
};
|
||||
|
||||
afterNavigate(({ from, to }) => {
|
||||
if (from?.url !== null && !from?.url.searchParams.has(QueryParameter.ID)) {
|
||||
previousPage = from!.url.toString();
|
||||
}
|
||||
|
||||
memoryManager.ready().then(
|
||||
() => {
|
||||
let target;
|
||||
@@ -386,7 +381,7 @@
|
||||
icon={mdiClose}
|
||||
aria-label={$t('close')}
|
||||
size="large"
|
||||
onclick={() => goto(previousPage)}
|
||||
onclick={() => goto(Route.photos())}
|
||||
/>
|
||||
<p class="text-lg">
|
||||
{$memoryLaneTitle(current.memory)}
|
||||
|
||||
@@ -33,14 +33,12 @@
|
||||
type OnLink,
|
||||
type OnUnlink,
|
||||
} from '$lib/utils/actions';
|
||||
import { asLocalTimeISO } from '$lib/utils/date-time';
|
||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel } from '@immich/ui';
|
||||
import { mdiDotsVertical } from '@mdi/js';
|
||||
import { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
let timelineManager = $state<TimelineManager>() as TimelineManager;
|
||||
@@ -92,10 +90,6 @@
|
||||
src: getAssetMediaUrl({ id: memory.assets[0].id }),
|
||||
})),
|
||||
);
|
||||
|
||||
if (memoryManager.filters === undefined || memoryManager.filters.$for !== asLocalTimeISO(DateTime.now())) {
|
||||
memoryManager.filters = { $for: asLocalTimeISO(DateTime.now()) };
|
||||
}
|
||||
</script>
|
||||
|
||||
<UserPageLayout hideNavbar={assetMultiSelectManager.selectionActive} scrollbar={false}>
|
||||
|
||||
Reference in New Issue
Block a user